From 28b13059066d264baca79a9da9953a21662bfc9f Mon Sep 17 00:00:00 2001 From: ryan Date: Sat, 30 May 2026 13:08:42 +0800 Subject: [PATCH] =?UTF-8?q?[=E4=BC=98=E5=8C=96]=20WAF=20=E7=95=8C=E9=9D=A2?= =?UTF-8?q?=E4=BC=98=E5=8C=96?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../config-version-snapshot-modal.tsx | 1 - .../components/config-versions-page.tsx | 1 - .../web/features/waf/components/waf-page.tsx | 1678 ++++++++++++----- 3 files changed, 1225 insertions(+), 455 deletions(-) diff --git a/openflare_server/web/features/config-versions/components/config-version-snapshot-modal.tsx b/openflare_server/web/features/config-versions/components/config-version-snapshot-modal.tsx index 4c29508c..3b312947 100644 --- a/openflare_server/web/features/config-versions/components/config-version-snapshot-modal.tsx +++ b/openflare_server/web/features/config-versions/components/config-version-snapshot-modal.tsx @@ -41,7 +41,6 @@ export function ConfigVersionSnapshotModal({ isOpen={Boolean(version)} onClose={onClose} title={version ? `版本 ${version.version}` : '查看快照'} - description="在弹窗中查看快照 JSON 与渲染结果。" size="xl" footer={
diff --git a/openflare_server/web/features/config-versions/components/config-versions-page.tsx b/openflare_server/web/features/config-versions/components/config-versions-page.tsx index d86d92f3..7f0855aa 100644 --- a/openflare_server/web/features/config-versions/components/config-versions-page.tsx +++ b/openflare_server/web/features/config-versions/components/config-versions-page.tsx @@ -442,7 +442,6 @@ export function ConfigVersionsPage() { message: `发布成功,版本 ${version.version}`, }); setPublishPreview(null); - setSelectedVersionId(version.id); await queryClient.invalidateQueries({ queryKey: versionsQueryKey }); }, onError: (error) => { diff --git a/openflare_server/web/features/waf/components/waf-page.tsx b/openflare_server/web/features/waf/components/waf-page.tsx index fb278cf5..96a3a8f3 100644 --- a/openflare_server/web/features/waf/components/waf-page.tsx +++ b/openflare_server/web/features/waf/components/waf-page.tsx @@ -1,213 +1,700 @@ '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 {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 { Drawer } from '@/components/ui/drawer'; -import { getProxyRoutes } from '@/features/proxy-routes/api/proxy-routes'; -import type { ProxyRouteItem } from '@/features/proxy-routes/types'; +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, + DangerButton, + PrimaryButton, + ResourceField, + ResourceInput, + ResourceTextarea, + SecondaryButton, + ToggleField, } from '@/features/shared/components/resource-primitives'; import { - createWAFRuleGroup, - deleteWAFRuleGroup, - getWAFRuleGroups, - replaceWAFRuleGroupSites, - updateWAFRuleGroup, + 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 type {WAFRuleGroup, WAFRuleGroupPayload} from '@/features/waf/types'; +import {cn} from '@/lib/utils/cn'; type FeedbackState = { - tone: 'success' | 'danger' | 'info'; - message: string; + 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: '', + 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 : '操作失败'; -} +const defaultRuleModalState: RuleModalState = { + open: false, + listType: 'whitelist', + dimension: 'ip', + ipValue: '', + countryValues: [], +}; -function listToText(items: string[]) { - return items.join('\n'); +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); + 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 ?? '', - }; + 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 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; + 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([]); + const [keyword, setKeyword] = useState(''); + const [selectedIDs, setSelectedIDs] = useState([]); - useEffect(() => { - setSelectedIDs(group?.applied_site_ids ?? []); - setKeyword(''); - }, [group, open]); + 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 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 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)); - }; + 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) => ( -
+ } + > +
+
+ + 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 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 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], - ); + 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], + ); - useEffect(() => { - if (selectedGroup) { - setSelectedID(selectedGroup.id); - setDraft(buildDraft(selectedGroup)); + 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 ; } - }, [selectedGroup]); - const invalidate = async () => { - await Promise.all([ - queryClient.invalidateQueries({ queryKey: ['waf', 'rule-groups'] }), - queryClient.invalidateQueries({ queryKey: ['config-versions', 'diff'] }), - ]); - }; + 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 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 openRuleModal = () => { + setRuleModal({...defaultRuleModalState, open: true}); + }; - 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 closeRuleModal = () => { + setRuleModal(defaultRuleModalState); + }; - 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) }); - }, - }); + const applyRuleModal = () => { + const values = + ruleModal.dimension === 'ip' + ? textToList(ruleModal.ipValue) + : normalizeItems(ruleModal.countryValues); - if (groupsQuery.isLoading || routesQuery.isLoading) { - return ; - } - if (groupsQuery.isError) { - return ; - } - if (routesQuery.isError) { - return ; - } - if (!selectedGroup && groups.length === 0) { - return ; - } + if (values.length === 0) { + setFeedback({ + tone: 'danger', + message: ruleModal.dimension === 'ip' ? '请先输入 IP 或 IP 段。' : '请先选择地域。', + }); + 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); + const listKey = getListFieldKey(ruleModal.listType, ruleModal.dimension); + setDraft((current) => + updateDraftList(current, listKey, (items) => normalizeItems([...items, ...values])), + ); + setFeedback({tone: 'info', message: '名单项已添加到当前草稿,保存后生效。'}); + closeRuleModal(); + setActiveTab('lists'); + }; - return ( - <> -
- { - setSelectedID(0); - setDraft({ ...emptyDraft, name: '自定义规则组' }); - }} - > - - 新建规则组 - - } - /> + const removeRuleItem = (key: ListFieldKey, value: string) => { + setDraft((current) => + updateDraftList(current, key, (items) => items.filter((item) => item !== value)), + ); + }; - {feedback ? : null} + 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') + : '未保存', + }, + ]; -
- -

启用规则组

-

{enabledCount}

-
- -

自定义覆盖网站

-

{protectedSites.size}

-
- -

黑白名单条目

-

{totalRules}

-
-
+ return ( + <> +
+ { + setSelectedID(0); + setActiveTab('basic'); + setDraft({...emptyDraft, name: '自定义规则组'}); + }} + > + + 新建规则组 + + } + /> -
- -
- {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 ? ( + 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} +
+ +
+
+
+
-
-
- {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 }); - } - }} - /> - - ); + + setRuleModal((current) => ({...current, ...patch, open: true}))} + onSubmit={applyRuleModal} + /> + + { + if (!open) { + setApplyGroup(null); + } + }} + onSave={(ids) => { + if (applyGroup) { + applyMutation.mutate({id: applyGroup.id, ids}); + } + }} + /> + + ); }