migrate frontend

This commit is contained in:
ryan
2026-06-18 17:43:11 +08:00
parent a9fd0bd128
commit 31b114b0cc
104 changed files with 16735 additions and 874 deletions
@@ -0,0 +1,268 @@
import type {ProxyRoutePoWConfig, WAFRuleGroup, WAFRuleGroupPayload,} from '@/lib/services/openflare';
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'
>;
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 emptyRuleGroupDraft: 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 wafTabItems: 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 buildRuleGroupDraft(group: WAFRuleGroup | null): WAFRuleGroupPayload {
if (!group) {
return { ...emptyRuleGroupDraft };
}
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;
}
export 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>;
}
export const ipGroupTypeLabels = {
manual: '手动',
automatic: '自动',
subscription: '订阅',
} as const;
export 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',
},
];
@@ -0,0 +1,453 @@
'use client';
import {useEffect} 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 {Select, SelectContent, SelectItem, SelectTrigger, SelectValue,} from '@/components/ui/select';
import {Switch} from '@/components/ui/switch';
import {Textarea} from '@/components/ui/textarea';
import type {
WAFIPGroup,
WAFIPGroupPayload,
WAFIPGroupSubscriptionFormat,
WAFIPGroupType,
} from '@/lib/services/openflare';
import {automaticPresetRules, listToText, parseAutomaticConfig, parseTextareaList,} from './helpers';
const ipGroupSchema = z
.object({
name: z.string().trim().min(1, '请输入 IP 组名称').max(255),
type: z.enum(['manual', 'automatic', 'subscription']),
enabled: z.boolean(),
ip_list_text: z.string(),
auto_config_text: z.string(),
subscription_url: z.string(),
subscription_format: z.enum(['text', 'json']),
subscription_mapping_rule: z.string(),
sync_interval_minutes: z.number().int().min(5),
remark: z.string().max(500),
})
.superRefine((value, context) => {
if (value.type === 'subscription' && !value.subscription_url.trim()) {
context.addIssue({
code: z.ZodIssueCode.custom,
path: ['subscription_url'],
message: '订阅类型需要填写订阅 URL',
});
}
if (value.type === 'automatic') {
try {
parseAutomaticConfig(value.auto_config_text);
} catch (error) {
context.addIssue({
code: z.ZodIssueCode.custom,
path: ['auto_config_text'],
message: error instanceof Error ? error.message : '自动配置格式错误',
});
}
}
});
type IPGroupFormValues = z.infer<typeof ipGroupSchema>;
const defaultValues: IPGroupFormValues = {
name: '',
type: 'manual',
enabled: true,
ip_list_text: '',
auto_config_text: '{}',
subscription_url: '',
subscription_format: 'text',
subscription_mapping_rule: '',
sync_interval_minutes: 1440,
remark: '',
};
function buildFormValues(group: WAFIPGroup | null): IPGroupFormValues {
if (!group) return defaultValues;
return {
name: group.name,
type: group.type,
enabled: group.enabled,
ip_list_text: listToText(group.ip_list),
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(values: IPGroupFormValues): WAFIPGroupPayload {
const autoConfig =
values.type === 'automatic'
? parseAutomaticConfig(values.auto_config_text)
: {};
return {
name: values.name.trim(),
type: values.type,
enabled: values.enabled,
ip_list: parseTextareaList(values.ip_list_text),
auto_config: autoConfig,
subscription_url: values.subscription_url.trim(),
subscription_format: values.subscription_format,
subscription_mapping_rule: values.subscription_mapping_rule.trim(),
sync_interval_minutes: values.sync_interval_minutes,
remark: values.remark.trim(),
};
}
function appendAutomaticPresetRule(
autoConfigText: string,
rule: (typeof automaticPresetRules)[number],
) {
const config = parseAutomaticConfig(autoConfigText);
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,
);
}
interface IPGroupDialogProps {
open: boolean;
group: WAFIPGroup | null;
submitting: boolean;
onOpenChange: (open: boolean) => void;
onSubmit: (payload: WAFIPGroupPayload) => Promise<void>;
}
export function IPGroupDialog({
open,
group,
submitting,
onOpenChange,
onSubmit,
}: IPGroupDialogProps) {
const form = useForm<IPGroupFormValues>({
resolver: zodResolver(ipGroupSchema),
defaultValues,
});
const type = form.watch('type');
useEffect(() => {
if (!open) return;
form.reset(buildFormValues(group));
}, [form, group, open]);
const handleSubmit = form.handleSubmit(async (values) => {
try {
await onSubmit(buildPayload(values));
onOpenChange(false);
} catch (error) {
form.setError('root', {
message: error instanceof Error ? error.message : '保存失败',
});
}
});
return (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent className="max-w-3xl max-h-[90vh] overflow-y-auto">
<DialogHeader>
<DialogTitle>{group ? `编辑 ${group.name}` : '新建 IP 组'}</DialogTitle>
<DialogDescription>
维护可被 WAF IP 黑白名单引用的手动、自动与订阅 IP 集合。
</DialogDescription>
</DialogHeader>
<Form {...form}>
<form onSubmit={handleSubmit} className="space-y-4">
<div className="grid gap-4 md:grid-cols-2">
<FormField
control={form.control}
name="name"
render={({ field }) => (
<FormItem>
<FormLabel>IP 组名称</FormLabel>
<FormControl>
<Input {...field} />
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="type"
render={({ field }) => (
<FormItem>
<FormLabel>类型</FormLabel>
<Select
value={field.value}
onValueChange={(value) =>
field.onChange(value as WAFIPGroupType)
}
>
<FormControl>
<SelectTrigger>
<SelectValue />
</SelectTrigger>
</FormControl>
<SelectContent>
<SelectItem value="manual">手动</SelectItem>
<SelectItem value="automatic">自动</SelectItem>
<SelectItem value="subscription">订阅</SelectItem>
</SelectContent>
</Select>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="enabled"
render={({ field }) => (
<FormItem className="flex items-center justify-between rounded-lg border border-dashed p-4 md:col-span-2">
<div className="space-y-0.5">
<FormLabel>启用 IP 组</FormLabel>
<FormDescription>
关闭后保留配置,但发布时不会展开到 WAF 运行时名单。
</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>
{type === 'subscription' ? (
<div className="grid gap-4 md:grid-cols-2 rounded-lg border border-dashed p-4">
<FormField
control={form.control}
name="subscription_url"
render={({ field }) => (
<FormItem>
<FormLabel>订阅 URL</FormLabel>
<FormControl>
<Input placeholder="https://example.com/ip-list.txt" {...field} />
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="subscription_format"
render={({ field }) => (
<FormItem>
<FormLabel>订阅格式</FormLabel>
<Select
value={field.value}
onValueChange={(value) =>
field.onChange(value as WAFIPGroupSubscriptionFormat)
}
>
<FormControl>
<SelectTrigger>
<SelectValue />
</SelectTrigger>
</FormControl>
<SelectContent>
<SelectItem value="text">文本列表</SelectItem>
<SelectItem value="json">JSON</SelectItem>
</SelectContent>
</Select>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="sync_interval_minutes"
render={({ field }) => (
<FormItem>
<FormLabel>同步间隔(分钟)</FormLabel>
<FormControl>
<Input
type="number"
min={5}
value={field.value}
onChange={(event) =>
field.onChange(Number(event.target.value))
}
/>
</FormControl>
<FormDescription>最小 5 分钟,默认 1440 分钟。</FormDescription>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="subscription_mapping_rule"
render={({ field }) => (
<FormItem>
<FormLabel>JSON 映射规则</FormLabel>
<FormControl>
<Input
disabled={form.watch('subscription_format') !== 'json'}
placeholder="留空表示根数组"
{...field}
/>
</FormControl>
<FormMessage />
</FormItem>
)}
/>
</div>
) : null}
{type === 'automatic' ? (
<div className="space-y-4 rounded-lg border border-dashed p-4">
<FormField
control={form.control}
name="sync_interval_minutes"
render={({ field }) => (
<FormItem>
<FormLabel>同步间隔(分钟)</FormLabel>
<FormControl>
<Input
type="number"
min={5}
value={field.value}
onChange={(event) =>
field.onChange(Number(event.target.value))
}
/>
</FormControl>
<FormDescription>
定时从请求日志挖掘恶意 IP 的周期。最小 5 分钟。
</FormDescription>
<FormMessage />
</FormItem>
)}
/>
<div className="space-y-2">
<FormLabel>预设规则</FormLabel>
<div className="flex flex-wrap gap-2">
{automaticPresetRules.map((rule) => (
<Button
key={rule.expr}
type="button"
size="sm"
variant="outline"
onClick={() => {
const current = form.getValues('auto_config_text');
form.setValue(
'auto_config_text',
appendAutomaticPresetRule(current, rule),
);
}}
>
<Plus className="size-3.5 mr-1" />
{rule.name}
</Button>
))}
</div>
</div>
<FormField
control={form.control}
name="auto_config_text"
render={({ field }) => (
<FormItem>
<FormLabel>自动配置 JSON</FormLabel>
<FormControl>
<Textarea className="min-h-48 font-mono text-xs" {...field} />
</FormControl>
<FormMessage />
</FormItem>
)}
/>
</div>
) : null}
{type !== 'automatic' ? (
<FormField
control={form.control}
name="ip_list_text"
render={({ field }) => (
<FormItem>
<FormLabel>IP / IP 段</FormLabel>
<FormControl>
<Textarea
className="min-h-48 font-mono text-xs"
placeholder={'203.0.113.10\n198.51.100.0/24'}
{...field}
/>
</FormControl>
<FormDescription>
{type === 'subscription'
? '订阅同步会覆盖此列表;也可以先手动保存当前内容。'
: '支持单个 IP 或 CIDR,每行一个。'}
</FormDescription>
<FormMessage />
</FormItem>
)}
/>
) : null}
{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 ? '保存中...' : '保存 IP 组'}
</Button>
</DialogFooter>
</form>
</Form>
</DialogContent>
</Dialog>
);
}
@@ -0,0 +1,70 @@
'use client';
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
} from '@/components/ui/dialog';
import {Button} from '@/components/ui/button';
import type {WAFIPGroupAutoTestResult} from '@/lib/services/openflare';
interface IPGroupTestDialogProps {
open: boolean;
loading: boolean;
result: WAFIPGroupAutoTestResult | null;
onOpenChange: (open: boolean) => void;
}
export function IPGroupTestDialog({
open,
loading,
result,
onOpenChange,
}: IPGroupTestDialogProps) {
return (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent className="max-w-2xl">
<DialogHeader>
<DialogTitle>自动规则测试结果</DialogTitle>
<DialogDescription>
基于当前自动配置 JSON 对请求日志进行回看测试。
</DialogDescription>
</DialogHeader>
{loading ? (
<p className="text-sm text-muted-foreground py-6 text-center">测试中...</p>
) : result ? (
<div className="space-y-4">
<div className="rounded-lg border border-dashed p-4 text-sm">
<p>
回看 {result.lookback_minutes} 分钟 · 规则 {result.rule_count} 条 · 命中{' '}
{result.matched_count} 个 IP
</p>
<p className="text-xs text-muted-foreground mt-1">
测试时间:{new Date(result.tested_at).toLocaleString()}
</p>
</div>
{result.matched_count > 0 ? (
<pre className="max-h-64 overflow-auto rounded-lg border bg-muted/40 p-3 text-xs whitespace-pre-wrap break-all">
{result.matched_ips.join('\n')}
</pre>
) : (
<p className="text-sm text-muted-foreground">当前没有匹配到任何 IP。</p>
)}
</div>
) : (
<p className="text-sm text-muted-foreground py-6 text-center">暂无测试结果。</p>
)}
<DialogFooter>
<Button type="button" onClick={() => onOpenChange(false)}>
关闭
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
);
}
@@ -0,0 +1,120 @@
'use client';
import {Download, MoreHorizontal, Pencil, Play, Trash2} from 'lucide-react';
import {Badge} from '@/components/ui/badge';
import {Button} from '@/components/ui/button';
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuSeparator,
DropdownMenuTrigger,
} from '@/components/ui/dropdown-menu';
import {Table, TableBody, TableCell, TableHead, TableHeader, TableRow,} from '@/components/ui/table';
import type {WAFIPGroup} from '@/lib/services/openflare';
import {formatDateTime} from '@/lib/utils';
import {ipGroupTypeLabels} from './helpers';
interface IPGroupsTableProps {
groups: WAFIPGroup[];
syncingId: number | null;
onEdit: (group: WAFIPGroup) => void;
onDelete: (group: WAFIPGroup) => void;
onSync: (group: WAFIPGroup) => void;
onTest: (group: WAFIPGroup) => void;
}
export function IPGroupsTable({
groups,
syncingId,
onEdit,
onDelete,
onSync,
onTest,
}: IPGroupsTableProps) {
return (
<Table>
<TableHeader>
<TableRow>
<TableHead>名称</TableHead>
<TableHead>类型</TableHead>
<TableHead>状态</TableHead>
<TableHead>IP 数</TableHead>
<TableHead>引用次数</TableHead>
<TableHead>同步状态</TableHead>
<TableHead>更新时间</TableHead>
<TableHead className="w-[80px] text-right">操作</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{groups.map((group) => (
<TableRow key={group.id}>
<TableCell className="font-medium">{group.name}</TableCell>
<TableCell>
<Badge variant="outline">{ipGroupTypeLabels[group.type]}</Badge>
</TableCell>
<TableCell>
<Badge variant={group.enabled ? 'default' : 'secondary'}>
{group.enabled ? '启用' : '停用'}
</Badge>
</TableCell>
<TableCell>{group.ip_list.length}</TableCell>
<TableCell>{group.referenced_by_rule_count}</TableCell>
<TableCell className="text-sm text-muted-foreground max-w-[200px] truncate">
{group.last_sync_status
? `${group.last_sync_status}: ${group.last_sync_message}`
: '尚无同步记录'}
</TableCell>
<TableCell className="text-muted-foreground text-sm">
{group.updated_at ? formatDateTime(group.updated_at) : '—'}
</TableCell>
<TableCell className="text-right">
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button variant="ghost" size="icon" className="size-8">
<MoreHorizontal className="size-4" />
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end">
<DropdownMenuItem onClick={() => onEdit(group)}>
<Pencil className="size-4 mr-2" />
编辑
</DropdownMenuItem>
{group.type === 'automatic' ? (
<DropdownMenuItem onClick={() => onTest(group)}>
<Play className="size-4 mr-2" />
测试规则
</DropdownMenuItem>
) : null}
{group.type === 'subscription' || group.type === 'automatic' ? (
<DropdownMenuItem
disabled={syncingId === group.id}
onClick={() => onSync(group)}
>
<Download className="size-4 mr-2" />
{syncingId === group.id
? '同步中...'
: group.type === 'automatic'
? '立即执行'
: '立即同步'}
</DropdownMenuItem>
) : null}
<DropdownMenuSeparator />
<DropdownMenuItem
className="text-destructive focus:text-destructive"
onClick={() => onDelete(group)}
>
<Trash2 className="size-4 mr-2" />
删除
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
</TableCell>
</TableRow>
))}
</TableBody>
</Table>
);
}
@@ -0,0 +1,43 @@
'use client';
import {Info} from 'lucide-react';
import {Switch} from '@/components/ui/switch';
import {Label} from '@/components/ui/label';
import type {ProxyRoutePoWConfig} from '@/lib/services/openflare';
interface PowConfigPanelProps {
enabled: boolean;
config: ProxyRoutePoWConfig;
onChange: (enabled: boolean, config: ProxyRoutePoWConfig) => void;
}
export function PowConfigPanel({ enabled, config, onChange }: PowConfigPanelProps) {
return (
<div className="space-y-4">
<div className="flex gap-3 rounded-lg border border-dashed bg-muted/40 p-4 text-sm">
<Info className="size-4 shrink-0 text-primary mt-0.5" />
<div className="space-y-1">
<p className="font-medium">PoW 配置面板(占位)</p>
<p className="text-muted-foreground text-xs">
完整 PoW 黑白名单与难度参数编辑将在后续迭代中实装。当前可先切换启用状态并保存规则组。
</p>
</div>
</div>
<div className="flex items-center justify-between rounded-lg border border-dashed p-4">
<div className="space-y-1">
<Label htmlFor="pow-enabled">启用 PoW 防护</Label>
<p className="text-xs text-muted-foreground">
算法 {config.algorithm} · 难度 {config.difficulty}
</p>
</div>
<Switch
id="pow-enabled"
checked={enabled}
onCheckedChange={(checked) => onChange(checked, config)}
/>
</div>
</div>
);
}
@@ -0,0 +1,283 @@
'use client';
import {useEffect, useMemo, useState} from 'react';
import {Search} from 'lucide-react';
import {Button} from '@/components/ui/button';
import {Checkbox} from '@/components/ui/checkbox';
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
} from '@/components/ui/dialog';
import {Input} from '@/components/ui/input';
import {Label} from '@/components/ui/label';
import {Textarea} from '@/components/ui/textarea';
import {cn} from '@/lib/utils';
import type {WAFIPGroup} from '@/lib/services/openflare';
import {
type CountryOption,
normalizeItems,
type RuleDimension,
type RuleListType,
type RuleModalState,
} from './helpers';
interface RuleEntryDialogProps {
state: RuleModalState;
countryOptions: CountryOption[];
ipGroups: WAFIPGroup[];
onClose: () => void;
onChange: (patch: Partial<RuleModalState>) => void;
onSubmit: () => void;
}
export function RuleEntryDialog({
state,
countryOptions,
ipGroups,
onClose,
onChange,
onSubmit,
}: RuleEntryDialogProps) {
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 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 (
<Dialog open={state.open} onOpenChange={(open) => !open && onClose()}>
<DialogContent className="max-w-2xl max-h-[90vh] overflow-y-auto">
<DialogHeader>
<DialogTitle>添加{typeLabel}规则</DialogTitle>
<DialogDescription>
当前准备新增 {dimensionLabel} 维度的{typeLabel}项。
</DialogDescription>
</DialogHeader>
<div className="space-y-5">
<div className="grid gap-4 md:grid-cols-2">
<div className="space-y-2">
<Label>类型</Label>
<div className="grid grid-cols-2 gap-2">
{[
{ value: 'blacklist', label: '黑名单' },
{ value: 'whitelist', label: '白名单' },
].map((option) => (
<Button
key={option.value}
type="button"
variant={state.listType === option.value ? 'default' : 'outline'}
onClick={() =>
onChange({ listType: option.value as RuleListType })
}
>
{option.label}
</Button>
))}
</div>
</div>
<div className="space-y-2">
<Label>维度</Label>
<div className="grid grid-cols-3 gap-2">
{[
{ value: 'ip', label: 'IP' },
{ value: 'ip_group', label: 'IP 组' },
{ value: 'country', label: '地域' },
].map((option) => (
<Button
key={option.value}
type="button"
size="sm"
variant={state.dimension === option.value ? 'default' : 'outline'}
onClick={() =>
onChange({ dimension: option.value as RuleDimension })
}
>
{option.label}
</Button>
))}
</div>
</div>
</div>
{state.dimension === 'ip' ? (
<div className="space-y-2">
<Label>IP / IP 段</Label>
<Textarea
value={state.ipValue}
placeholder="例如 1.1.1.1 或 192.168.0.0/24"
onChange={(event) => onChange({ ipValue: event.target.value })}
/>
<p className="text-xs text-muted-foreground">
支持单个 IP、CIDR,或使用换行/逗号一次添加多个。
</p>
</div>
) : null}
{state.dimension === 'ip_group' ? (
<div className="space-y-3 rounded-lg border border-dashed p-4">
<div className="flex items-center justify-between">
<div>
<p className="text-sm font-medium">选择 IP 组</p>
<p className="text-xs text-muted-foreground">
发布版本只保存引用 ID,IP 组成员由 Agent 按 checksum 差异同步。
</p>
</div>
<span className="text-xs text-muted-foreground">
已选 {state.ipGroupIDs.length}
</span>
</div>
<div className="max-h-64 space-y-2 overflow-y-auto">
{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-md border px-3 py-2',
selected && 'border-primary bg-muted/50',
)}
>
<Checkbox
checked={selected}
onCheckedChange={() => toggleIPGroup(group.id)}
/>
<span className="min-w-0 flex-1">
<span className="block text-sm font-medium truncate">
{group.name}
</span>
<span className="block text-xs text-muted-foreground">
{group.type} · {group.ip_list.length} 条 ·{' '}
{group.enabled ? '启用' : '停用'}
</span>
</span>
</label>
);
})
) : (
<p className="text-sm text-muted-foreground">暂无 IP 组,请先创建。</p>
)}
</div>
</div>
) : null}
{state.dimension === 'country' ? (
<div className="space-y-3">
<div className="flex items-center gap-2 rounded-md border px-3 py-2">
<Search className="size-4 text-muted-foreground" />
<Input
value={keyword}
placeholder="搜索国家代码或中文名"
className="border-0 shadow-none focus-visible:ring-0"
onChange={(event) => setKeyword(event.target.value)}
/>
<Button
type="button"
variant="ghost"
size="sm"
onClick={() =>
onChange({
countryValues: normalizeItems([
...state.countryValues,
...filteredCountries.map((option) => option.code),
]),
})
}
>
全选当前
</Button>
<Button
type="button"
variant="ghost"
size="sm"
onClick={() => onChange({ countryValues: [] })}
>
清空
</Button>
</div>
<div className="max-h-64 space-y-2 overflow-y-auto rounded-lg border border-dashed p-3">
{filteredCountries.map((option) => {
const selected = selectedCountrySet.has(option.code);
return (
<label
key={option.code}
className={cn(
'flex cursor-pointer items-center gap-3 rounded-md border px-3 py-2',
selected && 'border-primary bg-muted/50',
)}
>
<Checkbox
checked={selected}
onCheckedChange={() => toggleCountry(option.code)}
/>
<span className="text-sm">{option.label}</span>
</label>
);
})}
</div>
</div>
) : null}
</div>
<DialogFooter>
<Button type="button" variant="outline" onClick={onClose}>
取消
</Button>
<Button type="button" onClick={onSubmit}>
添加
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
);
}
@@ -0,0 +1,404 @@
'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,
defaultRuleModalState,
emptyRuleGroupDraft,
formatCountryItem,
getListFieldKey,
type ListFieldKey,
normalizeItems,
type RuleModalState,
textToList,
updateDraftList,
} from './helpers';
import {PowConfigPanel} from './pow-config-panel';
import {RuleEntryDialog} 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 [ruleModal, setRuleModal] = useState<RuleModalState>(defaultRuleModalState);
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,
});
setRuleModal(defaultRuleModalState);
}, [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 applyRuleModal = () => {
const values =
ruleModal.dimension === 'ip'
? textToList(ruleModal.ipValue)
: ruleModal.dimension === 'ip_group'
? ruleModal.ipGroupIDs.map(String)
: normalizeItems(ruleModal.countryValues);
if (values.length === 0) return;
const listKey = getListFieldKey(ruleModal.listType, ruleModal.dimension);
setListsDraft((current) =>
updateDraftList(current, listKey, (items) =>
normalizeItems([...items, ...values]),
),
);
setRuleModal(defaultRuleModalState);
};
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={() =>
setRuleModal({ ...defaultRuleModalState, open: 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
state={ruleModal}
countryOptions={countryOptions}
ipGroups={ipGroups}
onClose={() => setRuleModal(defaultRuleModalState)}
onChange={(patch) => setRuleModal((current) => ({ ...current, ...patch, open: true }))}
onSubmit={applyRuleModal}
/>
</>
);
}
@@ -0,0 +1,114 @@
'use client';
import {Globe2, MoreHorizontal, Pencil, ShieldCheck, Trash2, Users} from 'lucide-react';
import {Badge} from '@/components/ui/badge';
import {Button} from '@/components/ui/button';
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuSeparator,
DropdownMenuTrigger,
} from '@/components/ui/dropdown-menu';
import {Table, TableBody, TableCell, TableHead, TableHeader, TableRow,} from '@/components/ui/table';
import type {WAFRuleGroup} from '@/lib/services/openflare';
import {formatDateTime} from '@/lib/utils';
import {countRuleEntries} from './helpers';
interface RuleGroupsTableProps {
groups: WAFRuleGroup[];
onEdit: (group: WAFRuleGroup) => void;
onDelete: (group: WAFRuleGroup) => void;
onBindSites: (group: WAFRuleGroup) => void;
}
export function RuleGroupsTable({
groups,
onEdit,
onDelete,
onBindSites,
}: RuleGroupsTableProps) {
return (
<Table>
<TableHeader>
<TableRow>
<TableHead>名称</TableHead>
<TableHead>类型</TableHead>
<TableHead>状态</TableHead>
<TableHead>规则数</TableHead>
<TableHead>应用范围</TableHead>
<TableHead>更新时间</TableHead>
<TableHead className="w-[80px] text-right">操作</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{groups.map((group) => (
<TableRow key={group.id}>
<TableCell>
<div className="flex items-center gap-2">
{group.is_global ? (
<Globe2 className="size-4 text-primary shrink-0" />
) : (
<ShieldCheck className="size-4 text-muted-foreground shrink-0" />
)}
<span className="font-medium">{group.name}</span>
</div>
</TableCell>
<TableCell>
<Badge variant="outline">{group.is_global ? '全局' : '自定义'}</Badge>
</TableCell>
<TableCell>
<Badge variant={group.enabled ? 'default' : 'secondary'}>
{group.enabled ? '启用' : '停用'}
</Badge>
</TableCell>
<TableCell>{countRuleEntries(group)}</TableCell>
<TableCell>
{group.is_global
? '全部网站'
: `${group.applied_site_count} 个网站`}
</TableCell>
<TableCell className="text-muted-foreground text-sm">
{group.updated_at ? formatDateTime(group.updated_at) : '—'}
</TableCell>
<TableCell className="text-right">
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button variant="ghost" size="icon" className="size-8">
<MoreHorizontal className="size-4" />
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end">
<DropdownMenuItem onClick={() => onEdit(group)}>
<Pencil className="size-4 mr-2" />
编辑
</DropdownMenuItem>
{!group.is_global ? (
<DropdownMenuItem onClick={() => onBindSites(group)}>
<Users className="size-4 mr-2" />
绑定网站
</DropdownMenuItem>
) : null}
{!group.is_global ? (
<>
<DropdownMenuSeparator />
<DropdownMenuItem
className="text-destructive focus:text-destructive"
onClick={() => onDelete(group)}
>
<Trash2 className="size-4 mr-2" />
删除
</DropdownMenuItem>
</>
) : null}
</DropdownMenuContent>
</DropdownMenu>
</TableCell>
</TableRow>
))}
</TableBody>
</Table>
);
}
@@ -0,0 +1,78 @@
'use client';
import {X} from 'lucide-react';
import {Badge} from '@/components/ui/badge';
import {Button} from '@/components/ui/button';
interface RuleListSectionProps {
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;
}
export function RuleListSection({
title,
description,
items,
groupItems = [],
tone,
emptyText,
onRemove,
onRemoveGroup,
}: RuleListSectionProps) {
const total = items.length + groupItems.length;
const badgeVariant = tone === 'whitelist' ? 'secondary' : 'destructive';
return (
<div className="rounded-lg border border-dashed p-4 space-y-3">
<div className="flex items-start justify-between gap-3">
<div>
<h4 className="text-sm font-semibold">{title}</h4>
<p className="text-xs text-muted-foreground mt-1">{description}</p>
</div>
<Badge variant="outline">{total}</Badge>
</div>
{total > 0 ? (
<div className="flex flex-wrap gap-2">
{groupItems.map((group) => (
<Badge key={`group-${group.id}`} variant={badgeVariant} className="gap-1 pr-1">
IP组: {group.name}
{!group.enabled ? ' (停用)' : ''}
<Button
type="button"
variant="ghost"
size="icon"
className="size-4 hover:bg-transparent"
onClick={() => onRemoveGroup?.(group.id)}
>
<X className="size-3" />
</Button>
</Badge>
))}
{items.map((item) => (
<Badge key={item} variant={badgeVariant} className="gap-1 pr-1 font-mono text-xs">
{item}
<Button
type="button"
variant="ghost"
size="icon"
className="size-4 hover:bg-transparent"
onClick={() => onRemove(item)}
>
<X className="size-3" />
</Button>
</Badge>
))}
</div>
) : (
<p className="text-sm text-muted-foreground">{emptyText}</p>
)}
</div>
);
}
@@ -0,0 +1,135 @@
'use client';
import {useEffect, useMemo, useState} from 'react';
import {Check, Search} from 'lucide-react';
import {Button} from '@/components/ui/button';
import {Input} from '@/components/ui/input';
import {Sheet, SheetContent, SheetDescription, SheetFooter, SheetHeader, SheetTitle,} from '@/components/ui/sheet';
import {cn} from '@/lib/utils';
import type {ProxyRouteItem, WAFRuleGroup} from '@/lib/services/openflare';
interface SiteBindingSheetProps {
group: WAFRuleGroup | null;
routes: ProxyRouteItem[];
open: boolean;
pending: boolean;
onOpenChange: (open: boolean) => void;
onSave: (ids: number[]) => void;
}
export function SiteBindingSheet({
group,
routes,
open,
pending,
onOpenChange,
onSave,
}: SiteBindingSheetProps) {
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 (
<Sheet open={open} onOpenChange={onOpenChange}>
<SheetContent side="right" className="w-full sm:max-w-lg overflow-y-auto">
<SheetHeader>
<SheetTitle>{group ? `绑定 ${group.name}` : '绑定规则组'}</SheetTitle>
<SheetDescription>
选择这个自定义规则组要叠加到哪些网站。
</SheetDescription>
</SheetHeader>
<div className="space-y-4 px-4 pb-4">
<div className="flex items-center gap-2 rounded-md border px-3 py-2">
<Search className="size-4 text-muted-foreground" />
<Input
value={keyword}
placeholder="搜索网站或域名"
className="border-0 shadow-none focus-visible:ring-0"
onChange={(event) => setKeyword(event.target.value)}
/>
<Button type="button" variant="ghost" size="sm" onClick={selectFiltered}>
全选当前
</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-md border px-3 py-2 text-left transition',
selectedSet.has(route.id) && 'border-primary bg-muted/50',
)}
>
<span
className={cn(
'flex size-5 items-center justify-center rounded border',
selectedSet.has(route.id) && 'border-primary bg-primary text-primary-foreground',
)}
>
{selectedSet.has(route.id) ? <Check className="size-3" /> : null}
</span>
<span className="min-w-0 flex-1">
<span className="block truncate text-sm font-medium">
{route.site_name}
</span>
<span className="block truncate text-xs text-muted-foreground">
{route.domains.join(', ')}
</span>
</span>
</button>
))}
</div>
</div>
<SheetFooter className="px-4">
<Button type="button" variant="outline" onClick={() => onOpenChange(false)}>
取消
</Button>
<Button
type="button"
disabled={!group || pending}
onClick={() => onSave(selectedIDs)}
>
{pending ? '保存中...' : '保存应用范围'}
</Button>
</SheetFooter>
</SheetContent>
</Sheet>
);
}