'use client'; import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'; import { ArrowLeft, Download, Plus, Save, Trash2 } from 'lucide-react'; import { useRouter } from 'next/navigation'; 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 { createWAFIPGroup, deleteWAFIPGroup, getWAFIPGroups, syncWAFIPGroup, updateWAFIPGroup, } from '@/features/waf/api/waf'; import type { WAFIPGroup, WAFIPGroupPayload, WAFIPGroupSubscriptionFormat, WAFIPGroupType, } from '@/features/waf/types'; import { DangerButton, PrimaryButton, ResourceField, ResourceInput, ResourceSelect, ResourceTextarea, SecondaryButton, ToggleField, } from '@/features/shared/components/resource-primitives'; import { cn } from '@/lib/utils/cn'; import { getErrorMessage, listToText, parseTextareaList } from './helpers'; type FeedbackState = { tone: 'success' | 'danger' | 'info'; message: string; }; type IPGroupDraft = WAFIPGroupPayload & { ip_list_text: string; auto_config_text: string; }; const emptyIPGroupDraft: IPGroupDraft = { name: '', type: 'manual', enabled: true, ip_list: [], ip_list_text: '', auto_config: {}, auto_config_text: '{}', subscription_url: '', subscription_format: 'text', subscription_mapping_rule: '', sync_interval_minutes: 1440, remark: '', }; const typeLabels: Record = { manual: '手动', automatic: '自动', subscription: '订阅', }; function buildDraft(group: WAFIPGroup | null): IPGroupDraft { if (!group) { return { ...emptyIPGroupDraft }; } return { name: group.name, type: group.type, enabled: group.enabled, ip_list: group.ip_list ?? [], ip_list_text: listToText(group.ip_list), auto_config: group.auto_config ?? {}, auto_config_text: JSON.stringify(group.auto_config ?? {}, null, 2), subscription_url: group.subscription_url ?? '', subscription_format: group.subscription_format ?? 'text', subscription_mapping_rule: group.subscription_mapping_rule ?? '', sync_interval_minutes: group.sync_interval_minutes || 1440, remark: group.remark ?? '', }; } function buildPayload(draft: IPGroupDraft): WAFIPGroupPayload { let autoConfig: Record = {}; if (draft.type === 'automatic') { const parsed = JSON.parse(draft.auto_config_text || '{}') as unknown; if (!parsed || Array.isArray(parsed) || typeof parsed !== 'object') { throw new Error('自动配置必须是 JSON 对象。'); } autoConfig = parsed as Record; } return { name: draft.name, type: draft.type, enabled: draft.enabled, ip_list: parseTextareaList(draft.ip_list_text), auto_config: autoConfig, subscription_url: draft.subscription_url, subscription_format: draft.subscription_format, subscription_mapping_rule: draft.subscription_mapping_rule, sync_interval_minutes: draft.sync_interval_minutes, remark: draft.remark, }; } export function WAFIPGroupsPage() { const router = useRouter(); const queryClient = useQueryClient(); const [selectedID, setSelectedID] = useState(null); const [draft, setDraft] = useState(emptyIPGroupDraft); const [feedback, setFeedback] = useState(null); const groupsQuery = useQuery({ queryKey: ['waf', 'ip-groups'], queryFn: getWAFIPGroups, }); const groups = useMemo(() => groupsQuery.data ?? [], [groupsQuery.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', 'ip-groups'] }), queryClient.invalidateQueries({ queryKey: ['waf', 'rule-groups'] }), queryClient.invalidateQueries({ queryKey: ['config-versions', 'diff'] }), ]); }; const saveMutation = useMutation({ mutationFn: (payload: WAFIPGroupPayload) => { if (selectedGroup) { return updateWAFIPGroup(selectedGroup.id, payload); } return createWAFIPGroup(payload); }, onSuccess: async (group) => { setSelectedID(group.id); setFeedback({ tone: 'success', message: 'IP 组已保存。' }); await invalidate(); }, onError: (error) => { setFeedback({ tone: 'danger', message: getErrorMessage(error) }); }, }); const deleteMutation = useMutation({ mutationFn: deleteWAFIPGroup, onSuccess: async () => { setSelectedID(null); setFeedback({ tone: 'success', message: 'IP 组已删除。' }); await invalidate(); }, onError: (error) => { setFeedback({ tone: 'danger', message: getErrorMessage(error) }); }, }); const syncMutation = useMutation({ mutationFn: syncWAFIPGroup, onSuccess: async (result) => { setSelectedID(result.group.id); setFeedback({ tone: 'success', message: result.message }); await invalidate(); }, onError: (error) => { setFeedback({ tone: 'danger', message: getErrorMessage(error) }); }, }); if (groupsQuery.isLoading) { return ; } if (groupsQuery.isError) { return ( ); } const saveDraft = () => { try { saveMutation.mutate(buildPayload(draft)); } catch (error) { setFeedback({ tone: 'danger', message: getErrorMessage(error) }); } }; return (
router.push('/waf')}> 返回 WAF { setSelectedID(0); setDraft({ ...emptyIPGroupDraft, name: '自定义 IP 组' }); }} > 新建 IP 组
} /> {feedback ? ( ) : null}
{groups.length === 0 && selectedID !== 0 ? ( ) : (
{groups.map((group) => ( ))}
)}
{selectedGroup?.type === 'subscription' ? ( syncMutation.mutate(selectedGroup.id)} > {syncMutation.isPending ? '同步中...' : '立即同步'} ) : null} {saveMutation.isPending ? '保存中...' : '保存 IP 组'}
} >
setDraft((current) => ({ ...current, name: event.target.value, })) } /> setDraft((current) => ({ ...current, type: event.target.value as WAFIPGroupType, })) } > setDraft((current) => ({ ...current, enabled: checked })) } /> setDraft((current) => ({ ...current, remark: event.target.value, })) } />
{draft.type === 'subscription' ? (
setDraft((current) => ({ ...current, subscription_url: event.target.value, })) } /> setDraft((current) => ({ ...current, subscription_format: event.target .value as WAFIPGroupSubscriptionFormat, })) } > setDraft((current) => ({ ...current, sync_interval_minutes: Number(event.target.value), })) } /> setDraft((current) => ({ ...current, subscription_mapping_rule: event.target.value, })) } />
) : null} {draft.type === 'automatic' ? ( setDraft((current) => ({ ...current, auto_config_text: event.target.value, })) } /> ) : ( setDraft((current) => ({ ...current, ip_list_text: event.target.value, })) } /> )} {selectedGroup ? (
{selectedGroup.last_sync_status ? `${selectedGroup.last_sync_status}: ${selectedGroup.last_sync_message}` : '尚无同步记录'}
{ if ( window.confirm( `确认删除 IP 组 ${selectedGroup.name} 吗?`, ) ) { deleteMutation.mutate(selectedGroup.id); } }} > 删除
) : null}
); }