mirror of
https://github.com/Rain-kl/OpenFlare.git
synced 2026-09-28 05:46:36 +08:00
feat(waf): 规则组新建/编辑改用右侧 Sheet 抽屉
将 RuleGroupDialog 替换为 RuleGroupSheet,使用 shadcn Sheet 侧栏交互, 与站点绑定等操作保持一致;添加规则条目仍保留弹窗。
This commit is contained in:
@@ -1,399 +0,0 @@
|
||||
'use client';
|
||||
|
||||
import {useEffect, useMemo, useState} from 'react';
|
||||
import {zodResolver} from '@hookform/resolvers/zod';
|
||||
import {Plus} from 'lucide-react';
|
||||
import {useForm} from 'react-hook-form';
|
||||
import {z} from 'zod';
|
||||
|
||||
import {Button} from '@/components/ui/button';
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogFooter,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
} from '@/components/ui/dialog';
|
||||
import {Form, FormControl, FormDescription, FormField, FormItem, FormLabel, FormMessage,} from '@/components/ui/form';
|
||||
import {Input} from '@/components/ui/input';
|
||||
import {Switch} from '@/components/ui/switch';
|
||||
import {Tabs, TabsContent, TabsList, TabsTrigger} from '@/components/ui/tabs';
|
||||
import {Textarea} from '@/components/ui/textarea';
|
||||
import type {WAFIPGroup, WAFRuleGroup, WAFRuleGroupPayload} from '@/lib/services/openflare';
|
||||
|
||||
import {
|
||||
buildCountryOptions,
|
||||
buildRuleGroupDraft,
|
||||
countRuleEntries,
|
||||
emptyRuleGroupDraft,
|
||||
formatCountryItem,
|
||||
getListFieldKey,
|
||||
type ListFieldKey,
|
||||
normalizeItems,
|
||||
textToList,
|
||||
updateDraftList,
|
||||
} from './helpers';
|
||||
import {PowConfigPanel} from './pow-config-panel';
|
||||
import {RuleEntryDialog, type RuleEntryFormValues} from './rule-entry-dialog';
|
||||
import {RuleListSection} from './rule-list-section';
|
||||
|
||||
const ruleGroupSchema = z.object({
|
||||
name: z.string().trim().min(1, '请输入规则组名称').max(255, '名称不能超过 255 个字符'),
|
||||
enabled: z.boolean(),
|
||||
block_status_code: z.number().int().min(400).max(599),
|
||||
block_response_body: z.string(),
|
||||
remark: z.string().max(500, '备注不能超过 500 个字符'),
|
||||
pow_enabled: z.boolean(),
|
||||
});
|
||||
|
||||
type RuleGroupFormValues = z.infer<typeof ruleGroupSchema>;
|
||||
|
||||
interface RuleGroupDialogProps {
|
||||
open: boolean;
|
||||
group: WAFRuleGroup | null;
|
||||
ipGroups: WAFIPGroup[];
|
||||
submitting: boolean;
|
||||
onOpenChange: (open: boolean) => void;
|
||||
onSubmit: (payload: WAFRuleGroupPayload) => Promise<void>;
|
||||
}
|
||||
|
||||
export function RuleGroupDialog({
|
||||
open,
|
||||
group,
|
||||
ipGroups,
|
||||
submitting,
|
||||
onOpenChange,
|
||||
onSubmit,
|
||||
}: RuleGroupDialogProps) {
|
||||
const [listsDraft, setListsDraft] = useState<WAFRuleGroupPayload>(emptyRuleGroupDraft);
|
||||
const [ruleEntryOpen, setRuleEntryOpen] = useState(false);
|
||||
|
||||
const countryOptions = useMemo(() => buildCountryOptions(), []);
|
||||
const countryLabelMap = useMemo(
|
||||
() => new Map(countryOptions.map((option) => [option.code, option.label])),
|
||||
[countryOptions],
|
||||
);
|
||||
|
||||
const form = useForm<RuleGroupFormValues>({
|
||||
resolver: zodResolver(ruleGroupSchema),
|
||||
defaultValues: {
|
||||
name: '',
|
||||
enabled: true,
|
||||
block_status_code: 418,
|
||||
block_response_body: '',
|
||||
remark: '',
|
||||
pow_enabled: false,
|
||||
},
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
if (!open) return;
|
||||
const draft = buildRuleGroupDraft(group);
|
||||
setListsDraft(draft);
|
||||
form.reset({
|
||||
name: draft.name,
|
||||
enabled: draft.enabled,
|
||||
block_status_code: draft.block_status_code,
|
||||
block_response_body: draft.block_response_body,
|
||||
remark: draft.remark,
|
||||
pow_enabled: draft.pow_enabled,
|
||||
});
|
||||
setRuleEntryOpen(false);
|
||||
}, [form, group, open]);
|
||||
|
||||
const ipGroupByID = new Map(ipGroups.map((item) => [item.id, item]));
|
||||
const whitelistGroupItems = listsDraft.ip_whitelist_group_ids
|
||||
.map((id) => ipGroupByID.get(id))
|
||||
.filter((item): item is WAFIPGroup => Boolean(item));
|
||||
const blacklistGroupItems = listsDraft.ip_blacklist_group_ids
|
||||
.map((id) => ipGroupByID.get(id))
|
||||
.filter((item): item is WAFIPGroup => Boolean(item));
|
||||
|
||||
const removeRuleItem = (key: ListFieldKey, value: string) => {
|
||||
setListsDraft((current) =>
|
||||
updateDraftList(current, key, (items) => items.filter((item) => item !== value)),
|
||||
);
|
||||
};
|
||||
|
||||
const removeRuleGroup = (key: ListFieldKey, id: number) => {
|
||||
setListsDraft((current) =>
|
||||
updateDraftList(current, key, (items) =>
|
||||
items.filter((item) => item !== String(id)),
|
||||
),
|
||||
);
|
||||
};
|
||||
|
||||
const applyRuleEntry = (entry: RuleEntryFormValues) => {
|
||||
const values =
|
||||
entry.dimension === 'ip'
|
||||
? textToList(entry.ipValue)
|
||||
: entry.dimension === 'ip_group'
|
||||
? entry.ipGroupIDs.map(String)
|
||||
: normalizeItems(entry.countryValues);
|
||||
|
||||
const listKey = getListFieldKey(entry.listType, entry.dimension);
|
||||
setListsDraft((current) =>
|
||||
updateDraftList(current, listKey, (items) =>
|
||||
normalizeItems([...items, ...values]),
|
||||
),
|
||||
);
|
||||
setRuleEntryOpen(false);
|
||||
};
|
||||
|
||||
const handleSubmit = form.handleSubmit(async (values) => {
|
||||
const payload: WAFRuleGroupPayload = {
|
||||
...listsDraft,
|
||||
name: values.name,
|
||||
enabled: values.enabled,
|
||||
block_status_code: values.block_status_code,
|
||||
block_response_body: values.block_response_body,
|
||||
remark: values.remark,
|
||||
pow_enabled: values.pow_enabled,
|
||||
};
|
||||
try {
|
||||
await onSubmit(payload);
|
||||
onOpenChange(false);
|
||||
} catch (error) {
|
||||
form.setError('root', {
|
||||
message: error instanceof Error ? error.message : '保存失败',
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
const isGlobal = group?.is_global ?? false;
|
||||
|
||||
return (
|
||||
<>
|
||||
<Dialog open={open} onOpenChange={onOpenChange}>
|
||||
<DialogContent className="max-w-4xl max-h-[90vh] overflow-y-auto">
|
||||
<DialogHeader>
|
||||
<DialogTitle>{group ? `编辑 ${group.name}` : '新建规则组'}</DialogTitle>
|
||||
<DialogDescription>
|
||||
白名单命中后直接放行;未命中白名单时继续判断黑名单。当前规则数{' '}
|
||||
{countRuleEntries(listsDraft)} 条。
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
|
||||
<Form {...form}>
|
||||
<form onSubmit={handleSubmit} className="space-y-4">
|
||||
<Tabs defaultValue="basic">
|
||||
<TabsList className="grid w-full grid-cols-4">
|
||||
<TabsTrigger value="basic">基本信息</TabsTrigger>
|
||||
<TabsTrigger value="lists">黑白名单</TabsTrigger>
|
||||
<TabsTrigger value="pow">PoW</TabsTrigger>
|
||||
<TabsTrigger value="block">拦截返回</TabsTrigger>
|
||||
</TabsList>
|
||||
|
||||
<TabsContent value="basic" className="space-y-4 mt-4">
|
||||
<div className="grid gap-4 md:grid-cols-2">
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="name"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>规则组名称</FormLabel>
|
||||
<FormControl>
|
||||
<Input {...field} disabled={isGlobal} />
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="enabled"
|
||||
render={({ field }) => (
|
||||
<FormItem className="flex items-center justify-between rounded-lg border border-dashed p-4">
|
||||
<div className="space-y-0.5">
|
||||
<FormLabel>启用规则组</FormLabel>
|
||||
<FormDescription>关闭后保留配置,但不会参与匹配。</FormDescription>
|
||||
</div>
|
||||
<FormControl>
|
||||
<Switch checked={field.value} onCheckedChange={field.onChange} />
|
||||
</FormControl>
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="remark"
|
||||
render={({ field }) => (
|
||||
<FormItem className="md:col-span-2">
|
||||
<FormLabel>备注</FormLabel>
|
||||
<FormControl>
|
||||
<Input {...field} />
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
</div>
|
||||
</TabsContent>
|
||||
|
||||
<TabsContent value="lists" className="space-y-4 mt-4">
|
||||
<div className="flex items-center justify-between">
|
||||
<p className="text-sm text-muted-foreground">
|
||||
通过子对话框添加 IP、IP 组或地域规则。
|
||||
</p>
|
||||
<Button
|
||||
type="button"
|
||||
size="sm"
|
||||
onClick={() =>
|
||||
setRuleEntryOpen(true)
|
||||
}
|
||||
>
|
||||
<Plus className="size-3.5 mr-1" />
|
||||
添加规则
|
||||
</Button>
|
||||
</div>
|
||||
<div className="grid gap-4 xl:grid-cols-2">
|
||||
<RuleListSection
|
||||
title="IP 白名单"
|
||||
description="命中后直接放行,不再继续判断黑名单。"
|
||||
items={listsDraft.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={listsDraft.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={listsDraft.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={listsDraft.country_blacklist.map((code) =>
|
||||
formatCountryItem(code, countryLabelMap),
|
||||
)}
|
||||
tone="blacklist"
|
||||
emptyText="暂无地域黑名单规则。"
|
||||
onRemove={(item) => {
|
||||
const code = item.split(' ')[0] ?? item;
|
||||
removeRuleItem('country_blacklist', code);
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
</TabsContent>
|
||||
|
||||
<TabsContent value="pow" className="mt-4">
|
||||
<PowConfigPanel
|
||||
enabled={form.watch('pow_enabled')}
|
||||
config={listsDraft.pow_config}
|
||||
onChange={(enabled, config) => {
|
||||
form.setValue('pow_enabled', enabled);
|
||||
setListsDraft((current) => ({
|
||||
...current,
|
||||
pow_enabled: enabled,
|
||||
pow_config: config,
|
||||
}));
|
||||
}}
|
||||
/>
|
||||
</TabsContent>
|
||||
|
||||
<TabsContent value="block" className="space-y-4 mt-4">
|
||||
<div className="grid gap-4 xl:grid-cols-[280px_minmax(0,1fr)]">
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="block_status_code"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>拦截状态码</FormLabel>
|
||||
<FormControl>
|
||||
<Input
|
||||
type="number"
|
||||
min={400}
|
||||
max={599}
|
||||
value={field.value}
|
||||
onChange={(event) =>
|
||||
field.onChange(Number(event.target.value))
|
||||
}
|
||||
/>
|
||||
</FormControl>
|
||||
<div className="flex flex-wrap gap-2 pt-2">
|
||||
{[403, 418, 451, 503].map((code) => (
|
||||
<Button
|
||||
key={code}
|
||||
type="button"
|
||||
size="sm"
|
||||
variant={field.value === code ? 'default' : 'outline'}
|
||||
onClick={() => field.onChange(code)}
|
||||
>
|
||||
{code}
|
||||
</Button>
|
||||
))}
|
||||
</div>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="block_response_body"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>拦截页面</FormLabel>
|
||||
<FormControl>
|
||||
<Textarea
|
||||
className="min-h-48"
|
||||
placeholder="<html><body><h1>Request blocked</h1></body></html>"
|
||||
{...field}
|
||||
/>
|
||||
</FormControl>
|
||||
<FormDescription>留空时只返回状态码。</FormDescription>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
</div>
|
||||
</TabsContent>
|
||||
</Tabs>
|
||||
|
||||
{form.formState.errors.root ? (
|
||||
<p className="text-sm text-destructive">{form.formState.errors.root.message}</p>
|
||||
) : null}
|
||||
|
||||
<DialogFooter>
|
||||
<Button type="button" variant="outline" onClick={() => onOpenChange(false)}>
|
||||
取消
|
||||
</Button>
|
||||
<Button type="submit" disabled={submitting}>
|
||||
{submitting ? '保存中...' : '保存规则组'}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</form>
|
||||
</Form>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
|
||||
<RuleEntryDialog
|
||||
open={ruleEntryOpen}
|
||||
countryOptions={countryOptions}
|
||||
ipGroups={ipGroups}
|
||||
onOpenChange={setRuleEntryOpen}
|
||||
onSubmit={applyRuleEntry}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,399 @@
|
||||
'use client';
|
||||
|
||||
import {useEffect, useMemo, useState} from 'react';
|
||||
import {zodResolver} from '@hookform/resolvers/zod';
|
||||
import {Plus} from 'lucide-react';
|
||||
import {useForm} from 'react-hook-form';
|
||||
import {z} from 'zod';
|
||||
|
||||
import {Button} from '@/components/ui/button';
|
||||
import {Form, FormControl, FormDescription, FormField, FormItem, FormLabel, FormMessage,} from '@/components/ui/form';
|
||||
import {Input} from '@/components/ui/input';
|
||||
import {
|
||||
Sheet,
|
||||
SheetContent,
|
||||
SheetDescription,
|
||||
SheetFooter,
|
||||
SheetHeader,
|
||||
SheetTitle,
|
||||
} from '@/components/ui/sheet';
|
||||
import {Switch} from '@/components/ui/switch';
|
||||
import {Tabs, TabsContent, TabsList, TabsTrigger} from '@/components/ui/tabs';
|
||||
import {Textarea} from '@/components/ui/textarea';
|
||||
import type {WAFIPGroup, WAFRuleGroup, WAFRuleGroupPayload} from '@/lib/services/openflare';
|
||||
|
||||
import {
|
||||
buildCountryOptions,
|
||||
buildRuleGroupDraft,
|
||||
countRuleEntries,
|
||||
emptyRuleGroupDraft,
|
||||
formatCountryItem,
|
||||
getListFieldKey,
|
||||
type ListFieldKey,
|
||||
normalizeItems,
|
||||
textToList,
|
||||
updateDraftList,
|
||||
} from './helpers';
|
||||
import {PowConfigPanel} from './pow-config-panel';
|
||||
import {RuleEntryDialog, type RuleEntryFormValues} from './rule-entry-dialog';
|
||||
import {RuleListSection} from './rule-list-section';
|
||||
|
||||
const ruleGroupSchema = z.object({
|
||||
name: z.string().trim().min(1, '请输入规则组名称').max(255, '名称不能超过 255 个字符'),
|
||||
enabled: z.boolean(),
|
||||
block_status_code: z.number().int().min(400).max(599),
|
||||
block_response_body: z.string(),
|
||||
remark: z.string().max(500, '备注不能超过 500 个字符'),
|
||||
pow_enabled: z.boolean(),
|
||||
});
|
||||
|
||||
type RuleGroupFormValues = z.infer<typeof ruleGroupSchema>;
|
||||
|
||||
interface RuleGroupSheetProps {
|
||||
open: boolean;
|
||||
group: WAFRuleGroup | null;
|
||||
ipGroups: WAFIPGroup[];
|
||||
submitting: boolean;
|
||||
onOpenChange: (open: boolean) => void;
|
||||
onSubmit: (payload: WAFRuleGroupPayload) => Promise<void>;
|
||||
}
|
||||
|
||||
export function RuleGroupSheet({
|
||||
open,
|
||||
group,
|
||||
ipGroups,
|
||||
submitting,
|
||||
onOpenChange,
|
||||
onSubmit,
|
||||
}: RuleGroupSheetProps) {
|
||||
const [listsDraft, setListsDraft] = useState<WAFRuleGroupPayload>(emptyRuleGroupDraft);
|
||||
const [ruleEntryOpen, setRuleEntryOpen] = useState(false);
|
||||
|
||||
const countryOptions = useMemo(() => buildCountryOptions(), []);
|
||||
const countryLabelMap = useMemo(
|
||||
() => new Map(countryOptions.map((option) => [option.code, option.label])),
|
||||
[countryOptions],
|
||||
);
|
||||
|
||||
const form = useForm<RuleGroupFormValues>({
|
||||
resolver: zodResolver(ruleGroupSchema),
|
||||
defaultValues: {
|
||||
name: '',
|
||||
enabled: true,
|
||||
block_status_code: 418,
|
||||
block_response_body: '',
|
||||
remark: '',
|
||||
pow_enabled: false,
|
||||
},
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
if (!open) return;
|
||||
const draft = buildRuleGroupDraft(group);
|
||||
setListsDraft(draft);
|
||||
form.reset({
|
||||
name: draft.name,
|
||||
enabled: draft.enabled,
|
||||
block_status_code: draft.block_status_code,
|
||||
block_response_body: draft.block_response_body,
|
||||
remark: draft.remark,
|
||||
pow_enabled: draft.pow_enabled,
|
||||
});
|
||||
setRuleEntryOpen(false);
|
||||
}, [form, group, open]);
|
||||
|
||||
const ipGroupByID = new Map(ipGroups.map((item) => [item.id, item]));
|
||||
const whitelistGroupItems = listsDraft.ip_whitelist_group_ids
|
||||
.map((id) => ipGroupByID.get(id))
|
||||
.filter((item): item is WAFIPGroup => Boolean(item));
|
||||
const blacklistGroupItems = listsDraft.ip_blacklist_group_ids
|
||||
.map((id) => ipGroupByID.get(id))
|
||||
.filter((item): item is WAFIPGroup => Boolean(item));
|
||||
|
||||
const removeRuleItem = (key: ListFieldKey, value: string) => {
|
||||
setListsDraft((current) =>
|
||||
updateDraftList(current, key, (items) => items.filter((item) => item !== value)),
|
||||
);
|
||||
};
|
||||
|
||||
const removeRuleGroup = (key: ListFieldKey, id: number) => {
|
||||
setListsDraft((current) =>
|
||||
updateDraftList(current, key, (items) =>
|
||||
items.filter((item) => item !== String(id)),
|
||||
),
|
||||
);
|
||||
};
|
||||
|
||||
const applyRuleEntry = (entry: RuleEntryFormValues) => {
|
||||
const values =
|
||||
entry.dimension === 'ip'
|
||||
? textToList(entry.ipValue)
|
||||
: entry.dimension === 'ip_group'
|
||||
? entry.ipGroupIDs.map(String)
|
||||
: normalizeItems(entry.countryValues);
|
||||
|
||||
const listKey = getListFieldKey(entry.listType, entry.dimension);
|
||||
setListsDraft((current) =>
|
||||
updateDraftList(current, listKey, (items) =>
|
||||
normalizeItems([...items, ...values]),
|
||||
),
|
||||
);
|
||||
setRuleEntryOpen(false);
|
||||
};
|
||||
|
||||
const handleSubmit = form.handleSubmit(async (values) => {
|
||||
const payload: WAFRuleGroupPayload = {
|
||||
...listsDraft,
|
||||
name: values.name,
|
||||
enabled: values.enabled,
|
||||
block_status_code: values.block_status_code,
|
||||
block_response_body: values.block_response_body,
|
||||
remark: values.remark,
|
||||
pow_enabled: values.pow_enabled,
|
||||
};
|
||||
try {
|
||||
await onSubmit(payload);
|
||||
onOpenChange(false);
|
||||
} catch (error) {
|
||||
form.setError('root', {
|
||||
message: error instanceof Error ? error.message : '保存失败',
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
const isGlobal = group?.is_global ?? false;
|
||||
|
||||
return (
|
||||
<>
|
||||
<Sheet open={open} onOpenChange={onOpenChange}>
|
||||
<SheetContent side="right" className="flex h-svh w-full flex-col gap-0 p-0 sm:max-w-4xl">
|
||||
<SheetHeader className="border-b px-4 py-4">
|
||||
<SheetTitle>{group ? `编辑 ${group.name}` : '新建规则组'}</SheetTitle>
|
||||
<SheetDescription>
|
||||
白名单命中后直接放行;未命中白名单时继续判断黑名单。当前规则数{' '}
|
||||
{countRuleEntries(listsDraft)} 条。
|
||||
</SheetDescription>
|
||||
</SheetHeader>
|
||||
|
||||
<Form {...form}>
|
||||
<form
|
||||
id="rule-group-form"
|
||||
onSubmit={handleSubmit}
|
||||
className="flex min-h-0 flex-1 flex-col"
|
||||
>
|
||||
<div className="flex-1 space-y-4 overflow-y-auto px-4 py-4">
|
||||
<Tabs defaultValue="basic">
|
||||
<TabsList className="grid w-full grid-cols-4">
|
||||
<TabsTrigger value="basic">基本信息</TabsTrigger>
|
||||
<TabsTrigger value="lists">黑白名单</TabsTrigger>
|
||||
<TabsTrigger value="pow">PoW</TabsTrigger>
|
||||
<TabsTrigger value="block">拦截返回</TabsTrigger>
|
||||
</TabsList>
|
||||
|
||||
<TabsContent value="basic" className="mt-4 space-y-4">
|
||||
<div className="grid gap-4 md:grid-cols-2">
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="name"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>规则组名称</FormLabel>
|
||||
<FormControl>
|
||||
<Input {...field} disabled={isGlobal} />
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="enabled"
|
||||
render={({ field }) => (
|
||||
<FormItem className="flex items-center justify-between rounded-lg border border-dashed p-4">
|
||||
<div className="space-y-0.5">
|
||||
<FormLabel>启用规则组</FormLabel>
|
||||
<FormDescription>关闭后保留配置,但不会参与匹配。</FormDescription>
|
||||
</div>
|
||||
<FormControl>
|
||||
<Switch checked={field.value} onCheckedChange={field.onChange} />
|
||||
</FormControl>
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="remark"
|
||||
render={({ field }) => (
|
||||
<FormItem className="md:col-span-2">
|
||||
<FormLabel>备注</FormLabel>
|
||||
<FormControl>
|
||||
<Input {...field} />
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
</div>
|
||||
</TabsContent>
|
||||
|
||||
<TabsContent value="lists" className="mt-4 space-y-4">
|
||||
<div className="flex items-center justify-between">
|
||||
<p className="text-sm text-muted-foreground">
|
||||
通过弹窗添加 IP、IP 组或地域规则。
|
||||
</p>
|
||||
<Button type="button" size="sm" onClick={() => setRuleEntryOpen(true)}>
|
||||
<Plus className="mr-1 size-3.5" />
|
||||
添加规则
|
||||
</Button>
|
||||
</div>
|
||||
<div className="grid gap-4 xl:grid-cols-2">
|
||||
<RuleListSection
|
||||
title="IP 白名单"
|
||||
description="命中后直接放行,不再继续判断黑名单。"
|
||||
items={listsDraft.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={listsDraft.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={listsDraft.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={listsDraft.country_blacklist.map((code) =>
|
||||
formatCountryItem(code, countryLabelMap),
|
||||
)}
|
||||
tone="blacklist"
|
||||
emptyText="暂无地域黑名单规则。"
|
||||
onRemove={(item) => {
|
||||
const code = item.split(' ')[0] ?? item;
|
||||
removeRuleItem('country_blacklist', code);
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
</TabsContent>
|
||||
|
||||
<TabsContent value="pow" className="mt-4">
|
||||
<PowConfigPanel
|
||||
enabled={form.watch('pow_enabled')}
|
||||
config={listsDraft.pow_config}
|
||||
onChange={(enabled, config) => {
|
||||
form.setValue('pow_enabled', enabled);
|
||||
setListsDraft((current) => ({
|
||||
...current,
|
||||
pow_enabled: enabled,
|
||||
pow_config: config,
|
||||
}));
|
||||
}}
|
||||
/>
|
||||
</TabsContent>
|
||||
|
||||
<TabsContent value="block" className="mt-4 space-y-4">
|
||||
<div className="grid gap-4 xl:grid-cols-[280px_minmax(0,1fr)]">
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="block_status_code"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>拦截状态码</FormLabel>
|
||||
<FormControl>
|
||||
<Input
|
||||
type="number"
|
||||
min={400}
|
||||
max={599}
|
||||
value={field.value}
|
||||
onChange={(event) =>
|
||||
field.onChange(Number(event.target.value))
|
||||
}
|
||||
/>
|
||||
</FormControl>
|
||||
<div className="flex flex-wrap gap-2 pt-2">
|
||||
{[403, 418, 451, 503].map((code) => (
|
||||
<Button
|
||||
key={code}
|
||||
type="button"
|
||||
size="sm"
|
||||
variant={field.value === code ? 'default' : 'outline'}
|
||||
onClick={() => field.onChange(code)}
|
||||
>
|
||||
{code}
|
||||
</Button>
|
||||
))}
|
||||
</div>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="block_response_body"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>拦截页面</FormLabel>
|
||||
<FormControl>
|
||||
<Textarea
|
||||
className="min-h-48"
|
||||
placeholder="<html><body><h1>Request blocked</h1></body></html>"
|
||||
{...field}
|
||||
/>
|
||||
</FormControl>
|
||||
<FormDescription>留空时只返回状态码。</FormDescription>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
</div>
|
||||
</TabsContent>
|
||||
</Tabs>
|
||||
|
||||
{form.formState.errors.root ? (
|
||||
<p className="text-sm text-destructive">{form.formState.errors.root.message}</p>
|
||||
) : null}
|
||||
</div>
|
||||
|
||||
<SheetFooter className="flex-row justify-end border-t px-4 py-4">
|
||||
<Button type="button" variant="outline" onClick={() => onOpenChange(false)}>
|
||||
取消
|
||||
</Button>
|
||||
<Button type="submit" disabled={submitting}>
|
||||
{submitting ? '保存中...' : '保存规则组'}
|
||||
</Button>
|
||||
</SheetFooter>
|
||||
</form>
|
||||
</Form>
|
||||
</SheetContent>
|
||||
</Sheet>
|
||||
|
||||
<RuleEntryDialog
|
||||
open={ruleEntryOpen}
|
||||
countryOptions={countryOptions}
|
||||
ipGroups={ipGroups}
|
||||
onOpenChange={setRuleEntryOpen}
|
||||
onSubmit={applyRuleEntry}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -25,7 +25,7 @@ import type {WAFRuleGroup, WAFRuleGroupPayload} from '@/lib/services/openflare';
|
||||
import {ProxyRouteService, WafService} from '@/lib/services/openflare';
|
||||
|
||||
import {getErrorMessage} from './components/helpers';
|
||||
import {RuleGroupDialog} from './components/rule-group-dialog';
|
||||
import {RuleGroupSheet} from './components/rule-group-sheet';
|
||||
import {RuleGroupsTable} from './components/rule-groups-table';
|
||||
import {SiteBindingSheet} from './components/site-binding-sheet';
|
||||
|
||||
@@ -204,7 +204,7 @@ export default function WafPage() {
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<RuleGroupDialog
|
||||
<RuleGroupSheet
|
||||
open={editorOpen}
|
||||
group={editingGroup}
|
||||
ipGroups={ipGroupsQuery.data ?? []}
|
||||
|
||||
@@ -25,6 +25,7 @@ sidebar: false
|
||||
- 修复 Wavelet 全球态势板 ECharts 地图在紧凑布局下容器尺寸为 0 导致无法渲染的问题。
|
||||
- Wavelet 全球态势板地图容器调整为约 2:3(宽:高)比例,避免世界地图横向过度拉伸。
|
||||
- Wavelet WAF / 代理规则认证实装完整 PoW 配置面板:支持难度、算法、TTL 与路径/IP/UA 黑白名单编辑。
|
||||
- Wavelet WAF 新建/编辑规则组改用右侧 Sheet 抽屉,替代居中弹窗,与站点绑定等操作保持一致的 shadcn 侧栏交互。
|
||||
|
||||
- 将 `openflare-server` 后端业务域迁移至 `Wavelet/internal/apps/openflare/`,通过 `/api/*` legacy 兼容层保持旧前端 API 路径不变。
|
||||
- 新增 OpenFlare 业务表 goose 迁移(`of_options`、`of_origins`、`of_proxy_routes`、`of_nodes`、`of_waf_*`、`of_tls_*`、`of_config_versions`、`of_pages_*`、`of_apply_logs` 等)。
|
||||
|
||||
Reference in New Issue
Block a user