This commit is contained in:
ryan
2026-07-13 15:08:40 +08:00
parent 08e8eea932
commit 1eff7878a1
441 changed files with 26491 additions and 18405 deletions
@@ -40,7 +40,7 @@ export function CreateRuleDialog({
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent>
<form
className="flex flex-col gap-4"
className='flex flex-col gap-4'
onSubmit={async (event) => {
event.preventDefault();
if (!trimmedName || pending) return;
@@ -55,28 +55,28 @@ export function CreateRuleDialog({
</DialogHeader>
<FieldGroup>
<Field>
<FieldLabel htmlFor="waf-rule-name">规则名称</FieldLabel>
<FieldLabel htmlFor='waf-rule-name'>规则名称</FieldLabel>
<Input
id="waf-rule-name"
id='waf-rule-name'
value={name}
onChange={(event) => setName(event.target.value)}
placeholder="例如:入口防护"
autoComplete="off"
placeholder='例如:入口防护'
autoComplete='off'
autoFocus
/>
</Field>
</FieldGroup>
<DialogFooter>
<Button
type="button"
variant="outline"
type='button'
variant='outline'
disabled={pending}
onClick={() => onOpenChange(false)}
>
取消
</Button>
<Button type="submit" disabled={!trimmedName || pending}>
{pending ? <Spinner data-icon="inline-start" /> : null}
<Button type='submit' disabled={!trimmedName || pending}>
{pending ? <Spinner data-icon='inline-start' /> : null}
创建并编排
</Button>
</DialogFooter>
@@ -1,7 +1,4 @@
import type {
WAFIPGroup,
WAFIPGroupPayload,
} from '@/lib/services/openflare';
import type { WAFIPGroup, WAFIPGroupPayload } from '@/lib/services/openflare';
export function getErrorMessage(error: unknown) {
return error instanceof Error ? error.message : '操作失败';
@@ -18,7 +15,6 @@ export function parseTextareaList(text: string) {
.filter(Boolean);
}
export function parseAutomaticConfig(text: string): Record<string, unknown> {
const parsed = JSON.parse(text || '{}') as unknown;
if (!parsed || Array.isArray(parsed) || typeof parsed !== 'object') {
@@ -83,7 +79,9 @@ export function formatIPGroupBanRemaining(
if (expireDate.getTime() <= now.getTime()) {
return '已过期';
}
const diffMins = Math.round((expireDate.getTime() - now.getTime()) / (60 * 1000));
const diffMins = Math.round(
(expireDate.getTime() - now.getTime()) / (60 * 1000),
);
if (diffMins < 60) {
return `${diffMins} 分钟后`;
}
@@ -1,12 +1,12 @@
'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 { 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 { Button } from '@/components/ui/button';
import {
Dialog,
DialogContent,
@@ -15,11 +15,25 @@ import {
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 {
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,
@@ -27,7 +41,12 @@ import type {
WAFIPGroupType,
} from '@/lib/services/openflare';
import {automaticPresetRules, listToText, parseAutomaticConfig, parseTextareaList,} from './helpers';
import {
automaticPresetRules,
listToText,
parseAutomaticConfig,
parseTextareaList,
} from './helpers';
const ipGroupSchema = z
.object({
@@ -126,7 +145,9 @@ function appendAutomaticPresetRule(
return JSON.stringify(
{
lookback_minutes:
typeof config.lookback_minutes === 'number' ? config.lookback_minutes : 60,
typeof config.lookback_minutes === 'number'
? config.lookback_minutes
: 60,
...config,
rules: nextRules,
},
@@ -175,20 +196,22 @@ export function IPGroupDialog({
return (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent className="max-w-3xl max-h-[90vh] overflow-y-auto">
<DialogContent className='max-w-3xl max-h-[90vh] overflow-y-auto'>
<DialogHeader>
<DialogTitle>{group ? `编辑 ${group.name}` : '新建 IP 组'}</DialogTitle>
<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">
<form onSubmit={handleSubmit} className='space-y-4'>
<div className='grid gap-4 md:grid-cols-2'>
<FormField
control={form.control}
name="name"
name='name'
render={({ field }) => (
<FormItem>
<FormLabel>IP 组名称</FormLabel>
@@ -201,7 +224,7 @@ export function IPGroupDialog({
/>
<FormField
control={form.control}
name="type"
name='type'
render={({ field }) => (
<FormItem>
<FormLabel>类型</FormLabel>
@@ -217,9 +240,9 @@ export function IPGroupDialog({
</SelectTrigger>
</FormControl>
<SelectContent>
<SelectItem value="manual">手动</SelectItem>
<SelectItem value="automatic">自动</SelectItem>
<SelectItem value="subscription">订阅</SelectItem>
<SelectItem value='manual'>手动</SelectItem>
<SelectItem value='automatic'>自动</SelectItem>
<SelectItem value='subscription'>订阅</SelectItem>
</SelectContent>
</Select>
<FormMessage />
@@ -228,17 +251,20 @@ export function IPGroupDialog({
/>
<FormField
control={form.control}
name="enabled"
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">
<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} />
<Switch
checked={field.value}
onCheckedChange={field.onChange}
/>
</FormControl>
</FormItem>
)}
@@ -246,15 +272,18 @@ export function IPGroupDialog({
</div>
{type === 'subscription' ? (
<div className="grid gap-4 md:grid-cols-2 rounded-lg border border-dashed p-4">
<div className='grid gap-4 md:grid-cols-2 rounded-lg border border-dashed p-4'>
<FormField
control={form.control}
name="subscription_url"
name='subscription_url'
render={({ field }) => (
<FormItem>
<FormLabel>订阅 URL</FormLabel>
<FormControl>
<Input placeholder="https://example.com/ip-list.txt" {...field} />
<Input
placeholder='https://example.com/ip-list.txt'
{...field}
/>
</FormControl>
<FormMessage />
</FormItem>
@@ -262,7 +291,7 @@ export function IPGroupDialog({
/>
<FormField
control={form.control}
name="subscription_format"
name='subscription_format'
render={({ field }) => (
<FormItem>
<FormLabel>订阅格式</FormLabel>
@@ -278,8 +307,8 @@ export function IPGroupDialog({
</SelectTrigger>
</FormControl>
<SelectContent>
<SelectItem value="text">文本列表</SelectItem>
<SelectItem value="json">JSON</SelectItem>
<SelectItem value='text'>文本列表</SelectItem>
<SelectItem value='json'>JSON</SelectItem>
</SelectContent>
</Select>
<FormMessage />
@@ -288,13 +317,13 @@ export function IPGroupDialog({
/>
<FormField
control={form.control}
name="sync_interval_minutes"
name='sync_interval_minutes'
render={({ field }) => (
<FormItem>
<FormLabel>同步间隔(分钟)</FormLabel>
<FormControl>
<Input
type="number"
type='number'
min={5}
value={field.value}
onChange={(event) =>
@@ -302,21 +331,25 @@ export function IPGroupDialog({
}
/>
</FormControl>
<FormDescription>最小 5 分钟,默认 1440 分钟。</FormDescription>
<FormDescription>
最小 5 分钟,默认 1440 分钟。
</FormDescription>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="subscription_mapping_rule"
name='subscription_mapping_rule'
render={({ field }) => (
<FormItem>
<FormLabel>JSON 映射规则</FormLabel>
<FormControl>
<Input
disabled={form.watch('subscription_format') !== 'json'}
placeholder="留空表示根数组"
disabled={
form.watch('subscription_format') !== 'json'
}
placeholder='留空表示根数组'
{...field}
/>
</FormControl>
@@ -328,16 +361,16 @@ export function IPGroupDialog({
) : null}
{type === 'automatic' ? (
<div className="space-y-4 rounded-lg border border-dashed p-4">
<div className='space-y-4 rounded-lg border border-dashed p-4'>
<FormField
control={form.control}
name="sync_interval_minutes"
name='sync_interval_minutes'
render={({ field }) => (
<FormItem>
<FormLabel>同步间隔(分钟)</FormLabel>
<FormControl>
<Input
type="number"
type='number'
min={5}
value={field.value}
onChange={(event) =>
@@ -352,15 +385,15 @@ export function IPGroupDialog({
</FormItem>
)}
/>
<div className="space-y-2">
<div className='space-y-2'>
<FormLabel>预设规则</FormLabel>
<div className="flex flex-wrap gap-2">
<div className='flex flex-wrap gap-2'>
{automaticPresetRules.map((rule) => (
<Button
key={rule.expr}
type="button"
size="sm"
variant="outline"
type='button'
size='sm'
variant='outline'
onClick={() => {
const current = form.getValues('auto_config_text');
form.setValue(
@@ -369,7 +402,7 @@ export function IPGroupDialog({
);
}}
>
<Plus className="size-3.5 mr-1" />
<Plus className='size-3.5 mr-1' />
{rule.name}
</Button>
))}
@@ -377,12 +410,15 @@ export function IPGroupDialog({
</div>
<FormField
control={form.control}
name="auto_config_text"
name='auto_config_text'
render={({ field }) => (
<FormItem>
<FormLabel>自动配置 JSON</FormLabel>
<FormControl>
<Textarea className="min-h-48 font-mono text-xs" {...field} />
<Textarea
className='min-h-48 font-mono text-xs'
{...field}
/>
</FormControl>
<FormMessage />
</FormItem>
@@ -394,13 +430,13 @@ export function IPGroupDialog({
{type !== 'automatic' ? (
<FormField
control={form.control}
name="ip_list_text"
name='ip_list_text'
render={({ field }) => (
<FormItem>
<FormLabel>IP / IP 段</FormLabel>
<FormControl>
<Textarea
className="min-h-48 font-mono text-xs"
className='min-h-48 font-mono text-xs'
placeholder={'203.0.113.10\n198.51.100.0/24'}
{...field}
/>
@@ -417,14 +453,20 @@ export function IPGroupDialog({
) : null}
{form.formState.errors.root ? (
<p className="text-sm text-destructive">{form.formState.errors.root.message}</p>
<p className='text-sm text-destructive'>
{form.formState.errors.root.message}
</p>
) : null}
<DialogFooter>
<Button type="button" variant="outline" onClick={() => onOpenChange(false)}>
<Button
type='button'
variant='outline'
onClick={() => onOpenChange(false)}
>
取消
</Button>
<Button type="submit" disabled={submitting}>
<Button type='submit' disabled={submitting}>
{submitting ? '保存中...' : '保存 IP 组'}
</Button>
</DialogFooter>
@@ -8,8 +8,8 @@ import {
DialogHeader,
DialogTitle,
} from '@/components/ui/dialog';
import {Button} from '@/components/ui/button';
import type {WAFIPGroupAutoTestResult} from '@/lib/services/openflare';
import { Button } from '@/components/ui/button';
import type { WAFIPGroupAutoTestResult } from '@/lib/services/openflare';
interface IPGroupTestDialogProps {
open: boolean;
@@ -26,7 +26,7 @@ export function IPGroupTestDialog({
}: IPGroupTestDialogProps) {
return (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent className="max-w-2xl">
<DialogContent className='max-w-2xl'>
<DialogHeader>
<DialogTitle>自动规则测试结果</DialogTitle>
<DialogDescription>
@@ -35,32 +35,38 @@ export function IPGroupTestDialog({
</DialogHeader>
{loading ? (
<p className="text-sm text-muted-foreground py-6 text-center">测试中...</p>
<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">
<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
回看 {result.lookback_minutes} 分钟 · 规则 {result.rule_count}{' '}
条 · 命中 {result.matched_count} 个 IP
</p>
<p className="text-xs text-muted-foreground mt-1">
<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">
<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>
<p className='text-sm text-muted-foreground'>
当前没有匹配到任何 IP。
</p>
)}
</div>
) : (
<p className="text-sm text-muted-foreground py-6 text-center">暂无测试结果。</p>
<p className='text-sm text-muted-foreground py-6 text-center'>
暂无测试结果。
</p>
)}
<DialogFooter>
<Button type="button" onClick={() => onOpenChange(false)}>
<Button type='button' onClick={() => onOpenChange(false)}>
关闭
</Button>
</DialogFooter>
@@ -1,7 +1,7 @@
'use client';
import {Loader2, Trash2} from 'lucide-react';
import {useMemo, useState} from 'react';
import { Loader2, Trash2 } from 'lucide-react';
import { useMemo, useState } from 'react';
import {
AlertDialog,
@@ -13,7 +13,7 @@ import {
AlertDialogHeader,
AlertDialogTitle,
} from '@/components/ui/alert-dialog';
import {Button} from '@/components/ui/button';
import { Button } from '@/components/ui/button';
import {
Dialog,
DialogContent,
@@ -22,12 +22,23 @@ import {
DialogHeader,
DialogTitle,
} from '@/components/ui/dialog';
import {EmptyStateWithBorder} from '@/components/layout/empty';
import {Table, TableBody, TableCell, TableHead, TableHeader, TableRow,} from '@/components/ui/table';
import type {WAFIPGroup} from '@/lib/services/openflare';
import {formatDateTime} from '@/lib/utils';
import { EmptyStateWithBorder } from '@/components/layout/empty';
import {
Table,
TableBody,
TableCell,
TableHead,
TableHeader,
TableRow,
} from '@/components/ui/table';
import type { WAFIPGroup } from '@/lib/services/openflare';
import { formatDateTime } from '@/lib/utils';
import {getIPGroupViewEntries, ipGroupTypeLabels, type IPGroupViewEntry} from './helpers';
import {
getIPGroupViewEntries,
ipGroupTypeLabels,
type IPGroupViewEntry,
} from './helpers';
interface IPGroupViewDialogProps {
open: boolean;
@@ -46,7 +57,9 @@ export function IPGroupViewDialog({
onOpenChange,
onRemoveIp,
}: IPGroupViewDialogProps) {
const [deleteTarget, setDeleteTarget] = useState<IPGroupViewEntry | null>(null);
const [deleteTarget, setDeleteTarget] = useState<IPGroupViewEntry | null>(
null,
);
const entries = useMemo(
() => (group ? getIPGroupViewEntries(group) : []),
@@ -66,9 +79,11 @@ export function IPGroupViewDialog({
}
}}
>
<DialogContent className="max-w-3xl max-h-[90vh] overflow-y-auto">
<DialogContent className='max-w-3xl max-h-[90vh] overflow-y-auto'>
<DialogHeader>
<DialogTitle>{group ? `查看 ${group.name}` : '查看 IP 组'}</DialogTitle>
<DialogTitle>
{group ? `查看 ${group.name}` : '查看 IP 组'}
</DialogTitle>
<DialogDescription>
{group
? `${ipGroupTypeLabels[group.type]} · 共 ${entries.length} 条 IP`
@@ -77,12 +92,14 @@ export function IPGroupViewDialog({
</DialogHeader>
{loading ? (
<div className="flex items-center justify-center gap-2 py-12 text-sm text-muted-foreground">
<Loader2 className="size-4 animate-spin" />
<div className='flex items-center justify-center gap-2 py-12 text-sm text-muted-foreground'>
<Loader2 className='size-4 animate-spin' />
加载 IP 列表...
</div>
) : !group ? (
<p className="py-8 text-center text-sm text-muted-foreground">未选择 IP 组。</p>
<p className='py-8 text-center text-sm text-muted-foreground'>
未选择 IP 组。
</p>
) : entries.length === 0 ? (
<EmptyStateWithBorder
description={
@@ -92,13 +109,13 @@ export function IPGroupViewDialog({
}
/>
) : (
<div className="space-y-3">
<div className='space-y-3'>
{group.type === 'subscription' ? (
<p className="text-xs text-muted-foreground">
<p className='text-xs text-muted-foreground'>
订阅类型在下次同步时可能重新拉取已删除的 IP。
</p>
) : null}
<div className="rounded-lg border border-dashed">
<div className='rounded-lg border border-dashed'>
<Table>
<TableHeader>
<TableRow>
@@ -109,36 +126,44 @@ export function IPGroupViewDialog({
<TableHead>封禁剩余</TableHead>
</>
) : null}
<TableHead className="w-[80px] text-right">操作</TableHead>
<TableHead className='w-[80px] text-right'>
操作
</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{entries.map((entry) => (
<TableRow key={entry.ip}>
<TableCell className="font-mono text-sm">{entry.ip}</TableCell>
<TableCell className='font-mono text-sm'>
{entry.ip}
</TableCell>
{showAutomaticMeta ? (
<>
<TableCell className="text-sm text-muted-foreground">
{entry.capturedAt ? formatDateTime(entry.capturedAt) : '—'}
<TableCell className='text-sm text-muted-foreground'>
{entry.capturedAt
? formatDateTime(entry.capturedAt)
: '—'}
</TableCell>
<TableCell className='text-sm'>
{entry.banRemaining ?? '—'}
</TableCell>
<TableCell className="text-sm">{entry.banRemaining ?? '—'}</TableCell>
</>
) : null}
<TableCell className="text-right">
<TableCell className='text-right'>
<Button
type="button"
variant="ghost"
size="icon"
className="size-8 text-destructive hover:text-destructive"
type='button'
variant='ghost'
size='icon'
className='size-8 text-destructive hover:text-destructive'
disabled={removingIp === entry.ip}
onClick={() => setDeleteTarget(entry)}
>
{removingIp === entry.ip ? (
<Loader2 className="size-4 animate-spin" />
<Loader2 className='size-4 animate-spin' />
) : (
<Trash2 className="size-4" />
<Trash2 className='size-4' />
)}
<span className="sr-only">删除 {entry.ip}</span>
<span className='sr-only'>删除 {entry.ip}</span>
</Button>
</TableCell>
</TableRow>
@@ -150,7 +175,11 @@ export function IPGroupViewDialog({
)}
<DialogFooter>
<Button type="button" variant="outline" onClick={() => onOpenChange(false)}>
<Button
type='button'
variant='outline'
onClick={() => onOpenChange(false)}
>
关闭
</Button>
</DialogFooter>
@@ -169,9 +198,11 @@ export function IPGroupViewDialog({
</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel disabled={Boolean(removingIp)}>取消</AlertDialogCancel>
<AlertDialogCancel disabled={Boolean(removingIp)}>
取消
</AlertDialogCancel>
<AlertDialogAction
className="bg-destructive text-white hover:bg-destructive/90"
className='bg-destructive text-white hover:bg-destructive/90'
disabled={Boolean(removingIp)}
onClick={async () => {
if (!deleteTarget) return;
@@ -186,4 +217,4 @@ export function IPGroupViewDialog({
</AlertDialog>
</>
);
}
}
@@ -1,9 +1,16 @@
'use client';
import {Download, Eye, MoreHorizontal, Pencil, Play, Trash2} from 'lucide-react';
import {
Download,
Eye,
MoreHorizontal,
Pencil,
Play,
Trash2,
} from 'lucide-react';
import {Badge} from '@/components/ui/badge';
import {Button} from '@/components/ui/button';
import { Badge } from '@/components/ui/badge';
import { Button } from '@/components/ui/button';
import {
DropdownMenu,
DropdownMenuContent,
@@ -11,11 +18,18 @@ import {
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 {
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';
import { ipGroupTypeLabels } from './helpers';
interface IPGroupsTableProps {
groups: WAFIPGroup[];
@@ -47,15 +61,15 @@ export function IPGroupsTable({
<TableHead>引用次数</TableHead>
<TableHead>同步状态</TableHead>
<TableHead>更新时间</TableHead>
<TableHead className="w-[80px] text-right">操作</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 className='font-medium'>{group.name}</TableCell>
<TableCell>
<Badge variant="outline">{ipGroupTypeLabels[group.type]}</Badge>
<Badge variant='outline'>{ipGroupTypeLabels[group.type]}</Badge>
</TableCell>
<TableCell>
<Badge variant={group.enabled ? 'default' : 'secondary'}>
@@ -64,42 +78,43 @@ export function IPGroupsTable({
</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">
<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">
<TableCell className='text-muted-foreground text-sm'>
{group.updated_at ? formatDateTime(group.updated_at) : '—'}
</TableCell>
<TableCell className="text-right">
<TableCell className='text-right'>
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button variant="ghost" size="icon" className="size-8">
<MoreHorizontal className="size-4" />
<Button variant='ghost' size='icon' className='size-8'>
<MoreHorizontal className='size-4' />
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end">
<DropdownMenuContent align='end'>
<DropdownMenuItem onClick={() => onView(group)}>
<Eye className="size-4 mr-2" />
<Eye className='size-4 mr-2' />
查看
</DropdownMenuItem>
<DropdownMenuItem onClick={() => onEdit(group)}>
<Pencil className="size-4 mr-2" />
<Pencil className='size-4 mr-2' />
编辑
</DropdownMenuItem>
{group.type === 'automatic' ? (
<DropdownMenuItem onClick={() => onTest(group)}>
<Play className="size-4 mr-2" />
<Play className='size-4 mr-2' />
测试规则
</DropdownMenuItem>
) : null}
{group.type === 'subscription' || group.type === 'automatic' ? (
{group.type === 'subscription' ||
group.type === 'automatic' ? (
<DropdownMenuItem
disabled={syncingId === group.id}
onClick={() => onSync(group)}
>
<Download className="size-4 mr-2" />
<Download className='size-4 mr-2' />
{syncingId === group.id
? '同步中...'
: group.type === 'automatic'
@@ -109,10 +124,10 @@ export function IPGroupsTable({
) : null}
<DropdownMenuSeparator />
<DropdownMenuItem
className="text-destructive focus:text-destructive"
className='text-destructive focus:text-destructive'
onClick={() => onDelete(group)}
>
<Trash2 className="size-4 mr-2" />
<Trash2 className='size-4 mr-2' />
删除
</DropdownMenuItem>
</DropdownMenuContent>
@@ -1,9 +1,15 @@
'use client';
import {Globe2, MoreHorizontal, Pencil, ShieldCheck, Trash2} from 'lucide-react';
import {
Globe2,
MoreHorizontal,
Pencil,
ShieldCheck,
Trash2,
} from 'lucide-react';
import {Badge} from '@/components/ui/badge';
import {Button} from '@/components/ui/button';
import { Badge } from '@/components/ui/badge';
import { Button } from '@/components/ui/button';
import {
DropdownMenu,
DropdownMenuContent,
@@ -12,9 +18,16 @@ import {
DropdownMenuSeparator,
DropdownMenuTrigger,
} from '@/components/ui/dropdown-menu';
import {Table, TableBody, TableCell, TableHead, TableHeader, TableRow,} from '@/components/ui/table';
import type {WAFRule} from '@/lib/services/openflare';
import {formatDateTime} from '@/lib/utils';
import {
Table,
TableBody,
TableCell,
TableHead,
TableHeader,
TableRow,
} from '@/components/ui/table';
import type { WAFRule } from '@/lib/services/openflare';
import { formatDateTime } from '@/lib/utils';
interface RuleGroupsTableProps {
groups: WAFRule[];
@@ -37,24 +50,26 @@ export function RuleGroupsTable({
<TableHead>节点数</TableHead>
<TableHead>应用范围</TableHead>
<TableHead>更新时间</TableHead>
<TableHead className="w-[80px] text-right">操作</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">
<div className='flex items-center gap-2'>
{group.is_global ? (
<Globe2 className="size-4 text-primary shrink-0" />
<Globe2 className='size-4 text-primary shrink-0' />
) : (
<ShieldCheck className="size-4 text-muted-foreground shrink-0" />
<ShieldCheck className='size-4 text-muted-foreground shrink-0' />
)}
<span className="font-medium">{group.name}</span>
<span className='font-medium'>{group.name}</span>
</div>
</TableCell>
<TableCell>
<Badge variant="outline">{group.is_global ? '全局' : '自定义'}</Badge>
<Badge variant='outline'>
{group.is_global ? '全局' : '自定义'}
</Badge>
</TableCell>
<TableCell>
<Badge variant={group.enabled ? 'default' : 'secondary'}>
@@ -67,17 +82,17 @@ export function RuleGroupsTable({
? '全部网站'
: `${group.applied_site_count} 个网站`}
</TableCell>
<TableCell className="text-muted-foreground text-sm">
<TableCell className='text-muted-foreground text-sm'>
{group.updated_at ? formatDateTime(group.updated_at) : '—'}
</TableCell>
<TableCell className="text-right">
<TableCell className='text-right'>
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button variant="ghost" size="icon" className="size-8">
<Button variant='ghost' size='icon' className='size-8'>
<MoreHorizontal />
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end">
<DropdownMenuContent align='end'>
<DropdownMenuGroup>
<DropdownMenuItem onClick={() => onEdit(group)}>
<Pencil />
@@ -89,7 +104,7 @@ export function RuleGroupsTable({
<DropdownMenuSeparator />
<DropdownMenuGroup>
<DropdownMenuItem
variant="destructive"
variant='destructive'
onClick={() => onDelete(group)}
>
<Trash2 />