feat(frontend): 控制台 next-intl 中英双语

接入无 URL 前缀的 zh-CN/en,顶栏与外观设置可切换语言;选择写入 cookie 后刷新生效。
This commit is contained in:
ryan
2026-08-16 16:49:16 +08:00
parent 64fbaa7ef1
commit 5a8722ff07
219 changed files with 21437 additions and 5212 deletions
@@ -1,5 +1,6 @@
'use client';
import { useTranslations } from 'next-intl';
import { useEffect, useState } from 'react';
import { Button } from '@/components/ui/button';
@@ -28,6 +29,8 @@ export function CreateRuleDialog({
onOpenChange,
onCreate,
}: CreateRuleDialogProps) {
const t = useTranslations('waf');
const tCommon = useTranslations('common');
const [name, setName] = useState('');
useEffect(() => {
@@ -48,19 +51,21 @@ export function CreateRuleDialog({
}}
>
<DialogHeader>
<DialogTitle>新建 WAF 规则</DialogTitle>
<DialogTitle>{t('createDialog.title')}</DialogTitle>
<DialogDescription>
创建后将进入编排页面,通过处理单元配置规则执行流程。
{t('createDialog.description')}
</DialogDescription>
</DialogHeader>
<FieldGroup>
<Field>
<FieldLabel htmlFor='waf-rule-name'>规则名称</FieldLabel>
<FieldLabel htmlFor='waf-rule-name'>
{t('createDialog.name')}
</FieldLabel>
<Input
id='waf-rule-name'
value={name}
onChange={(event) => setName(event.target.value)}
placeholder='例如:入口防护'
placeholder={t('createDialog.namePlaceholder')}
autoComplete='off'
autoFocus
/>
@@ -73,11 +78,11 @@ export function CreateRuleDialog({
disabled={pending}
onClick={() => onOpenChange(false)}
>
取消
{tCommon('cancel')}
</Button>
<Button type='submit' disabled={!trimmedName || pending}>
{pending ? <Spinner data-icon='inline-start' /> : null}
创建并编排
{t('createDialog.submit')}
</Button>
</DialogFooter>
</form>
+29 -19
View File
@@ -1,7 +1,7 @@
import type { WAFIPGroup, WAFIPGroupPayload } from '@/lib/services/openflare';
export function getErrorMessage(error: unknown) {
return error instanceof Error ? error.message : '操作失败';
export function getErrorMessage(error: unknown, fallback?: string) {
return error instanceof Error ? error.message : (fallback ?? '操作失败');
}
export function listToText(items: string[] | undefined) {
@@ -15,27 +15,26 @@ export function parseTextareaList(text: string) {
.filter(Boolean);
}
export function parseAutomaticConfig(text: string): Record<string, unknown> {
export function parseAutomaticConfig(
text: string,
invalidMessage = '自动配置必须是 JSON 对象。',
): Record<string, unknown> {
const parsed = JSON.parse(text || '{}') as unknown;
if (!parsed || Array.isArray(parsed) || typeof parsed !== 'object') {
throw new Error('自动配置必须是 JSON 对象。');
throw new Error(invalidMessage);
}
return parsed as Record<string, unknown>;
}
export const ipGroupTypeLabels = {
manual: '手动',
automatic: '自动',
subscription: '订阅',
} as const;
export const automaticPresetRules = [
{
name: '单 IP 404 高频扫描',
labelKey: 'presets.scan404' as const,
expr: 'request_count > 100 && StatusRatio(404) >= 0.8',
},
{
name: '单 IP 直连访问异常',
labelKey: 'presets.directAccess' as const,
expr: 'ip_host_count > 50 && ip_host_ratio > 0.5',
},
];
@@ -63,39 +62,50 @@ export function buildIPGroupPayloadFromGroup(
};
}
type BanRemainingTranslator = (
key: 'ban.permanent' | 'ban.expired' | 'ban.minutesLater' | 'ban.hoursLater',
values?: { count: number },
) => string;
export function formatIPGroupBanRemaining(
capturedAt: string,
ttlSeconds: number,
now = new Date(),
now?: Date,
t?: BanRemainingTranslator,
): string {
const reference = now ?? new Date();
if (ttlSeconds <= 0) {
return '永久';
return t ? t('ban.permanent') : '永久';
}
const capturedDate = new Date(capturedAt);
if (Number.isNaN(capturedDate.getTime())) {
return '—';
}
const expireDate = new Date(capturedDate.getTime() + ttlSeconds * 1000);
if (expireDate.getTime() <= now.getTime()) {
return '已过期';
if (expireDate.getTime() <= reference.getTime()) {
return t ? t('ban.expired') : '已过期';
}
const diffMins = Math.round(
(expireDate.getTime() - now.getTime()) / (60 * 1000),
(expireDate.getTime() - reference.getTime()) / (60 * 1000),
);
if (diffMins < 60) {
return `${diffMins} 分钟后`;
return t ? t('ban.minutesLater', { count: diffMins }) : `${diffMins} 分钟后`;
}
return `${Math.round(diffMins / 60)} 小时后`;
const hours = Math.round(diffMins / 60);
return t ? t('ban.hoursLater', { count: hours }) : `${hours} 小时后`;
}
export function getIPGroupViewEntries(group: WAFIPGroup): IPGroupViewEntry[] {
export function getIPGroupViewEntries(
group: WAFIPGroup,
t?: BanRemainingTranslator,
): IPGroupViewEntry[] {
if (group.type === 'automatic' && group.ext_ips && group.ext_ips.length > 0) {
const ttl =
typeof group.auto_config?.ttl === 'number' ? group.auto_config.ttl : -1;
return group.ext_ips.map((item) => ({
ip: item.ip,
capturedAt: item.captured_at,
banRemaining: formatIPGroupBanRemaining(item.captured_at, ttl),
banRemaining: formatIPGroupBanRemaining(item.captured_at, ttl, undefined, t),
}));
}
return (group.ip_list ?? []).map((ip) => ({ ip }));
@@ -1,8 +1,9 @@
'use client';
import { useEffect } from 'react';
import { useEffect, useMemo } from 'react';
import { zodResolver } from '@hookform/resolvers/zod';
import { Plus } from 'lucide-react';
import { useTranslations } from 'next-intl';
import { useForm } from 'react-hook-form';
import { z } from 'zod';
@@ -48,40 +49,51 @@ import {
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(1),
})
.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) {
function createIPGroupSchema(
t: (key: string) => string,
tWaf: (key: string) => string,
) {
return z
.object({
name: z.string().trim().min(1, t('dialog.nameRequired')).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(1),
})
.superRefine((value, context) => {
if (value.type === 'subscription' && !value.subscription_url.trim()) {
context.addIssue({
code: z.ZodIssueCode.custom,
path: ['auto_config_text'],
message: error instanceof Error ? error.message : '自动配置格式错误',
path: ['subscription_url'],
message: t('dialog.subscriptionUrlRequired'),
});
}
}
});
if (value.type === 'automatic') {
try {
parseAutomaticConfig(
value.auto_config_text,
tWaf('autoConfigMustBeObject'),
);
} catch (error) {
context.addIssue({
code: z.ZodIssueCode.custom,
path: ['auto_config_text'],
message:
error instanceof Error
? error.message
: tWaf('autoConfigInvalid'),
});
}
}
});
}
type IPGroupFormValues = z.infer<typeof ipGroupSchema>;
type IPGroupFormValues = z.infer<ReturnType<typeof createIPGroupSchema>>;
const defaultValues: IPGroupFormValues = {
name: '',
@@ -114,10 +126,13 @@ function buildFormValues(group: WAFIPGroup | null): IPGroupFormValues {
};
}
function buildPayload(values: IPGroupFormValues): WAFIPGroupPayload {
function buildPayload(
values: IPGroupFormValues,
invalidMessage: string,
): WAFIPGroupPayload {
const autoConfig =
values.type === 'automatic'
? parseAutomaticConfig(values.auto_config_text)
? parseAutomaticConfig(values.auto_config_text, invalidMessage)
: {};
return {
name: values.name.trim(),
@@ -135,8 +150,9 @@ function buildPayload(values: IPGroupFormValues): WAFIPGroupPayload {
function appendAutomaticPresetRule(
autoConfigText: string,
rule: (typeof automaticPresetRules)[number],
invalidMessage: string,
) {
const config = parseAutomaticConfig(autoConfigText);
const config = parseAutomaticConfig(autoConfigText, invalidMessage);
const rules = Array.isArray(config.rules) ? config.rules : [];
const exists = rules.some(
(item) =>
@@ -185,6 +201,13 @@ export function IPGroupDialog({
onOpenChange,
onSubmit,
}: IPGroupDialogProps) {
const t = useTranslations('ipGroups');
const tWaf = useTranslations('waf');
const tCommon = useTranslations('common');
const ipGroupSchema = useMemo(
() => createIPGroupSchema((key) => t(key), (key) => tWaf(key)),
[t, tWaf],
);
const form = useForm<IPGroupFormValues>({
resolver: zodResolver(ipGroupSchema),
defaultValues,
@@ -199,11 +222,12 @@ export function IPGroupDialog({
const handleSubmit = form.handleSubmit(async (values) => {
try {
await onSubmit(buildPayload(values));
await onSubmit(buildPayload(values, tWaf('autoConfigMustBeObject')));
onOpenChange(false);
} catch (error) {
form.setError('root', {
message: error instanceof Error ? error.message : '保存失败',
message:
error instanceof Error ? error.message : tWaf('saveFailed'),
});
}
});
@@ -213,11 +237,11 @@ export function IPGroupDialog({
<DialogContent className='max-w-3xl max-h-[90vh] overflow-y-auto'>
<DialogHeader>
<DialogTitle>
{group ? `编辑 ${group.name}` : '新建 IP 组'}
{group
? t('dialog.editTitle', { name: group.name })
: t('dialog.createTitle')}
</DialogTitle>
<DialogDescription>
维护可被 WAF IP 黑白名单引用的手动、自动与订阅 IP 集合。
</DialogDescription>
<DialogDescription>{t('dialog.description')}</DialogDescription>
</DialogHeader>
<Form {...form}>
@@ -228,7 +252,7 @@ export function IPGroupDialog({
name='name'
render={({ field }) => (
<FormItem>
<FormLabel>IP 组名称</FormLabel>
<FormLabel>{t('dialog.name')}</FormLabel>
<FormControl>
<Input {...field} />
</FormControl>
@@ -241,7 +265,7 @@ export function IPGroupDialog({
name='type'
render={({ field }) => (
<FormItem>
<FormLabel>类型</FormLabel>
<FormLabel>{t('dialog.type')}</FormLabel>
<Select
value={field.value}
onValueChange={(value) =>
@@ -254,9 +278,15 @@ export function IPGroupDialog({
</SelectTrigger>
</FormControl>
<SelectContent>
<SelectItem value='manual'>手动</SelectItem>
<SelectItem value='automatic'>自动</SelectItem>
<SelectItem value='subscription'>订阅</SelectItem>
<SelectItem value='manual'>
{t('types.manual')}
</SelectItem>
<SelectItem value='automatic'>
{t('types.automatic')}
</SelectItem>
<SelectItem value='subscription'>
{t('types.subscription')}
</SelectItem>
</SelectContent>
</Select>
<FormMessage />
@@ -269,9 +299,9 @@ export function IPGroupDialog({
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>
<FormLabel>{t('dialog.enable')}</FormLabel>
<FormDescription>
关闭后保留配置,但发布时不会展开到 WAF 运行时名单。
{t('dialog.enableDesc')}
</FormDescription>
</div>
<FormControl>
@@ -292,7 +322,7 @@ export function IPGroupDialog({
name='subscription_url'
render={({ field }) => (
<FormItem>
<FormLabel>订阅 URL</FormLabel>
<FormLabel>{t('dialog.subscriptionUrl')}</FormLabel>
<FormControl>
<Input
placeholder='https://example.com/ip-list.txt'
@@ -308,7 +338,7 @@ export function IPGroupDialog({
name='subscription_format'
render={({ field }) => (
<FormItem>
<FormLabel>订阅格式</FormLabel>
<FormLabel>{t('dialog.subscriptionFormat')}</FormLabel>
<Select
value={field.value}
onValueChange={(value) =>
@@ -321,7 +351,9 @@ export function IPGroupDialog({
</SelectTrigger>
</FormControl>
<SelectContent>
<SelectItem value='text'>文本列表</SelectItem>
<SelectItem value='text'>
{t('dialog.formatText')}
</SelectItem>
<SelectItem value='json'>JSON</SelectItem>
</SelectContent>
</Select>
@@ -334,7 +366,7 @@ export function IPGroupDialog({
name='sync_interval_minutes'
render={({ field }) => (
<FormItem>
<FormLabel>同步间隔(分钟)</FormLabel>
<FormLabel>{t('dialog.syncInterval')}</FormLabel>
<FormControl>
<Input
type='number'
@@ -346,7 +378,7 @@ export function IPGroupDialog({
/>
</FormControl>
<FormDescription>
最小 1 分钟,默认 1440 分钟。
{t('dialog.syncIntervalDesc')}
</FormDescription>
<FormMessage />
</FormItem>
@@ -357,13 +389,13 @@ export function IPGroupDialog({
name='subscription_mapping_rule'
render={({ field }) => (
<FormItem>
<FormLabel>JSON 映射规则</FormLabel>
<FormLabel>{t('dialog.mappingRule')}</FormLabel>
<FormControl>
<Input
disabled={
form.watch('subscription_format') !== 'json'
}
placeholder='留空表示根数组'
placeholder={t('dialog.mappingPlaceholder')}
{...field}
/>
</FormControl>
@@ -381,7 +413,7 @@ export function IPGroupDialog({
name='sync_interval_minutes'
render={({ field }) => (
<FormItem>
<FormLabel>同步间隔(分钟)</FormLabel>
<FormLabel>{t('dialog.syncInterval')}</FormLabel>
<FormControl>
<Input
type='number'
@@ -393,15 +425,14 @@ export function IPGroupDialog({
/>
</FormControl>
<FormDescription>
定时从请求日志挖掘恶意 IP 的周期。最小 1 分钟,默认 1440
分钟。
{t('dialog.autoIntervalDesc')}
</FormDescription>
<FormMessage />
</FormItem>
)}
/>
<div className='space-y-2'>
<FormLabel>预设规则</FormLabel>
<FormLabel>{t('dialog.presetRules')}</FormLabel>
<div className='flex flex-wrap gap-2'>
{automaticPresetRules.map((rule) => (
<Button
@@ -413,12 +444,16 @@ export function IPGroupDialog({
const current = form.getValues('auto_config_text');
form.setValue(
'auto_config_text',
appendAutomaticPresetRule(current, rule),
appendAutomaticPresetRule(
current,
rule,
tWaf('autoConfigMustBeObject'),
),
);
}}
>
<Plus className='size-3.5 mr-1' />
{rule.name}
{t(rule.labelKey)}
</Button>
))}
</div>
@@ -428,7 +463,7 @@ export function IPGroupDialog({
name='auto_config_text'
render={({ field }) => (
<FormItem>
<FormLabel>自动配置 JSON</FormLabel>
<FormLabel>{t('dialog.autoConfigJson')}</FormLabel>
<FormControl>
<Textarea
className='min-h-48 font-mono text-xs'
@@ -448,7 +483,7 @@ export function IPGroupDialog({
name='ip_list_text'
render={({ field }) => (
<FormItem>
<FormLabel>IP / IP 段</FormLabel>
<FormLabel>{t('dialog.ipList')}</FormLabel>
<FormControl>
<Textarea
className='min-h-48 font-mono text-xs'
@@ -458,8 +493,8 @@ export function IPGroupDialog({
</FormControl>
<FormDescription>
{type === 'subscription'
? '订阅同步会覆盖此列表;也可以先手动保存当前内容。'
: '支持单个 IP 或 CIDR,每行一个。'}
? t('dialog.ipListSubscriptionHint')
: t('dialog.ipListManualHint')}
</FormDescription>
<FormMessage />
</FormItem>
@@ -479,10 +514,10 @@ export function IPGroupDialog({
variant='outline'
onClick={() => onOpenChange(false)}
>
取消
{tCommon('cancel')}
</Button>
<Button type='submit' disabled={submitting}>
{submitting ? '保存中...' : '保存 IP 组'}
{submitting ? t('dialog.saving') : t('dialog.save')}
</Button>
</DialogFooter>
</form>
@@ -1,5 +1,7 @@
'use client';
import { useTranslations } from 'next-intl';
import {
Dialog,
DialogContent,
@@ -24,29 +26,33 @@ export function IPGroupTestDialog({
result,
onOpenChange,
}: IPGroupTestDialogProps) {
const t = useTranslations('ipGroups.testDialog');
return (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent className='max-w-2xl'>
<DialogHeader>
<DialogTitle>自动规则测试结果</DialogTitle>
<DialogDescription>
基于当前自动配置 JSON 对请求日志进行回看测试。
</DialogDescription>
<DialogTitle>{t('title')}</DialogTitle>
<DialogDescription>{t('description')}</DialogDescription>
</DialogHeader>
{loading ? (
<p className='text-sm text-muted-foreground py-6 text-center'>
测试中...
{t('testing')}
</p>
) : result ? (
<div className='space-y-4'>
<div className='rounded-lg border border-dashed p-4 text-sm'>
<p>
回看 {result.lookback} · 规则 {result.rule_count} 条 · 命中{' '}
{result.matched_count} 个 IP
{t('summary', {
lookback: result.lookback,
rules: result.rule_count,
matched: result.matched_count,
})}
</p>
<p className='text-xs text-muted-foreground mt-1'>
测试时间:{new Date(result.tested_at).toLocaleString()}
{t('testedAt', {
time: new Date(result.tested_at).toLocaleString(),
})}
</p>
</div>
{result.matched_count > 0 ? (
@@ -54,20 +60,18 @@ export function IPGroupTestDialog({
{result.matched_ips.join('\n')}
</pre>
) : (
<p className='text-sm text-muted-foreground'>
当前没有匹配到任何 IP。
</p>
<p className='text-sm text-muted-foreground'>{t('noMatch')}</p>
)}
</div>
) : (
<p className='text-sm text-muted-foreground py-6 text-center'>
暂无测试结果。
{t('empty')}
</p>
)}
<DialogFooter>
<Button type='button' onClick={() => onOpenChange(false)}>
关闭
{t('close')}
</Button>
</DialogFooter>
</DialogContent>
@@ -1,6 +1,7 @@
'use client';
import { Loader2, Trash2 } from 'lucide-react';
import { useTranslations } from 'next-intl';
import { useMemo, useState } from 'react';
import {
@@ -34,11 +35,7 @@ import {
import type { WAFIPGroup } from '@/lib/services/openflare';
import { formatDateTime } from '@/lib/utils';
import {
getIPGroupViewEntries,
ipGroupTypeLabels,
type IPGroupViewEntry,
} from './helpers';
import { getIPGroupViewEntries, type IPGroupViewEntry } from './helpers';
interface IPGroupViewDialogProps {
open: boolean;
@@ -57,13 +54,18 @@ export function IPGroupViewDialog({
onOpenChange,
onRemoveIp,
}: IPGroupViewDialogProps) {
const t = useTranslations('ipGroups');
const tCommon = useTranslations('common');
const [deleteTarget, setDeleteTarget] = useState<IPGroupViewEntry | null>(
null,
);
const entries = useMemo(
() => (group ? getIPGroupViewEntries(group) : []),
[group],
() =>
group
? getIPGroupViewEntries(group, (key, values) => t(key, values))
: [],
[group, t],
);
const showAutomaticMeta = group?.type === 'automatic';
@@ -82,52 +84,57 @@ export function IPGroupViewDialog({
<DialogContent className='max-w-3xl max-h-[90vh] overflow-y-auto'>
<DialogHeader>
<DialogTitle>
{group ? `查看 ${group.name}` : '查看 IP 组'}
{group
? t('viewDialog.titleNamed', { name: group.name })
: t('viewDialog.title')}
</DialogTitle>
<DialogDescription>
{group
? `${ipGroupTypeLabels[group.type]} · 共 ${entries.length} 条 IP`
: '查看当前 IP 组中的 IP 列表,并可移除不需要的条目。'}
? t('viewDialog.summary', {
type: t(`types.${group.type}`),
count: entries.length,
})
: t('viewDialog.fallbackDesc')}
</DialogDescription>
</DialogHeader>
{loading ? (
<div className='flex items-center justify-center gap-2 py-12 text-sm text-muted-foreground'>
<Loader2 className='size-4 animate-spin' />
加载 IP 列表...
{t('viewDialog.loading')}
</div>
) : !group ? (
<p className='py-8 text-center text-sm text-muted-foreground'>
未选择 IP 组。
{t('viewDialog.noGroup')}
</p>
) : entries.length === 0 ? (
<EmptyStateWithBorder
description={
group.type === 'automatic'
? '该自动规则暂未抓取任何 IP,可点击「立即执行」手动触发抓取。'
: '当前 IP 组暂无 IP 条目。'
? t('viewDialog.emptyAuto')
: t('viewDialog.empty')
}
/>
) : (
<div className='space-y-3'>
{group.type === 'subscription' ? (
<p className='text-xs text-muted-foreground'>
订阅类型在下次同步时可能重新拉取已删除的 IP。
{t('viewDialog.subscriptionHint')}
</p>
) : null}
<div className='rounded-lg border border-dashed'>
<Table>
<TableHeader>
<TableRow>
<TableHead>IP 地址</TableHead>
<TableHead>{t('viewDialog.ipAddress')}</TableHead>
{showAutomaticMeta ? (
<>
<TableHead>抓取时间</TableHead>
<TableHead>封禁剩余</TableHead>
<TableHead>{t('viewDialog.capturedAt')}</TableHead>
<TableHead>{t('viewDialog.banRemaining')}</TableHead>
</>
) : null}
<TableHead className='w-[80px] text-right'>
操作
{t('viewDialog.actions')}
</TableHead>
</TableRow>
</TableHeader>
@@ -163,7 +170,9 @@ export function IPGroupViewDialog({
) : (
<Trash2 className='size-4' />
)}
<span className='sr-only'>删除 {entry.ip}</span>
<span className='sr-only'>
{t('viewDialog.deleteIp', { ip: entry.ip })}
</span>
</Button>
</TableCell>
</TableRow>
@@ -180,7 +189,7 @@ export function IPGroupViewDialog({
variant='outline'
onClick={() => onOpenChange(false)}
>
关闭
{t('viewDialog.close')}
</Button>
</DialogFooter>
</DialogContent>
@@ -192,14 +201,17 @@ export function IPGroupViewDialog({
>
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle>确认删除 IP</AlertDialogTitle>
<AlertDialogTitle>{t('viewDialog.deleteTitle')}</AlertDialogTitle>
<AlertDialogDescription>
确认从 IP 组「{group?.name}」中移除 {deleteTarget?.ip} 吗?
{t('viewDialog.deleteDesc', {
name: group?.name ?? '',
ip: deleteTarget?.ip ?? '',
})}
</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel disabled={Boolean(removingIp)}>
取消
{tCommon('cancel')}
</AlertDialogCancel>
<AlertDialogAction
className='bg-destructive text-white hover:bg-destructive/90'
@@ -210,7 +222,9 @@ export function IPGroupViewDialog({
setDeleteTarget(null);
}}
>
{removingIp ? '删除中...' : '确认删除'}
{removingIp
? t('viewDialog.deleting')
: t('viewDialog.confirmDelete')}
</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
@@ -1,6 +1,7 @@
'use client';
import { Download, Eye, Pencil, Play, Trash2 } from 'lucide-react';
import { useTranslations } from 'next-intl';
import { Badge } from '@/components/ui/badge';
import { Button } from '@/components/ui/button';
@@ -12,11 +13,9 @@ import {
TableHeader,
TableRow,
} from '@/components/ui/table';
import type { WAFIPGroup } from '@/lib/services/openflare';
import type { WAFIPGroup, WAFIPGroupType } from '@/lib/services/openflare';
import { formatDateTime } from '@/lib/utils';
import { ipGroupTypeLabels } from './helpers';
interface IPGroupsTableProps {
groups: WAFIPGroup[];
syncingId: number | null;
@@ -36,18 +35,22 @@ export function IPGroupsTable({
onSync,
onTest,
}: IPGroupsTableProps) {
const t = useTranslations('ipGroups');
const typeLabel = (type: WAFIPGroupType) => t(`types.${type}`);
return (
<Table>
<TableHeader>
<TableRow>
<TableHead>名称</TableHead>
<TableHead>类型</TableHead>
<TableHead>状态</TableHead>
<TableHead>IP 数</TableHead>
<TableHead>引用次数</TableHead>
<TableHead>同步状态</TableHead>
<TableHead>更新时间</TableHead>
<TableHead className='w-[168px] text-right'>操作</TableHead>
<TableHead>{t('columns.name')}</TableHead>
<TableHead>{t('columns.type')}</TableHead>
<TableHead>{t('columns.status')}</TableHead>
<TableHead>{t('columns.ipCount')}</TableHead>
<TableHead>{t('columns.refCount')}</TableHead>
<TableHead>{t('columns.syncStatus')}</TableHead>
<TableHead>{t('columns.updatedAt')}</TableHead>
<TableHead className='w-[168px] text-right'>
{t('columns.actions')}
</TableHead>
</TableRow>
</TableHeader>
<TableBody>
@@ -55,11 +58,11 @@ export function IPGroupsTable({
<TableRow key={group.id}>
<TableCell className='font-medium'>{group.name}</TableCell>
<TableCell>
<Badge variant='outline'>{ipGroupTypeLabels[group.type]}</Badge>
<Badge variant='outline'>{typeLabel(group.type)}</Badge>
</TableCell>
<TableCell>
<Badge variant={group.enabled ? 'default' : 'secondary'}>
{group.enabled ? '启用' : '停用'}
{group.enabled ? t('enabled') : t('disabled')}
</Badge>
</TableCell>
<TableCell>{group.ip_list.length}</TableCell>
@@ -67,7 +70,7 @@ export function IPGroupsTable({
<TableCell className='max-w-[200px] truncate text-sm text-muted-foreground'>
{group.last_sync_status
? `${group.last_sync_status}: ${group.last_sync_message}`
: '尚无同步记录'}
: t('noSyncRecord')}
</TableCell>
<TableCell className='text-sm text-muted-foreground'>
{group.updated_at ? formatDateTime(group.updated_at) : '—'}
@@ -79,8 +82,8 @@ export function IPGroupsTable({
variant='ghost'
size='icon'
className='size-8'
title='查看'
aria-label='查看'
title={t('actions.view')}
aria-label={t('actions.view')}
onClick={() => onView(group)}
>
<Eye />
@@ -90,8 +93,8 @@ export function IPGroupsTable({
variant='ghost'
size='icon'
className='size-8'
title='编辑'
aria-label='编辑'
title={t('actions.edit')}
aria-label={t('actions.edit')}
onClick={() => onEdit(group)}
>
<Pencil />
@@ -102,8 +105,8 @@ export function IPGroupsTable({
variant='ghost'
size='icon'
className='size-8'
title='测试规则'
aria-label='测试规则'
title={t('actions.test')}
aria-label={t('actions.test')}
onClick={() => onTest(group)}
>
<Play />
@@ -115,9 +118,15 @@ export function IPGroupsTable({
variant='ghost'
size='icon'
className='size-8'
title={group.type === 'automatic' ? '立即执行' : '立即同步'}
title={
group.type === 'automatic'
? t('actions.runNow')
: t('actions.syncNow')
}
aria-label={
group.type === 'automatic' ? '立即执行' : '立即同步'
group.type === 'automatic'
? t('actions.runNow')
: t('actions.syncNow')
}
disabled={syncingId === group.id}
onClick={() => onSync(group)}
@@ -130,8 +139,8 @@ export function IPGroupsTable({
variant='ghost'
size='icon'
className='size-8 text-destructive hover:text-destructive'
title='删除'
aria-label='删除'
title={t('actions.delete')}
aria-label={t('actions.delete')}
onClick={() => onDelete(group)}
>
<Trash2 />
@@ -1,6 +1,7 @@
'use client';
import { Globe2, Pencil, ShieldCheck, Trash2 } from 'lucide-react';
import { useTranslations } from 'next-intl';
import { Badge } from '@/components/ui/badge';
import { Button } from '@/components/ui/button';
@@ -26,17 +27,20 @@ export function RuleGroupsTable({
onEdit,
onDelete,
}: RuleGroupsTableProps) {
const t = useTranslations('waf');
return (
<Table>
<TableHeader>
<TableRow>
<TableHead>名称</TableHead>
<TableHead>类型</TableHead>
<TableHead>状态</TableHead>
<TableHead>节点数</TableHead>
<TableHead>应用范围</TableHead>
<TableHead>更新时间</TableHead>
<TableHead className='w-[88px] text-right'>操作</TableHead>
<TableHead>{t('columns.name')}</TableHead>
<TableHead>{t('columns.type')}</TableHead>
<TableHead>{t('columns.status')}</TableHead>
<TableHead>{t('columns.nodeCount')}</TableHead>
<TableHead>{t('columns.scope')}</TableHead>
<TableHead>{t('columns.updatedAt')}</TableHead>
<TableHead className='w-[88px] text-right'>
{t('columns.actions')}
</TableHead>
</TableRow>
</TableHeader>
<TableBody>
@@ -54,19 +58,19 @@ export function RuleGroupsTable({
</TableCell>
<TableCell>
<Badge variant='outline'>
{group.is_global ? '全局' : '自定义'}
{group.is_global ? t('global') : t('custom')}
</Badge>
</TableCell>
<TableCell>
<Badge variant={group.enabled ? 'default' : 'secondary'}>
{group.enabled ? '启用' : '停用'}
{group.enabled ? t('enable') : t('disable')}
</Badge>
</TableCell>
<TableCell>{group.graph.nodes.length}</TableCell>
<TableCell>
{group.is_global
? '全部网站'
: `${group.applied_site_count} 个网站`}
? t('allSites')
: t('siteCount', { count: group.applied_site_count })}
</TableCell>
<TableCell className='text-sm text-muted-foreground'>
{group.updated_at ? formatDateTime(group.updated_at) : '—'}
@@ -78,8 +82,8 @@ export function RuleGroupsTable({
variant='ghost'
size='icon'
className='size-8'
title='编排'
aria-label='编排'
title={t('actions.compose')}
aria-label={t('actions.compose')}
onClick={() => onEdit(group)}
>
<Pencil />
@@ -90,8 +94,8 @@ export function RuleGroupsTable({
variant='ghost'
size='icon'
className='size-8 text-destructive hover:text-destructive'
title='删除'
aria-label='删除'
title={t('actions.delete')}
aria-label={t('actions.delete')}
onClick={() => onDelete(group)}
>
<Trash2 />