feat(waf): 规则组新建/编辑改用右侧 Sheet 抽屉

将 RuleGroupDialog 替换为 RuleGroupSheet,使用 shadcn Sheet 侧栏交互,
与站点绑定等操作保持一致;添加规则条目仍保留弹窗。
This commit is contained in:
ryan
2026-06-18 20:23:56 +08:00
parent 89b3f5d843
commit a2f838605c
4 changed files with 402 additions and 401 deletions
@@ -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}
/>
</>
);
}
+2 -2
View File
@@ -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 ?? []}
+1
View File
@@ -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` 等)。