[优化] go 引用调整

This commit is contained in:
ryan
2026-06-06 10:26:20 +08:00
parent ee1110b752
commit 3cfefb4367
552 changed files with 1642 additions and 2185 deletions
@@ -0,0 +1,217 @@
import type {ProxyRoutePoWConfig} from '@/features/proxy-routes/types';
import type {WAFRuleGroup, WAFRuleGroupPayload} from '@/features/waf/types';
import type {
CountryOption,
ListFieldKey,
RuleDimension,
RuleListRenderable,
RuleListType,
RuleModalState,
WAFTab,
} from './types';
export const defaultPowConfig: ProxyRoutePoWConfig = {
difficulty: 4,
algorithm: 'fast',
session_ttl: 600,
challenge_ttl: 300,
whitelist: {
ips: [],
ip_cidrs: [],
paths: [],
path_regexes: [],
user_agents: [],
},
blacklist: {
ips: [],
ip_cidrs: [],
paths: [],
path_regexes: [],
user_agents: [],
},
};
export const emptyDraft: WAFRuleGroupPayload = {
name: '',
enabled: true,
block_status_code: 418,
block_response_body: '',
ip_whitelist: [],
ip_blacklist: [],
ip_whitelist_group_ids: [],
ip_blacklist_group_ids: [],
country_whitelist: [],
country_blacklist: [],
region_whitelist: [],
region_blacklist: [],
pow_enabled: false,
pow_config: defaultPowConfig,
remark: '',
};
export const defaultRuleModalState: RuleModalState = {
open: false,
listType: 'blacklist',
dimension: 'ip',
ipValue: '',
ipGroupIDs: [],
countryValues: [],
};
export const tabItems: Array<{
id: WAFTab;
label: string;
}> = [
{ id: 'basic', label: '基本信息' },
{ id: 'lists', label: '黑白名单' },
{ id: 'pow', label: 'PoW' },
{ id: 'block', label: '拦截返回' },
];
export function getErrorMessage(error: unknown) {
return error instanceof Error ? error.message : '操作失败';
}
export function textToList(text: string) {
return text
.split(/[\n,,\s]+/)
.map((item) => item.trim())
.filter(Boolean);
}
export function listToText(items: string[] | undefined) {
return (items ?? []).join('\n');
}
export function parseTextareaList(text: string) {
return text
.split(/\r?\n/)
.map((item) => item.trim())
.filter(Boolean);
}
export function normalizeItems(items: string[]) {
return Array.from(
new Set(items.map((item) => item.trim()).filter(Boolean)),
).sort((left, right) => left.localeCompare(right));
}
export 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 ?? [],
ip_whitelist_group_ids: group.ip_whitelist_group_ids ?? [],
ip_blacklist_group_ids: group.ip_blacklist_group_ids ?? [],
country_whitelist: group.country_whitelist ?? [],
country_blacklist: group.country_blacklist ?? [],
region_whitelist: group.region_whitelist ?? [],
region_blacklist: group.region_blacklist ?? [],
pow_enabled: group.pow_enabled ?? false,
pow_config: group.pow_config ?? defaultPowConfig,
remark: group.remark ?? '',
};
}
export function countRuleEntries(group: RuleListRenderable) {
return (
group.ip_whitelist.length +
group.ip_blacklist.length +
group.ip_whitelist_group_ids.length +
group.ip_blacklist_group_ids.length +
group.country_whitelist.length +
group.country_blacklist.length +
group.region_whitelist.length +
group.region_blacklist.length
);
}
export 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));
}
export function getListFieldKey(
listType: RuleListType,
dimension: RuleDimension,
): ListFieldKey {
if (dimension === 'ip') {
return listType === 'whitelist' ? 'ip_whitelist' : 'ip_blacklist';
}
if (dimension === 'ip_group') {
return listType === 'whitelist'
? 'ip_whitelist_group_ids'
: 'ip_blacklist_group_ids';
}
return listType === 'whitelist' ? 'country_whitelist' : 'country_blacklist';
}
export 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 'ip_whitelist_group_ids':
return {
...draft,
ip_whitelist_group_ids: updater(
draft.ip_whitelist_group_ids.map(String),
).map(Number),
};
case 'ip_blacklist_group_ids':
return {
...draft,
ip_blacklist_group_ids: updater(
draft.ip_blacklist_group_ids.map(String),
).map(Number),
};
case 'country_whitelist':
return { ...draft, country_whitelist: updater(draft.country_whitelist) };
case 'country_blacklist':
return { ...draft, country_blacklist: updater(draft.country_blacklist) };
}
}
export function formatCountryItem(code: string, labelMap: Map<string, string>) {
return labelMap.get(code) ?? code;
}
@@ -0,0 +1,784 @@
'use client';
import {useMutation, useQuery, useQueryClient} from '@tanstack/react-query';
import {ArrowLeft, Download, Eye, Play, 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 {AppModal} from '@/components/ui/app-modal';
import {
createWAFIPGroup,
deleteWAFIPGroup,
getWAFIPGroups,
syncWAFIPGroup,
testWAFIPGroupAutoConfig,
updateWAFIPGroup,
} from '@/features/waf/api/waf';
import type {
WAFIPGroup,
WAFIPGroupAutoTestResult,
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<WAFIPGroupType, string> = {
manual: '手动',
automatic: '自动',
subscription: '订阅',
};
const automaticPresetRules = [
{
name: '单 IP 404 高频扫描',
expr: 'request_count > 100 && StatusRatio(404) >= 0.8',
},
{
name: '单 IP 直连访问异常',
expr: 'ip_host_count > 50 && ip_host_ratio > 0.5',
},
];
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 {
const autoConfig =
draft.type === 'automatic'
? parseAutomaticConfig(draft.auto_config_text)
: {};
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,
};
}
function parseAutomaticConfig(text: string): Record<string, unknown> {
const parsed = JSON.parse(text || '{}') as unknown;
if (!parsed || Array.isArray(parsed) || typeof parsed !== 'object') {
throw new Error('自动配置必须是 JSON 对象。');
}
return parsed as Record<string, unknown>;
}
function appendAutomaticPresetRule(
autoConfigText: string,
rule: (typeof automaticPresetRules)[number],
) {
const parsed = JSON.parse(autoConfigText || '{}') as unknown;
if (!parsed || Array.isArray(parsed) || typeof parsed !== 'object') {
throw new Error('自动配置必须是 JSON 对象。');
}
const config = parsed as Record<string, unknown>;
const rules = Array.isArray(config.rules) ? config.rules : [];
const exists = rules.some(
(item) =>
item &&
typeof item === 'object' &&
'expr' in item &&
(item as { expr?: unknown }).expr === rule.expr,
);
const nextRules = exists ? rules : [...rules, rule];
return JSON.stringify(
{
lookback_minutes:
typeof config.lookback_minutes === 'number'
? config.lookback_minutes
: 60,
...config,
rules: nextRules,
},
null,
2,
);
}
export function WAFIPGroupsPage() {
const router = useRouter();
const queryClient = useQueryClient();
const [selectedID, setSelectedID] = useState<number | null>(null);
const [draft, setDraft] = useState<IPGroupDraft>(emptyIPGroupDraft);
const [feedback, setFeedback] = useState<FeedbackState | null>(null);
const [autoTestResult, setAutoTestResult] =
useState<WAFIPGroupAutoTestResult | null>(null);
const [isCapturedIPsModalOpen, setIsCapturedIPsModalOpen] = useState(false);
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) });
},
});
const testMutation = useMutation({
mutationFn: testWAFIPGroupAutoConfig,
onSuccess: (result) => {
setAutoTestResult(result);
setFeedback({
tone: result.matched_count > 0 ? 'success' : 'info',
message:
result.matched_count > 0
? `规则测试完成,命中 ${result.matched_count} 个 IP。`
: '规则测试完成,当前未命中任何 IP。',
});
},
onError: (error) => {
setAutoTestResult(null);
setFeedback({ tone: 'danger', message: getErrorMessage(error) });
},
});
useEffect(() => {
setAutoTestResult(null);
}, [draft.type, draft.auto_config_text, selectedID]);
if (groupsQuery.isLoading) {
return <LoadingState />;
}
if (groupsQuery.isError) {
return (
<ErrorState
title="IP 组加载失败"
description={getErrorMessage(groupsQuery.error)}
/>
);
}
const saveDraft = () => {
try {
saveMutation.mutate(buildPayload(draft));
} catch (error) {
setFeedback({ tone: 'danger', message: getErrorMessage(error) });
}
};
const testDraft = () => {
try {
testMutation.mutate({
auto_config: parseAutomaticConfig(draft.auto_config_text),
});
} catch (error) {
setAutoTestResult(null);
setFeedback({ tone: 'danger', message: getErrorMessage(error) });
}
};
return (
<div className="space-y-6">
<PageHeader
title="IP 组"
description="维护可被 WAF IP 黑白名单引用的手动、自动与订阅 IP 集合。"
action={
<div className="flex flex-wrap gap-3">
<SecondaryButton type="button" onClick={() => router.push('/waf')}>
<ArrowLeft className="mr-2 h-4 w-4" />
返回 WAF
</SecondaryButton>
<PrimaryButton
type="button"
onClick={() => {
setSelectedID(0);
setDraft({ ...emptyIPGroupDraft, name: '自定义 IP 组' });
}}
>
<Plus className="mr-2 h-4 w-4" />
新建 IP 组
</PrimaryButton>
</div>
}
/>
{feedback ? (
<InlineMessage
tone={feedback.tone}
message={feedback.message}
onClear={() => setFeedback(null)}
/>
) : null}
<div className="grid gap-5 xl:grid-cols-[360px_minmax(0,1fr)]">
<AppCard title="IP 组列表">
{groups.length === 0 && selectedID !== 0 ? (
<EmptyState title="暂无 IP 组" />
) : (
<div className="space-y-2">
{groups.map((group) => (
<button
key={group.id}
type="button"
onClick={() => setSelectedID(group.id)}
className={cn(
'w-full rounded-2xl border px-4 py-3 text-left transition',
selectedGroup?.id === group.id
? 'border-[var(--border-strong)] bg-[var(--accent-soft)]'
: 'border-[var(--border-default)] bg-[var(--surface-elevated)] hover:bg-[var(--surface-muted)]',
)}
>
<span className="flex items-center justify-between gap-3">
<span className="truncate text-sm font-semibold text-[var(--foreground-primary)]">
{group.name}
</span>
<span className="text-xs text-[var(--foreground-secondary)]">
{typeLabels[group.type]}
</span>
</span>
<span className="mt-2 block text-xs text-[var(--foreground-secondary)]">
{group.enabled ? '启用' : '停用'} · {group.ip_list.length}{' '}
条 · 被引用 {group.referenced_by_rule_count} 次
</span>
</button>
))}
</div>
)}
</AppCard>
<AppCard
title={selectedGroup ? selectedGroup.name : '新建 IP 组'}
description={
draft.type === 'automatic'
? '自动 IP 组会按 Expr 规则定时从请求日志中聚合命中 IP。'
: '保存后可在 WAF 规则组黑白名单中引用。'
}
action={
<div className="flex flex-wrap gap-3">
{draft.type === 'automatic' ? (
<SecondaryButton
type="button"
disabled={testMutation.isPending}
onClick={testDraft}
>
<Play className="mr-2 h-4 w-4" />
{testMutation.isPending ? '测试中...' : '测试规则'}
</SecondaryButton>
) : null}
{selectedGroup?.type === 'subscription' ||
selectedGroup?.type === 'automatic' ? (
<SecondaryButton
type="button"
disabled={syncMutation.isPending}
onClick={() => syncMutation.mutate(selectedGroup.id)}
>
<Download className="mr-2 h-4 w-4" />
{syncMutation.isPending
? '执行中...'
: selectedGroup.type === 'automatic'
? '立即执行'
: '立即同步'}
</SecondaryButton>
) : null}
{selectedGroup?.type === 'automatic' ? (
<SecondaryButton
type="button"
onClick={() => setIsCapturedIPsModalOpen(true)}
>
<Eye className="mr-2 h-4 w-4" />
查看已抓取 IP
</SecondaryButton>
) : null}
<PrimaryButton
type="button"
disabled={saveMutation.isPending}
onClick={saveDraft}
>
<Save className="mr-2 h-4 w-4" />
{saveMutation.isPending ? '保存中...' : '保存 IP 组'}
</PrimaryButton>
</div>
}
>
<div className="space-y-6">
<div className="grid gap-5 xl:grid-cols-2">
<ResourceField label="IP 组名称">
<ResourceInput
value={draft.name}
onChange={(event) =>
setDraft((current) => ({
...current,
name: event.target.value,
}))
}
/>
</ResourceField>
<ResourceField label="类型">
<ResourceSelect
value={draft.type}
onChange={(event) =>
setDraft((current) => ({
...current,
type: event.target.value as WAFIPGroupType,
}))
}
>
<option value="manual">手动</option>
<option value="automatic">自动</option>
<option value="subscription">订阅</option>
</ResourceSelect>
</ResourceField>
<ToggleField
label="启用 IP 组"
description="关闭后保留配置,但发布时不会展开到 WAF 运行时名单。"
checked={draft.enabled}
onChange={(checked) =>
setDraft((current) => ({ ...current, enabled: checked }))
}
/>
<ResourceField label="备注">
<ResourceInput
value={draft.remark}
onChange={(event) =>
setDraft((current) => ({
...current,
remark: event.target.value,
}))
}
/>
</ResourceField>
</div>
{draft.type === 'subscription' ? (
<div className="grid gap-5 xl:grid-cols-2">
<ResourceField label="订阅 URL">
<ResourceInput
value={draft.subscription_url}
placeholder="https://example.com/ip-list.txt"
onChange={(event) =>
setDraft((current) => ({
...current,
subscription_url: event.target.value,
}))
}
/>
</ResourceField>
<ResourceField label="订阅格式">
<ResourceSelect
value={draft.subscription_format}
onChange={(event) =>
setDraft((current) => ({
...current,
subscription_format: event.target
.value as WAFIPGroupSubscriptionFormat,
}))
}
>
<option value="text">文本列表</option>
<option value="json">JSON</option>
</ResourceSelect>
</ResourceField>
<ResourceField
label="同步间隔(分钟)"
hint="最小 5 分钟,默认 1440 分钟。"
>
<ResourceInput
type="number"
min={5}
value={draft.sync_interval_minutes}
onChange={(event) =>
setDraft((current) => ({
...current,
sync_interval_minutes: Number(event.target.value),
}))
}
/>
</ResourceField>
<ResourceField
label="JSON 映射规则"
hint="留空表示根数组;示例:data.items[]。文本格式无需填写。"
>
<ResourceInput
value={draft.subscription_mapping_rule}
disabled={draft.subscription_format !== 'json'}
onChange={(event) =>
setDraft((current) => ({
...current,
subscription_mapping_rule: event.target.value,
}))
}
/>
</ResourceField>
</div>
) : null}
{draft.type === 'automatic' ? (
<div className="space-y-4">
<div className="grid gap-5 xl:grid-cols-2">
<ResourceField
label="同步间隔(分钟)"
hint="定时从请求日志挖掘恶意 IP 的周期。最小 5 分钟,默认 1440 分钟。"
>
<ResourceInput
type="number"
min={5}
value={draft.sync_interval_minutes}
onChange={(event) =>
setDraft((current) => ({
...current,
sync_interval_minutes: Number(event.target.value),
}))
}
/>
</ResourceField>
</div>
<ResourceField
label="预设规则"
hint="表达式按单个 IP 的请求日志聚合指标计算。"
container="div"
>
<div className="flex flex-wrap gap-2">
{automaticPresetRules.map((rule) => (
<SecondaryButton
key={rule.expr}
type="button"
onClick={() => {
try {
setDraft((current) => ({
...current,
auto_config_text: appendAutomaticPresetRule(
current.auto_config_text,
rule,
),
}));
} catch (error) {
setFeedback({
tone: 'danger',
message: getErrorMessage(error),
});
}
}}
>
<Plus className="mr-2 h-4 w-4" />
{rule.name}
</SecondaryButton>
))}
</div>
</ResourceField>
<ResourceField
label="自动配置 JSON"
hint="可用字段:request_count、status_404_count、status_404_ratio、ip_host_count、ip_host_ratio。方法:StatusCount(code)、StatusRatio(code)。支持 ttl(秒,默认 -1 永久拉黑)。"
>
<ResourceTextarea
value={draft.auto_config_text}
className="min-h-64 font-mono"
onChange={(event) =>
setDraft((current) => ({
...current,
auto_config_text: event.target.value,
}))
}
/>
</ResourceField>
{autoTestResult ? (
<div className="space-y-3 rounded-2xl border border-[var(--border-default)] bg-[var(--surface-muted)] p-4">
<div className="flex flex-wrap items-center justify-between gap-2">
<div className="text-sm font-semibold text-[var(--foreground-primary)]">
测试结果
</div>
<div className="text-xs text-[var(--foreground-secondary)]">
回看 {autoTestResult.lookback_minutes} 分钟 · 规则{' '}
{autoTestResult.rule_count} 条
</div>
</div>
<InlineMessage
tone={
autoTestResult.matched_count > 0 ? 'success' : 'info'
}
message={
autoTestResult.matched_count > 0
? `命中 ${autoTestResult.matched_count} 个 IP。`
: '当前没有匹配到任何 IP。'
}
/>
{autoTestResult.matched_count > 0 ? (
<div className="rounded-2xl border border-[var(--border-default)] bg-[var(--surface-elevated)] p-3">
<div className="mb-2 text-xs font-medium text-[var(--foreground-secondary)]">
命中 IP 列表
</div>
<pre className="overflow-x-auto text-sm break-all whitespace-pre-wrap text-[var(--foreground-primary)]">
{autoTestResult.matched_ips.join('\n')}
</pre>
</div>
) : null}
</div>
) : null}
</div>
) : (
<ResourceField
label="IP / IP 段"
hint={
draft.type === 'subscription'
? '订阅同步会覆盖此列表;也可以先手动保存当前内容。'
: '支持单个 IP 或 CIDR,每行一个。'
}
>
<ResourceTextarea
value={draft.ip_list_text}
className="min-h-72 font-mono"
placeholder={'203.0.113.10\n198.51.100.0/24'}
onChange={(event) =>
setDraft((current) => ({
...current,
ip_list_text: event.target.value,
}))
}
/>
</ResourceField>
)}
{selectedGroup ? (
<div className="flex flex-wrap justify-between gap-3 border-t border-[var(--border-default)] pt-6">
<div className="text-sm text-[var(--foreground-secondary)]">
{selectedGroup.last_sync_status
? `${selectedGroup.last_sync_status}: ${selectedGroup.last_sync_message}`
: '尚无同步记录'}
</div>
<DangerButton
type="button"
disabled={deleteMutation.isPending}
onClick={() => {
if (
window.confirm(
`确认删除 IP 组 ${selectedGroup.name} 吗?`,
)
) {
deleteMutation.mutate(selectedGroup.id);
}
}}
>
<Trash2 className="mr-2 h-4 w-4" />
删除
</DangerButton>
</div>
) : null}
</div>
</AppCard>
</div>
{selectedGroup?.type === 'automatic' ? (
<AppModal
isOpen={isCapturedIPsModalOpen}
title={`已抓取 IP 列表 - ${selectedGroup.name}`}
description="展示当前自动挖掘并放入黑名单的 IP 列表及其到期状态。"
size="lg"
onClose={() => setIsCapturedIPsModalOpen(false)}
footer={
<div className="flex justify-end">
<SecondaryButton
type="button"
onClick={() => setIsCapturedIPsModalOpen(false)}
>
关闭
</SecondaryButton>
</div>
}
>
<div className="space-y-4">
{!selectedGroup.ext_ips || selectedGroup.ext_ips.length === 0 ? (
<EmptyState
title="暂无抓取记录"
description="该自动规则暂未抓取或命中任何恶意 IP,点击「立即执行」手动触发抓取。"
/>
) : (
<div className="overflow-hidden rounded-2xl border border-[var(--border-default)]">
<div className="overflow-x-auto">
<table className="min-w-full divide-y divide-[var(--border-default)]">
<thead className="bg-[var(--surface-muted)]">
<tr>
<th className="px-4 py-3 text-left text-xs font-semibold text-[var(--foreground-secondary)]">
IP 地址
</th>
<th className="px-4 py-3 text-left text-xs font-semibold text-[var(--foreground-secondary)]">
抓取时间
</th>
<th className="px-4 py-3 text-left text-xs font-semibold text-[var(--foreground-secondary)]">
封禁剩余时间
</th>
</tr>
</thead>
<tbody className="divide-y divide-[var(--border-default)] bg-[var(--surface-elevated)]">
{selectedGroup.ext_ips.map((item) => {
const autoConfig = selectedGroup.auto_config as
| { ttl?: number }
| undefined;
const ttl = autoConfig?.ttl ?? -1;
let expireText = '永久';
if (ttl > 0) {
const capturedDate = new Date(item.captured_at);
const expireDate = new Date(
capturedDate.getTime() + ttl * 1000,
);
const now = new Date();
if (expireDate.getTime() <= now.getTime()) {
expireText = '已过期';
} else {
const diffMs = expireDate.getTime() - now.getTime();
const diffMins = Math.round(diffMs / (60 * 1000));
if (diffMins < 60) {
expireText = `${diffMins} 分钟后`;
} else {
const diffHours = Math.round(diffMins / 60);
expireText = `${diffHours} 小时后`;
}
}
}
return (
<tr key={item.ip}>
<td className="px-4 py-3 font-mono text-sm font-medium whitespace-nowrap text-[var(--foreground-primary)]">
{item.ip}
</td>
<td className="px-4 py-3 text-sm whitespace-nowrap text-[var(--foreground-secondary)]">
{new Date(item.captured_at).toLocaleString()}
</td>
<td
className={cn(
'px-4 py-3 text-sm font-medium whitespace-nowrap',
ttl > 0 ? 'text-amber-500' : 'text-emerald-500',
)}
>
{expireText}
</td>
</tr>
);
})}
</tbody>
</table>
</div>
</div>
)}
</div>
</AppModal>
) : null}
</div>
);
}
@@ -0,0 +1,219 @@
import {useEffect, useState} from 'react';
import type {ProxyRoutePoWConfig} from '@/features/proxy-routes/types';
import {
ResourceField,
ResourceInput,
ResourceTextarea,
ToggleField,
} from '@/features/shared/components/resource-primitives';
import {listToText, parseTextareaList} from './helpers';
export function PowTabPanel({
enabled,
config,
onChange,
}: {
enabled: boolean;
config: ProxyRoutePoWConfig;
onChange: (enabled: boolean, config: ProxyRoutePoWConfig) => void;
}) {
const [draft, setDraft] = useState(() => ({
whitelist: {
ips: listToText(config.whitelist?.ips),
ip_cidrs: listToText(config.whitelist?.ip_cidrs),
paths: listToText(config.whitelist?.paths),
path_regexes: listToText(config.whitelist?.path_regexes),
user_agents: listToText(config.whitelist?.user_agents),
},
blacklist: {
ips: listToText(config.blacklist?.ips),
ip_cidrs: listToText(config.blacklist?.ip_cidrs),
paths: listToText(config.blacklist?.paths),
path_regexes: listToText(config.blacklist?.path_regexes),
user_agents: listToText(config.blacklist?.user_agents),
},
}));
useEffect(() => {
setDraft({
whitelist: {
ips: listToText(config.whitelist?.ips),
ip_cidrs: listToText(config.whitelist?.ip_cidrs),
paths: listToText(config.whitelist?.paths),
path_regexes: listToText(config.whitelist?.path_regexes),
user_agents: listToText(config.whitelist?.user_agents),
},
blacklist: {
ips: listToText(config.blacklist?.ips),
ip_cidrs: listToText(config.blacklist?.ip_cidrs),
paths: listToText(config.blacklist?.paths),
path_regexes: listToText(config.blacklist?.path_regexes),
user_agents: listToText(config.blacklist?.user_agents),
},
});
}, [config]);
const updateConfig = (
newEnabled: boolean,
newConfig: Partial<ProxyRoutePoWConfig>,
newDraft?: typeof draft,
) => {
const nextConfig = { ...config, ...newConfig };
if (newDraft) {
setDraft(newDraft);
nextConfig.whitelist = {
ips: parseTextareaList(newDraft.whitelist.ips),
ip_cidrs: parseTextareaList(newDraft.whitelist.ip_cidrs),
paths: parseTextareaList(newDraft.whitelist.paths),
path_regexes: parseTextareaList(newDraft.whitelist.path_regexes),
user_agents: parseTextareaList(newDraft.whitelist.user_agents),
};
nextConfig.blacklist = {
ips: parseTextareaList(newDraft.blacklist.ips),
ip_cidrs: parseTextareaList(newDraft.blacklist.ip_cidrs),
paths: parseTextareaList(newDraft.blacklist.paths),
path_regexes: parseTextareaList(newDraft.blacklist.path_regexes),
user_agents: parseTextareaList(newDraft.blacklist.user_agents),
};
}
onChange(newEnabled, nextConfig);
};
const updateList = (
scope: 'whitelist' | 'blacklist',
key: keyof ProxyRoutePoWConfig['whitelist'],
value: string,
) => {
const nextDraft = {
...draft,
[scope]: {
...draft[scope],
[key]: value,
},
};
updateConfig(enabled, {}, nextDraft);
};
return (
<div className="space-y-6">
<ToggleField
label="启用 PoW 防护"
description="启用后,命中该规则组的请求需要完成浏览器计算挑战。"
checked={enabled}
onChange={(newEnabled) => updateConfig(newEnabled, {})}
/>
<div className="grid gap-5 md:grid-cols-2 xl:grid-cols-4">
<ResourceField label="算法">
<select
value={config.algorithm}
onChange={(event) =>
updateConfig(enabled, {
algorithm: event.target.value as 'fast' | 'slow',
})
}
className="w-full rounded-2xl border border-[var(--border-default)] bg-[var(--control-background)] px-4 py-3 text-sm text-[var(--foreground-primary)] transition outline-none focus:border-[var(--border-strong)]"
>
<option value="fast">Fast</option>
<option value="slow">Slow</option>
</select>
</ResourceField>
<ResourceField label="难度">
<ResourceInput
type="number"
min={1}
max={16}
value={config.difficulty}
onChange={(event) =>
updateConfig(enabled, { difficulty: Number(event.target.value) })
}
/>
</ResourceField>
<ResourceField label="会话 TTL">
<ResourceInput
type="number"
min={60}
value={config.session_ttl}
onChange={(event) =>
updateConfig(enabled, { session_ttl: Number(event.target.value) })
}
/>
</ResourceField>
<ResourceField label="挑战 TTL">
<ResourceInput
type="number"
min={30}
value={config.challenge_ttl}
onChange={(event) =>
updateConfig(enabled, {
challenge_ttl: Number(event.target.value),
})
}
/>
</ResourceField>
</div>
<div className="grid gap-5 xl:grid-cols-2">
{(['whitelist', 'blacklist'] as const).map((scope) => (
<div
key={scope}
className="rounded-[26px] border border-[var(--border-default)] bg-[var(--surface-elevated)] p-5"
>
<h3 className="text-sm font-semibold text-[var(--foreground-primary)]">
{scope === 'whitelist'
? '白名单(跳过 PoW)'
: '黑名单(必须 PoW)'}
</h3>
<div className="mt-4 space-y-4">
<ResourceField label="IP">
<ResourceTextarea
className="min-h-20"
value={draft[scope].ips}
onChange={(event) =>
updateList(scope, 'ips', event.target.value)
}
/>
</ResourceField>
<ResourceField label="IP CIDR">
<ResourceTextarea
className="min-h-20"
value={draft[scope].ip_cidrs}
onChange={(event) =>
updateList(scope, 'ip_cidrs', event.target.value)
}
/>
</ResourceField>
<ResourceField label="路径">
<ResourceTextarea
className="min-h-20"
value={draft[scope].paths}
onChange={(event) =>
updateList(scope, 'paths', event.target.value)
}
/>
</ResourceField>
<ResourceField label="路径正则">
<ResourceTextarea
className="min-h-20"
value={draft[scope].path_regexes}
onChange={(event) =>
updateList(scope, 'path_regexes', event.target.value)
}
/>
</ResourceField>
<ResourceField label="User-Agent">
<ResourceTextarea
className="min-h-20"
value={draft[scope].user_agents}
onChange={(event) =>
updateList(scope, 'user_agents', event.target.value)
}
/>
</ResourceField>
</div>
</div>
))}
</div>
</div>
);
}
@@ -0,0 +1,32 @@
import {cn} from '@/lib/utils/cn';
export function RuleChip({
label,
tone,
onRemove,
}: {
label: string;
tone: 'whitelist' | 'blacklist';
onRemove: () => void;
}) {
return (
<span
className={cn(
'inline-flex items-center gap-2 rounded-full border px-3 py-2 text-sm',
tone === 'whitelist'
? 'border-emerald-500/30 bg-emerald-500/18 text-[var(--foreground-primary)]'
: 'border-rose-500/30 bg-rose-500/18 text-[var(--foreground-primary)]',
)}
>
<span className="break-all">{label}</span>
<button
type="button"
onClick={onRemove}
className="inline-flex h-5 w-5 items-center justify-center rounded-full border border-current/20 text-xs text-[var(--foreground-primary)] transition hover:bg-black/10"
aria-label={`移除 ${label}`}
>
×
</button>
</span>
);
}
@@ -0,0 +1,308 @@
import {useEffect, useMemo, useState} from 'react';
import {Search} from 'lucide-react';
import {AppModal} from '@/components/ui/app-modal';
import type {WAFIPGroup} from '@/features/waf/types';
import {
PrimaryButton,
ResourceField,
ResourceTextarea,
SecondaryButton,
} from '@/features/shared/components/resource-primitives';
import {cn} from '@/lib/utils/cn';
import {normalizeItems} from './helpers';
import type {CountryOption, RuleDimension, RuleListType, RuleModalState,} from './types';
export function RuleEntryModal({
state,
countryOptions,
ipGroups,
pending,
onClose,
onChange,
onSubmit,
}: {
state: RuleModalState;
countryOptions: CountryOption[];
ipGroups: WAFIPGroup[];
pending: boolean;
onClose: () => void;
onChange: (patch: Partial<RuleModalState>) => 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 selectedIPGroupSet = useMemo(
() => new Set(state.ipGroupIDs),
[state.ipGroupIDs],
);
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 toggleIPGroup = (id: number) => {
const values = selectedIPGroupSet.has(id)
? state.ipGroupIDs.filter((item) => item !== id)
: [...state.ipGroupIDs, id].sort((left, right) => left - right);
onChange({ ipGroupIDs: values });
};
const typeLabel = state.listType === 'blacklist' ? '黑名单' : '白名单';
const dimensionLabel =
state.dimension === 'ip'
? 'IP'
: state.dimension === 'ip_group'
? 'IP 组'
: '地域';
return (
<AppModal
isOpen={state.open}
title={`添加${typeLabel}规则`}
description={`当前准备新增 ${dimensionLabel} 维度的${typeLabel}项。`}
size="lg"
onClose={onClose}
footer={
<div className="flex justify-end gap-3">
<SecondaryButton type="button" onClick={onClose}>
取消
</SecondaryButton>
<PrimaryButton type="button" disabled={pending} onClick={onSubmit}>
{pending ? '处理中...' : '添加'}
</PrimaryButton>
</div>
}
>
<div className="space-y-6">
<div className="grid gap-5 md:grid-cols-2">
<ResourceField label="类型" container="div">
<div className="grid grid-cols-2 gap-3">
{[
{ value: 'blacklist', label: '黑名单' },
{ value: 'whitelist', label: '白名单' },
].map((option) => (
<button
key={option.value}
type="button"
onClick={() =>
onChange({ listType: option.value as RuleListType })
}
className={cn(
'rounded-2xl border px-4 py-3 text-sm font-medium transition',
state.listType === option.value
? 'border-[var(--border-strong)] bg-[var(--accent-soft)] text-[var(--foreground-primary)]'
: 'border-[var(--border-default)] bg-[var(--surface-elevated)] text-[var(--foreground-secondary)] hover:bg-[var(--surface-muted)]',
)}
>
{option.label}
</button>
))}
</div>
</ResourceField>
<ResourceField label="维度" container="div">
<div className="grid grid-cols-3 gap-3">
{[
{ value: 'ip', label: 'IP' },
{ value: 'ip_group', label: 'IP 组' },
{ value: 'country', label: '地域' },
].map((option) => (
<button
key={option.value}
type="button"
onClick={() =>
onChange({ dimension: option.value as RuleDimension })
}
className={cn(
'rounded-2xl border px-4 py-3 text-sm font-medium transition',
state.dimension === option.value
? 'border-[var(--border-strong)] bg-[var(--accent-soft)] text-[var(--foreground-primary)]'
: 'border-[var(--border-default)] bg-[var(--surface-elevated)] text-[var(--foreground-secondary)] hover:bg-[var(--surface-muted)]',
)}
>
{option.label}
</button>
))}
</div>
</ResourceField>
</div>
{state.dimension === 'ip' ? (
<ResourceField
label="IP / IP 段"
hint="支持单个 IP、CIDR,或使用换行/逗号一次添加多个。"
>
<ResourceTextarea
value={state.ipValue}
placeholder="例如 1.1.1.1 或 192.168.0.0/24"
onChange={(event) => onChange({ ipValue: event.target.value })}
/>
</ResourceField>
) : state.dimension === 'ip_group' ? (
<div className="rounded-[26px] border border-[var(--border-default)] bg-[var(--surface-elevated)] p-5">
<div className="flex items-center justify-between gap-3">
<div>
<h3 className="text-sm font-semibold text-[var(--foreground-primary)]">
选择 IP 组
</h3>
<p className="mt-1 text-xs leading-5 text-[var(--foreground-secondary)]">
发布版本只保存引用 ID,IP 组成员由 Agent 按 checksum
差异同步。
</p>
</div>
<span className="rounded-full border border-[var(--border-default)] px-2.5 py-1 text-xs font-medium text-[var(--foreground-secondary)]">
已选 {state.ipGroupIDs.length}
</span>
</div>
<div className="mt-4 max-h-80 space-y-2 overflow-y-auto pr-1">
{ipGroups.length > 0 ? (
ipGroups.map((group) => {
const selected = selectedIPGroupSet.has(group.id);
return (
<label
key={group.id}
className={cn(
'flex cursor-pointer items-center gap-3 rounded-2xl border px-4 py-3 transition',
selected
? 'border-[var(--border-strong)] bg-[var(--accent-soft)]'
: 'border-[var(--border-default)] bg-[var(--surface-panel)] hover:bg-[var(--surface-muted)]',
)}
>
<input
type="checkbox"
checked={selected}
onChange={() => toggleIPGroup(group.id)}
className="h-4 w-4 rounded border-[var(--border-default)] accent-[var(--brand-primary)]"
/>
<span className="min-w-0 flex-1">
<span className="block truncate text-sm font-medium text-[var(--foreground-primary)]">
{group.name}
</span>
<span className="mt-1 block text-xs text-[var(--foreground-secondary)]">
{group.type} · {group.ip_list.length} 条 ·{' '}
{group.enabled ? '启用' : '停用'}
</span>
</span>
</label>
);
})
) : (
<p className="text-sm text-[var(--foreground-muted)]">
暂无 IP 组,请先进入 IP 组管理页面创建。
</p>
)}
</div>
</div>
) : (
<div className="space-y-4">
<div className="flex items-center gap-3 rounded-2xl border border-[var(--border-default)] bg-[var(--surface-elevated)] px-4 py-3">
<Search className="h-4 w-4 text-[var(--foreground-secondary)]" />
<input
value={keyword}
onChange={(event) => 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)]"
/>
<button
type="button"
onClick={selectFiltered}
className="text-xs font-medium text-[var(--brand-primary)]"
>
全选当前
</button>
<button
type="button"
onClick={clearCountries}
className="text-xs font-medium text-[var(--foreground-secondary)]"
>
清空
</button>
</div>
<div className="rounded-[26px] border border-[var(--border-default)] bg-[var(--surface-elevated)] p-5">
<div className="flex items-center justify-between gap-3">
<div>
<h3 className="text-sm font-semibold text-[var(--foreground-primary)]">
地域多选
</h3>
<p className="mt-1 text-xs leading-5 text-[var(--foreground-secondary)]">
选项显示为「国家代码 国家中文名」。
</p>
</div>
<span className="rounded-full border border-[var(--border-default)] px-2.5 py-1 text-xs font-medium text-[var(--foreground-secondary)]">
已选 {state.countryValues.length}
</span>
</div>
<div className="mt-4 max-h-80 space-y-2 overflow-y-auto pr-1">
{filteredCountries.map((option) => {
const selected = selectedCountrySet.has(option.code);
return (
<label
key={option.code}
className={cn(
'flex cursor-pointer items-center gap-3 rounded-2xl border px-4 py-3 transition',
selected
? 'border-[var(--border-strong)] bg-[var(--accent-soft)]'
: 'border-[var(--border-default)] bg-[var(--surface-panel)] hover:bg-[var(--surface-muted)]',
)}
>
<input
type="checkbox"
checked={selected}
onChange={() => toggleCountry(option.code)}
className="h-4 w-4 rounded border-[var(--border-default)] accent-[var(--brand-primary)]"
/>
<span className="min-w-0">
<span className="block text-sm font-medium text-[var(--foreground-primary)]">
{option.label}
</span>
</span>
</label>
);
})}
</div>
</div>
</div>
)}
</div>
</AppModal>
);
}
@@ -0,0 +1,64 @@
import {RuleChip} from './rule-chip';
export function RuleListSection({
title,
description,
items,
groupItems = [],
tone,
emptyText,
onRemove,
onRemoveGroup,
}: {
title: string;
description: string;
items: string[];
groupItems?: Array<{ id: number; name: string; enabled: boolean }>;
tone: 'whitelist' | 'blacklist';
emptyText: string;
onRemove: (item: string) => void;
onRemoveGroup?: (id: number) => void;
}) {
const total = items.length + groupItems.length;
return (
<div className="rounded-[26px] border border-[var(--border-default)] bg-[var(--surface-elevated)] p-5">
<div className="flex items-start justify-between gap-4">
<div>
<h3 className="text-sm font-semibold text-[var(--foreground-primary)]">
{title}
</h3>
<p className="mt-1 text-xs leading-5 text-[var(--foreground-secondary)]">
{description}
</p>
</div>
<span className="shrink-0 rounded-full bg-[var(--surface-muted)] px-3 py-1 text-xs font-semibold text-[var(--foreground-primary)]">
{total}
</span>
</div>
<div className="mt-5">
{total > 0 ? (
<div className="flex flex-wrap gap-2">
{groupItems.map((group) => (
<RuleChip
key={`group-${group.id}`}
label={`IP组: ${group.name}${group.enabled ? '' : ' (停用)'}`}
tone={tone}
onRemove={() => onRemoveGroup?.(group.id)}
/>
))}
{items.map((item) => (
<RuleChip
key={item}
label={item}
tone={tone}
onRemove={() => onRemove(item)}
/>
))}
</div>
) : (
<p className="text-sm text-[var(--foreground-muted)]">{emptyText}</p>
)}
</div>
</div>
);
}
@@ -0,0 +1,141 @@
import { useEffect, useMemo, useState } from 'react';
import { Check, Search } from 'lucide-react';
import { Drawer } from '@/components/ui/drawer';
import type { ProxyRouteItem } from '@/features/proxy-routes/types';
import {
PrimaryButton,
SecondaryButton,
} from '@/features/shared/components/resource-primitives';
import type { WAFRuleGroup } from '@/features/waf/types';
import { cn } from '@/lib/utils/cn';
export 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<number[]>([]);
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 (
<Drawer
open={open}
onOpenChange={onOpenChange}
direction="right"
title={group ? `应用 ${group.name}` : '应用规则组'}
description="选择这个自定义规则组要叠加到哪些网站。"
footer={
<div className="flex justify-end gap-3">
<SecondaryButton type="button" onClick={() => onOpenChange(false)}>
取消
</SecondaryButton>
<PrimaryButton
type="button"
disabled={!group || pending}
onClick={() => onSave(selectedIDs)}
>
{pending ? '保存中...' : '保存应用范围'}
</PrimaryButton>
</div>
}
>
<div className="space-y-4">
<div className="flex items-center gap-3 rounded-2xl border border-[var(--border-default)] bg-[var(--surface-elevated)] px-4 py-3">
<Search className="h-4 w-4 text-[var(--foreground-secondary)]" />
<input
value={keyword}
onChange={(event) => 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)]"
/>
<button
type="button"
onClick={selectFiltered}
className="text-xs font-medium text-[var(--brand-primary)]"
>
全选当前
</button>
</div>
<div className="space-y-2">
{filteredRoutes.map((route) => (
<button
key={route.id}
type="button"
onClick={() => toggleID(route.id)}
className={cn(
'flex w-full items-center gap-3 rounded-2xl border px-4 py-3 text-left transition',
selectedSet.has(route.id)
? 'border-[var(--border-strong)] bg-[var(--accent-soft)]'
: 'border-[var(--border-default)] bg-[var(--surface-elevated)] hover:bg-[var(--surface-muted)]',
)}
>
<span
className={cn(
'flex h-5 w-5 items-center justify-center rounded-md border',
selectedSet.has(route.id)
? 'border-[var(--brand-primary)] bg-[var(--brand-primary)] text-[var(--foreground-inverse)]'
: 'border-[var(--border-default)]',
)}
>
{selectedSet.has(route.id) ? (
<Check className="h-3 w-3" />
) : null}
</span>
<span className="min-w-0 flex-1">
<span className="block truncate text-sm font-medium text-[var(--foreground-primary)]">
{route.site_name}
</span>
<span className="block truncate text-xs text-[var(--foreground-secondary)]">
{route.domains.join(', ')}
</span>
</span>
</button>
))}
</div>
</div>
</Drawer>
);
}
@@ -0,0 +1,36 @@
import type { LucideIcon } from 'lucide-react';
export function StatCard({
icon: Icon,
label,
value,
hint,
}: {
icon: LucideIcon;
label: string;
value: string;
hint?: string;
}) {
return (
<div className="rounded-[26px] border border-[var(--border-default)] bg-[var(--surface-elevated)] p-5">
<div className="flex items-start justify-between gap-4">
<div className="space-y-2">
<p className="text-xs font-medium tracking-[0.22em] text-[var(--foreground-muted)] uppercase">
{label}
</p>
<p className="text-2xl font-semibold text-[var(--foreground-primary)]">
{value}
</p>
</div>
<span className="inline-flex h-11 w-11 items-center justify-center rounded-2xl border border-[var(--border-default)] bg-[var(--surface-panel)] text-[var(--foreground-primary)]">
<Icon className="h-5 w-5" />
</span>
</div>
{hint && (
<p className="mt-3 text-sm leading-6 text-[var(--foreground-secondary)]">
{hint}
</p>
)}
</div>
);
}
@@ -0,0 +1,28 @@
import { cn } from '@/lib/utils/cn';
export function TabButton({
label,
active,
onClick,
}: {
label: string;
active: boolean;
onClick: () => void;
}) {
return (
<button
type="button"
onClick={onClick}
className={cn(
'group rounded-[24px] border px-4 py-4 text-left transition',
active
? 'border-[var(--border-strong)] bg-[var(--accent-soft)] shadow-[var(--shadow-soft)]'
: 'border-[var(--border-default)] bg-[var(--surface-elevated)] hover:border-[var(--border-strong)] hover:bg-[var(--surface-muted)]',
)}
>
<p className="text-sm font-semibold text-[var(--foreground-primary)]">
{label}
</p>
</button>
);
}
@@ -0,0 +1,45 @@
import type { WAFRuleGroupPayload } from '@/features/waf/types';
export type FeedbackState = {
tone: 'success' | 'danger' | 'info';
message: string;
};
export type WAFTab = 'basic' | 'lists' | 'pow' | 'block';
export type RuleListType = 'whitelist' | 'blacklist';
export type RuleDimension = 'ip' | 'ip_group' | 'country';
export type ListFieldKey =
| 'ip_whitelist'
| 'ip_blacklist'
| 'ip_whitelist_group_ids'
| 'ip_blacklist_group_ids'
| 'country_whitelist'
| 'country_blacklist';
export type CountryOption = {
code: string;
zhName: string;
label: string;
searchText: string;
};
export type RuleModalState = {
open: boolean;
listType: RuleListType;
dimension: RuleDimension;
ipValue: string;
ipGroupIDs: number[];
countryValues: string[];
};
export type RuleListRenderable = Pick<
WAFRuleGroupPayload,
| 'ip_whitelist'
| 'ip_blacklist'
| 'ip_whitelist_group_ids'
| 'ip_blacklist_group_ids'
| 'country_whitelist'
| 'country_blacklist'
| 'region_whitelist'
| 'region_blacklist'
>;
@@ -0,0 +1,742 @@
'use client';
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
import type { ReactNode } from 'react';
import { useEffect, useMemo, useState } from 'react';
import { useRouter } from 'next/navigation';
import { Globe2, Network, 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,
getWAFIPGroups,
getWAFRuleGroups,
replaceWAFRuleGroupSites,
updateWAFRuleGroup,
} from '@/features/waf/api/waf';
import type {
WAFIPGroup,
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 router = useRouter();
const queryClient = useQueryClient();
const [selectedID, setSelectedID] = useState<number | null>(null);
const [activeTab, setActiveTab] = useState<WAFTab>('basic');
const [draft, setDraft] = useState<WAFRuleGroupPayload>(emptyDraft);
const [feedback, setFeedback] = useState<FeedbackState | null>(null);
const [applyGroup, setApplyGroup] = useState<WAFRuleGroup | null>(null);
const [ruleModal, setRuleModal] = useState<RuleModalState>(
defaultRuleModalState,
);
const groupsQuery = useQuery({
queryKey: ['waf', 'rule-groups'],
queryFn: getWAFRuleGroups,
});
const ipGroupsQuery = useQuery({
queryKey: ['waf', 'ip-groups'],
queryFn: getWAFIPGroups,
});
const routesQuery = useQuery({
queryKey: ['proxy-routes'],
queryFn: getProxyRoutes,
});
const groups = useMemo(() => groupsQuery.data ?? [], [groupsQuery.data]);
const ipGroups = useMemo(
() => ipGroupsQuery.data ?? [],
[ipGroupsQuery.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: ['waf', 'ip-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 ||
ipGroupsQuery.isLoading
) {
return <LoadingState />;
}
if (groupsQuery.isError) {
return (
<ErrorState
title="WAF 加载失败"
description={getErrorMessage(groupsQuery.error)}
/>
);
}
if (routesQuery.isError) {
return (
<ErrorState
title="网站列表加载失败"
description={getErrorMessage(routesQuery.error)}
/>
);
}
if (ipGroupsQuery.isError) {
return (
<ErrorState
title="IP 组加载失败"
description={getErrorMessage(ipGroupsQuery.error)}
/>
);
}
if (!selectedGroup && groups.length === 0) {
return (
<EmptyState
title="WAF 尚未初始化"
description="刷新页面后系统会自动创建全局规则组。"
/>
);
}
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 ipGroupByID = new Map(ipGroups.map((group) => [group.id, group]));
const whitelistGroupItems = draft.ip_whitelist_group_ids
.map((id) => ipGroupByID.get(id))
.filter((group): group is WAFIPGroup => Boolean(group));
const blacklistGroupItems = draft.ip_blacklist_group_ids
.map((id) => ipGroupByID.get(id))
.filter((group): group is WAFIPGroup => Boolean(group));
const openRuleModal = () => {
setRuleModal({ ...defaultRuleModalState, open: true });
};
const closeRuleModal = () => {
setRuleModal(defaultRuleModalState);
};
const applyRuleModal = () => {
const values =
ruleModal.dimension === 'ip'
? textToList(ruleModal.ipValue)
: ruleModal.dimension === 'ip_group'
? ruleModal.ipGroupIDs.map(String)
: normalizeItems(ruleModal.countryValues);
if (values.length === 0) {
setFeedback({
tone: 'danger',
message:
ruleModal.dimension === 'ip'
? '请先输入 IP 或 IP 段。'
: ruleModal.dimension === 'ip_group'
? '请先选择 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 removeRuleGroup = (key: ListFieldKey, id: number) => {
setDraft((current) =>
updateDraftList(current, key, (items) =>
items.filter((item) => item !== String(id)),
),
);
};
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 (
<>
<div className="space-y-6">
<PageHeader
title="WAF"
description="按规则组维护 WAF 与 PoW 防护规则,全局规则组始终应用到所有网站。"
action={
<div className="flex flex-wrap gap-3">
<SecondaryButton
type="button"
onClick={() => router.push('/waf/ip-groups')}
>
<Network className="mr-2 h-4 w-4" />
管理 IP 组
</SecondaryButton>
<PrimaryButton
type="button"
onClick={() => {
setSelectedID(0);
setActiveTab('basic');
setDraft({ ...emptyDraft, name: '自定义规则组' });
}}
>
<Plus className="mr-2 h-4 w-4" />
新建规则组
</PrimaryButton>
</div>
}
/>
{feedback ? (
<InlineMessage
tone={feedback.tone}
message={feedback.message}
onClear={() => setFeedback(null)}
/>
) : null}
<div className="grid gap-5 xl:grid-cols-[360px_minmax(0,1fr)]">
<AppCard title="规则组">
<div className="space-y-2">
{groups.map((group) => (
<button
key={group.id}
type="button"
onClick={() => {
setSelectedID(group.id);
setActiveTab('basic');
}}
className={cn(
'w-full rounded-2xl border px-4 py-3 text-left transition',
selectedGroup?.id === group.id
? 'border-[var(--border-strong)] bg-[var(--accent-soft)]'
: 'border-[var(--border-default)] bg-[var(--surface-elevated)] hover:bg-[var(--surface-muted)]',
)}
>
<span className="flex items-center justify-between gap-3">
<span className="flex min-w-0 items-center gap-2">
{group.is_global ? (
<Globe2 className="h-4 w-4" />
) : (
<ShieldCheck className="h-4 w-4" />
)}
<span className="truncate text-sm font-semibold text-[var(--foreground-primary)]">
{group.name}
</span>
</span>
<span className="text-xs text-[var(--foreground-secondary)]">
{group.enabled ? '启用' : '停用'}
</span>
</span>
<span className="mt-2 block text-xs text-[var(--foreground-secondary)]">
{group.is_global
? '应用全部网站'
: `已应用 ${group.applied_site_count} 个网站`}{' '}
· {countRuleEntries(group)} 条规则
</span>
</button>
))}
</div>
</AppCard>
<AppCard
title={selectedGroup ? selectedGroup.name : '新建规则组'}
description="简介:白名单命中后直接放行;未命中白名单时继续判断黑名单。"
action={
<div className="flex flex-wrap gap-3">
{selectedGroup && !selectedGroup.is_global ? (
<SecondaryButton
type="button"
onClick={() => setApplyGroup(selectedGroup)}
>
一键应用
</SecondaryButton>
) : null}
<PrimaryButton
type="button"
disabled={saveMutation.isPending}
onClick={() => saveMutation.mutate(draft)}
>
<Save className="mr-2 h-4 w-4" />
{saveMutation.isPending ? '保存中...' : '保存规则组'}
</PrimaryButton>
</div>
}
>
<div className="space-y-6">
<div className="grid gap-3 md:grid-cols-4">
{tabItems.map((tab) => (
<TabButton
key={tab.id}
label={tab.label}
active={activeTab === tab.id}
onClick={() => setActiveTab(tab.id)}
/>
))}
</div>
{activeTab === 'basic' ? (
<div className="space-y-5">
<div className="grid gap-5 xl:grid-cols-2">
<ResourceField label="规则组名称">
<ResourceInput
value={draft.name}
disabled={selectedGroup?.is_global}
onChange={(event) =>
setDraft((current) => ({
...current,
name: event.target.value,
}))
}
/>
</ResourceField>
<ToggleField
label="启用规则组"
description="关闭后保留配置,但不会参与匹配。"
checked={draft.enabled}
onChange={(checked) =>
setDraft((current) => ({
...current,
enabled: checked,
}))
}
/>
<ResourceField
label="备注"
className="xl:col-span-2"
hint="用于记录规则组用途、业务说明或变更备注。"
>
<ResourceInput
value={draft.remark}
onChange={(event) =>
setDraft((current) => ({
...current,
remark: event.target.value,
}))
}
/>
</ResourceField>
</div>
<div className="grid gap-5">
<div className="space-y-5">
<div className="rounded-[26px] border border-[var(--border-default)] bg-[var(--surface-elevated)] p-5">
<div className="flex items-start justify-between gap-4">
<div>
<h3 className="text-sm font-semibold text-[var(--foreground-primary)]">
配置总览
</h3>
</div>
<span className="rounded-full border border-[var(--border-default)] px-2.5 py-1 text-xs font-medium text-[var(--foreground-secondary)]">
{selectedGroup?.is_global ? '全局' : '自定义'}
</span>
</div>
<dl className="mt-5 grid gap-4 sm:grid-cols-2">
{overviewItems.map((item) => (
<div
key={item.label}
className="rounded-2xl border border-[var(--border-default)] bg-[var(--surface-panel)] px-4 py-4"
>
<dt className="text-xs font-medium tracking-[0.18em] text-[var(--foreground-muted)] uppercase">
{item.label}
</dt>
<dd className="mt-2 text-sm font-medium text-[var(--foreground-primary)]">
{item.value}
</dd>
</div>
))}
</dl>
<div className="mt-5">
<p className="text-xs font-medium tracking-[0.18em] text-[var(--foreground-muted)] uppercase">
当前应用网站
</p>
<div className="mt-3 flex flex-wrap gap-2">
{appliedSiteNames.length > 0 ? (
appliedSiteNames.map((name) => (
<span
key={name}
className="rounded-full border border-[var(--border-default)] bg-[var(--surface-panel)] px-3 py-2 text-sm text-[var(--foreground-secondary)]"
>
{name}
</span>
))
) : (
<span className="rounded-2xl border border-dashed border-[var(--border-default)] px-4 py-3 text-sm text-[var(--foreground-secondary)]">
尚未绑定网站,可点击右上角「一键应用」进行配置。
</span>
)}
</div>
</div>
</div>
</div>
</div>
</div>
) : null}
{activeTab === 'lists' ? (
<div className="space-y-5">
<div className="flex flex-wrap items-center justify-between gap-3">
<div>
<h3 className="text-sm font-semibold text-[var(--foreground-primary)]">
黑白名单规则
</h3>
</div>
<PrimaryButton type="button" onClick={openRuleModal}>
<Plus className="mr-2 h-4 w-4" />
添加
</PrimaryButton>
</div>
<div className="grid gap-4 xl:grid-cols-2">
<RuleListSection
title="IP 白名单"
description="命中后直接放行,不再继续判断黑名单。"
items={draft.ip_whitelist}
groupItems={whitelistGroupItems}
tone="whitelist"
emptyText="暂无 IP 白名单规则。"
onRemove={(item) => removeRuleItem('ip_whitelist', item)}
onRemoveGroup={(id) =>
removeRuleGroup('ip_whitelist_group_ids', id)
}
/>
<RuleListSection
title="IP 黑名单"
description="未命中白名单时,命中这些 IP / IP 段将被拦截。"
items={draft.ip_blacklist}
groupItems={blacklistGroupItems}
tone="blacklist"
emptyText="暂无 IP 黑名单规则。"
onRemove={(item) => removeRuleItem('ip_blacklist', item)}
onRemoveGroup={(id) =>
removeRuleGroup('ip_blacklist_group_ids', id)
}
/>
<RuleListSection
title="地域白名单"
description="显示格式为国家代码与中文名,命中后直接放行。"
items={draft.country_whitelist.map((code) =>
formatCountryItem(code, countryLabelMap),
)}
tone="whitelist"
emptyText="暂无地域白名单规则。"
onRemove={(item) => {
const code = item.split(' ')[0] ?? item;
removeRuleItem('country_whitelist', code);
}}
/>
<RuleListSection
title="地域黑名单"
description="当请求未命中白名单时,命中这些地域将被拦截。"
items={draft.country_blacklist.map((code) =>
formatCountryItem(code, countryLabelMap),
)}
tone="blacklist"
emptyText="暂无地域黑名单规则。"
onRemove={(item) => {
const code = item.split(' ')[0] ?? item;
removeRuleItem('country_blacklist', code);
}}
/>
</div>
</div>
) : null}
{activeTab === 'pow' ? (
<PowTabPanel
key={selectedID}
enabled={draft.pow_enabled}
config={draft.pow_config}
onChange={(enabled, config) =>
setDraft((current) => ({
...current,
pow_enabled: enabled,
pow_config: config,
}))
}
/>
) : null}
{activeTab === 'block' ? (
<div className="grid gap-5 xl:grid-cols-[360px_minmax(0,1fr)]">
<div className="space-y-5">
<div className="rounded-[26px] border border-[var(--border-default)] bg-[var(--surface-elevated)] p-5">
<h3 className="text-sm font-semibold text-[var(--foreground-primary)]">
拦截返回状态码
</h3>
<p className="mt-1 text-xs leading-5 text-[var(--foreground-secondary)]">
建议使用 403、418、451 等明确表达策略拦截含义的状态码。
</p>
<div className="mt-4 space-y-4">
<ResourceField label="状态码">
<ResourceInput
type="number"
min={400}
max={599}
value={draft.block_status_code}
onChange={(event) =>
setDraft((current) => ({
...current,
block_status_code: Number(event.target.value),
}))
}
/>
</ResourceField>
<div className="flex flex-wrap gap-2">
{[403, 418, 451, 503].map((code) => (
<button
key={code}
type="button"
onClick={() =>
setDraft((current) => ({
...current,
block_status_code: code,
}))
}
className={cn(
'rounded-full border px-3 py-2 text-sm transition',
draft.block_status_code === code
? 'border-[var(--border-strong)] bg-[var(--accent-soft)] text-[var(--foreground-primary)]'
: 'border-[var(--border-default)] bg-[var(--surface-panel)] text-[var(--foreground-secondary)] hover:bg-[var(--surface-muted)]',
)}
>
{code}
</button>
))}
</div>
</div>
</div>
</div>
<div className="rounded-[26px] border border-[var(--border-default)] bg-[var(--surface-elevated)] p-5">
<ResourceField
label="拦截页面"
hint="支持直接输入 HTML 或纯文本。留空时只返回状态码。"
>
<ResourceTextarea
value={draft.block_response_body}
className="min-h-72"
placeholder="<html><body><h1>Request blocked</h1></body></html>"
onChange={(event) =>
setDraft((current) => ({
...current,
block_response_body: event.target.value,
}))
}
/>
</ResourceField>
</div>
</div>
) : null}
<div className="flex flex-wrap justify-between gap-3 border-t border-[var(--border-default)] pt-6">
<div>
{selectedGroup && !selectedGroup.is_global ? (
<DangerButton
type="button"
disabled={deleteMutation.isPending}
onClick={() => {
if (
window.confirm(
`确认删除 WAF 规则组 ${selectedGroup.name} 吗?`,
)
) {
deleteMutation.mutate(selectedGroup.id);
}
}}
>
<Trash2 className="mr-2 h-4 w-4" />
删除
</DangerButton>
) : null}
</div>
</div>
</div>
</AppCard>
</div>
</div>
<RuleEntryModal
state={ruleModal}
countryOptions={countryOptions}
ipGroups={ipGroups}
pending={saveMutation.isPending}
onClose={closeRuleModal}
onChange={(patch) =>
setRuleModal((current) => ({ ...current, ...patch, open: true }))
}
onSubmit={applyRuleModal}
/>
<SiteApplyDrawer
group={applyGroup}
routes={routes}
open={Boolean(applyGroup)}
pending={applyMutation.isPending}
onOpenChange={(open) => {
if (!open) {
setApplyGroup(null);
}
}}
onSave={(ids) => {
if (applyGroup) {
applyMutation.mutate({ id: applyGroup.id, ids });
}
}}
/>
</>
);
}