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 ? '处理中...' : '添加到草稿'}
+
+
+ }
+ >
+