mirror of
https://github.com/Rain-kl/OpenFlare.git
synced 2026-10-13 18:36:37 +08:00
feat(frontend): 控制台 next-intl 中英双语
接入无 URL 前缀的 zh-CN/en,顶栏与外观设置可切换语言;选择写入 cookie 后刷新生效。
This commit is contained in:
@@ -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>
|
||||
|
||||
@@ -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 />
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
|
||||
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
|
||||
import { Loader2, Plus, RefreshCw, Shield } from 'lucide-react';
|
||||
import { useTranslations } from 'next-intl';
|
||||
import { useRouter } from 'next/navigation';
|
||||
import { useState } from 'react';
|
||||
import { toast } from 'sonner';
|
||||
@@ -37,6 +38,8 @@ import { RuleGroupsTable } from './components/rule-groups-table';
|
||||
const ruleGroupsQueryKey = ['openflare', 'waf', 'rule-groups'];
|
||||
|
||||
export default function WafPage() {
|
||||
const t = useTranslations('waf');
|
||||
const tCommon = useTranslations('common');
|
||||
const router = useRouter();
|
||||
const queryClient = useQueryClient();
|
||||
const [createOpen, setCreateOpen] = useState(false);
|
||||
@@ -59,24 +62,24 @@ export default function WafPage() {
|
||||
const createMutation = useMutation({
|
||||
mutationFn: (name: string) => WafService.createRule({ name }),
|
||||
onSuccess: (rule) => {
|
||||
toast.success('规则已创建');
|
||||
toast.success(t('created'));
|
||||
setCreateOpen(false);
|
||||
router.push(`/waf/rules/editor?id=${rule.id}`);
|
||||
},
|
||||
onError: (error) => {
|
||||
toast.error(getErrorMessage(error));
|
||||
toast.error(getErrorMessage(error, t('operationFailed')));
|
||||
},
|
||||
});
|
||||
|
||||
const deleteMutation = useMutation({
|
||||
mutationFn: (id: number) => WafService.deleteRuleGroup(id),
|
||||
onSuccess: async () => {
|
||||
toast.success('规则组已删除');
|
||||
toast.success(t('groupDeleted'));
|
||||
setDeleteTarget(null);
|
||||
await invalidate();
|
||||
},
|
||||
onError: (error) => {
|
||||
toast.error(getErrorMessage(error));
|
||||
toast.error(getErrorMessage(error, t('operationFailed')));
|
||||
},
|
||||
});
|
||||
|
||||
@@ -93,7 +96,7 @@ export default function WafPage() {
|
||||
<div className='flex items-center justify-between gap-3'>
|
||||
<div className='flex items-center gap-2'>
|
||||
<Shield className='size-5 text-primary' />
|
||||
<h1 className='text-2xl font-semibold tracking-tight'>WAF</h1>
|
||||
<h1 className='text-2xl font-semibold tracking-tight'>{t('title')}</h1>
|
||||
</div>
|
||||
<div className='flex items-center gap-2'>
|
||||
<Button
|
||||
@@ -102,7 +105,7 @@ export default function WafPage() {
|
||||
onClick={() => setCreateOpen(true)}
|
||||
>
|
||||
<Plus data-icon='inline-start' />
|
||||
新建规则
|
||||
{t('createRule')}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
@@ -111,10 +114,10 @@ export default function WafPage() {
|
||||
<CardHeader className='pb-3'>
|
||||
<div className='flex items-center justify-between gap-3'>
|
||||
<div>
|
||||
<CardTitle className='text-base font-semibold'>规则组</CardTitle>
|
||||
<CardDescription>
|
||||
使用可视化流程编排 WAF 防护规则;全局规则始终应用到所有网站。
|
||||
</CardDescription>
|
||||
<CardTitle className='text-base font-semibold'>
|
||||
{t('ruleGroups')}
|
||||
</CardTitle>
|
||||
<CardDescription>{t('ruleGroupsDesc')}</CardDescription>
|
||||
</div>
|
||||
<Button
|
||||
variant='outline'
|
||||
@@ -128,7 +131,7 @@ export default function WafPage() {
|
||||
) : (
|
||||
<RefreshCw className='size-3.5 mr-1' />
|
||||
)}
|
||||
刷新
|
||||
{t('refresh')}
|
||||
</Button>
|
||||
</div>
|
||||
</CardHeader>
|
||||
@@ -136,12 +139,12 @@ export default function WafPage() {
|
||||
{loading ? (
|
||||
<LoadingStateWithBorder
|
||||
icon={Shield}
|
||||
description='加载 WAF 规则组中...'
|
||||
description={t('loading')}
|
||||
/>
|
||||
) : error ? (
|
||||
<div className='p-8 border border-dashed rounded-lg'>
|
||||
<ErrorInline
|
||||
message={getErrorMessage(error)}
|
||||
message={getErrorMessage(error, t('operationFailed'))}
|
||||
onRetry={handleRefresh}
|
||||
className='justify-center'
|
||||
/>
|
||||
@@ -149,7 +152,7 @@ export default function WafPage() {
|
||||
) : groups.length === 0 ? (
|
||||
<EmptyStateWithBorder
|
||||
icon={Shield}
|
||||
description='暂无规则组,系统通常会自动创建全局规则组。'
|
||||
description={t('empty')}
|
||||
/>
|
||||
) : (
|
||||
<RuleGroupsTable
|
||||
@@ -176,14 +179,14 @@ export default function WafPage() {
|
||||
>
|
||||
<AlertDialogContent>
|
||||
<AlertDialogHeader>
|
||||
<AlertDialogTitle>确认删除规则组</AlertDialogTitle>
|
||||
<AlertDialogTitle>{t('deleteTitle')}</AlertDialogTitle>
|
||||
<AlertDialogDescription>
|
||||
确认删除规则组「{deleteTarget?.name}」吗?删除后无法恢复。
|
||||
{t('deleteDesc', { name: deleteTarget?.name ?? '' })}
|
||||
</AlertDialogDescription>
|
||||
</AlertDialogHeader>
|
||||
<AlertDialogFooter>
|
||||
<AlertDialogCancel disabled={deleteMutation.isPending}>
|
||||
取消
|
||||
{tCommon('cancel')}
|
||||
</AlertDialogCancel>
|
||||
<AlertDialogAction
|
||||
className='bg-destructive text-white hover:bg-destructive/90'
|
||||
@@ -192,7 +195,7 @@ export default function WafPage() {
|
||||
deleteTarget && deleteMutation.mutate(deleteTarget.id)
|
||||
}
|
||||
>
|
||||
{deleteMutation.isPending ? '删除中...' : '确认删除'}
|
||||
{deleteMutation.isPending ? t('deleting') : t('confirmDelete')}
|
||||
</AlertDialogAction>
|
||||
</AlertDialogFooter>
|
||||
</AlertDialogContent>
|
||||
|
||||
@@ -1,10 +1,28 @@
|
||||
import { fireEvent, render, screen } from '@testing-library/react';
|
||||
import { NextIntlClientProvider } from 'next-intl';
|
||||
import type { ReactElement } from 'react';
|
||||
import { expect, it, vi } from 'vitest';
|
||||
|
||||
import type { WAFIPGroup, WAFRuleNode } from '@/lib/services/openflare';
|
||||
import mainZh from '@/messages/zh-CN.json';
|
||||
import securityZh from '@/messages/fragments/security.zh-CN.json';
|
||||
|
||||
import { NodeProperties } from './node-properties';
|
||||
|
||||
const messages = { ...mainZh, ...securityZh };
|
||||
|
||||
function renderWithIntl(ui: ReactElement) {
|
||||
return render(
|
||||
<NextIntlClientProvider
|
||||
locale='zh-CN'
|
||||
messages={messages}
|
||||
timeZone='Asia/Shanghai'
|
||||
>
|
||||
{ui}
|
||||
</NextIntlClientProvider>,
|
||||
);
|
||||
}
|
||||
|
||||
it('hides match and block until UA check is enabled', () => {
|
||||
const node: WAFRuleNode = {
|
||||
id: 'ua',
|
||||
@@ -22,7 +40,7 @@ it('hides match and block until UA check is enabled', () => {
|
||||
},
|
||||
};
|
||||
const onChange = vi.fn();
|
||||
const { rerender } = render(
|
||||
const { rerender } = renderWithIntl(
|
||||
<NodeProperties node={node} ipGroups={[]} onChange={onChange} />,
|
||||
);
|
||||
expect(
|
||||
@@ -36,11 +54,17 @@ it('hides match and block until UA check is enabled', () => {
|
||||
}),
|
||||
);
|
||||
rerender(
|
||||
<NodeProperties
|
||||
node={{ ...node, config: { ...node.config, require_ua: true } }}
|
||||
ipGroups={[]}
|
||||
onChange={onChange}
|
||||
/>,
|
||||
<NextIntlClientProvider
|
||||
locale='zh-CN'
|
||||
messages={messages}
|
||||
timeZone='Asia/Shanghai'
|
||||
>
|
||||
<NodeProperties
|
||||
node={{ ...node, config: { ...node.config, require_ua: true } }}
|
||||
ipGroups={[]}
|
||||
onChange={onChange}
|
||||
/>
|
||||
</NextIntlClientProvider>,
|
||||
);
|
||||
expect(
|
||||
screen.getByRole('switch', { name: /屏蔽常见爬虫/ }),
|
||||
@@ -62,7 +86,9 @@ it('edits display name for configurable nodes', () => {
|
||||
config: { ips: [], cidrs: [], ip_group_ids: [] },
|
||||
};
|
||||
const onChange = vi.fn();
|
||||
render(<NodeProperties node={node} ipGroups={[]} onChange={onChange} />);
|
||||
renderWithIntl(
|
||||
<NodeProperties node={node} ipGroups={[]} onChange={onChange} />,
|
||||
);
|
||||
fireEvent.change(screen.getByLabelText('显示名称'), {
|
||||
target: { value: '内网放行' },
|
||||
});
|
||||
@@ -78,7 +104,9 @@ it('hides display name for system nodes', () => {
|
||||
position: { x: 0, y: 0 },
|
||||
config: {},
|
||||
};
|
||||
render(<NodeProperties node={node} ipGroups={[]} onChange={vi.fn()} />);
|
||||
renderWithIntl(
|
||||
<NodeProperties node={node} ipGroups={[]} onChange={vi.fn()} />,
|
||||
);
|
||||
expect(screen.queryByLabelText('显示名称')).not.toBeInTheDocument();
|
||||
expect(screen.getByText('系统节点无需配置。')).toBeInTheDocument();
|
||||
});
|
||||
@@ -92,7 +120,9 @@ it('edits IP group config through a typed multi-select', async () => {
|
||||
};
|
||||
const group = { id: 7, name: '办公室出口' } as WAFIPGroup;
|
||||
const onChange = vi.fn();
|
||||
render(<NodeProperties node={node} ipGroups={[group]} onChange={onChange} />);
|
||||
renderWithIntl(
|
||||
<NodeProperties node={node} ipGroups={[group]} onChange={onChange} />,
|
||||
);
|
||||
fireEvent.click(screen.getByRole('button', { name: 'IP 组' }));
|
||||
fireEvent.click(await screen.findByText('办公室出口'));
|
||||
expect(onChange).toHaveBeenCalledWith(
|
||||
@@ -114,7 +144,9 @@ it('associates numeric property labels and constrains server ranges', () => {
|
||||
challenge_ttl: 30,
|
||||
},
|
||||
};
|
||||
render(<NodeProperties node={node} ipGroups={[]} onChange={vi.fn()} />);
|
||||
renderWithIntl(
|
||||
<NodeProperties node={node} ipGroups={[]} onChange={vi.fn()} />,
|
||||
);
|
||||
expect(screen.getByLabelText('难度')).toHaveAttribute('min', '1');
|
||||
expect(screen.getByLabelText('难度')).toHaveAttribute('max', '16');
|
||||
expect(screen.getByLabelText('会话 TTL(秒)')).toHaveAttribute('min', '60');
|
||||
@@ -128,7 +160,9 @@ it('creates any normalized valid geography code', async () => {
|
||||
config: { countries: [], regions: [] },
|
||||
};
|
||||
const onChange = vi.fn();
|
||||
render(<NodeProperties node={node} ipGroups={[]} onChange={onChange} />);
|
||||
renderWithIntl(
|
||||
<NodeProperties node={node} ipGroups={[]} onChange={onChange} />,
|
||||
);
|
||||
fireEvent.click(screen.getByRole('button', { name: '国家代码' }));
|
||||
fireEvent.change(await screen.findByPlaceholderText('搜索名称或代码'), {
|
||||
target: { value: 'nz' },
|
||||
@@ -148,7 +182,9 @@ it('shows localized country names together with their codes', async () => {
|
||||
position: { x: 0, y: 0 },
|
||||
config: { countries: [], regions: [] },
|
||||
};
|
||||
render(<NodeProperties node={node} ipGroups={[]} onChange={vi.fn()} />);
|
||||
renderWithIntl(
|
||||
<NodeProperties node={node} ipGroups={[]} onChange={vi.fn()} />,
|
||||
);
|
||||
fireEvent.click(screen.getByRole('button', { name: '国家代码' }));
|
||||
expect(await screen.findByText('中国')).toBeInTheDocument();
|
||||
expect(screen.getByText('CN')).toBeInTheDocument();
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { CircleHelp, Settings2 } from 'lucide-react';
|
||||
import { useTranslations } from 'next-intl';
|
||||
import { useState } from 'react';
|
||||
|
||||
import { Button } from '@/components/ui/button';
|
||||
@@ -47,6 +48,7 @@ export function NodeProperties({
|
||||
ipGroups: WAFIPGroup[];
|
||||
onChange: (node: WAFRuleNode) => void;
|
||||
}) {
|
||||
const t = useTranslations('waf.editor');
|
||||
return (
|
||||
<aside className='w-80 shrink-0 border-l bg-card'>
|
||||
<ScrollArea className='h-full'>
|
||||
@@ -54,8 +56,8 @@ export function NodeProperties({
|
||||
<div className='flex items-center gap-2'>
|
||||
<Settings2 className='size-5 text-primary' />
|
||||
<div>
|
||||
<h2 className='text-sm font-semibold'>节点属性</h2>
|
||||
<p className='text-xs text-muted-foreground'>配置当前处理单元</p>
|
||||
<h2 className='text-sm font-semibold'>{t('propsTitle')}</h2>
|
||||
<p className='text-xs text-muted-foreground'>{t('propsDesc')}</p>
|
||||
</div>
|
||||
</div>
|
||||
<Separator />
|
||||
@@ -75,15 +77,18 @@ function PropertyFields({
|
||||
ipGroups: WAFIPGroup[];
|
||||
onChange: (node: WAFRuleNode) => void;
|
||||
}) {
|
||||
const t = useTranslations('waf.editor');
|
||||
if (node.type === 'start' || node.type === 'allow')
|
||||
return <p className='text-sm text-muted-foreground'>系统节点无需配置。</p>;
|
||||
return (
|
||||
<p className='text-sm text-muted-foreground'>{t('systemNodeNoConfig')}</p>
|
||||
);
|
||||
if (node.type === 'ip_match')
|
||||
return (
|
||||
<FieldGroup>
|
||||
<DisplayNameField node={node} onChange={onChange} />
|
||||
<CsvField
|
||||
id={`${node.id}-ips`}
|
||||
label='IP 地址'
|
||||
label={t('ips')}
|
||||
value={node.config.ips}
|
||||
onChange={(ips) =>
|
||||
onChange({ ...node, config: { ...node.config, ips } })
|
||||
@@ -91,7 +96,7 @@ function PropertyFields({
|
||||
/>
|
||||
<CsvField
|
||||
id={`${node.id}-cidrs`}
|
||||
label='CIDR 网段'
|
||||
label={t('cidrs')}
|
||||
value={node.config.cidrs}
|
||||
onChange={(cidrs) =>
|
||||
onChange({ ...node, config: { ...node.config, cidrs } })
|
||||
@@ -99,7 +104,7 @@ function PropertyFields({
|
||||
/>
|
||||
<MultiSelect
|
||||
id={`${node.id}-groups`}
|
||||
label='IP 组'
|
||||
label={t('ipGroups')}
|
||||
options={ipGroups.map((group) => ({
|
||||
value: String(group.id),
|
||||
label: group.name,
|
||||
@@ -121,8 +126,8 @@ function PropertyFields({
|
||||
<DisplayNameField node={node} onChange={onChange} />
|
||||
<MultiSelect
|
||||
id={`${node.id}-countries`}
|
||||
label='国家代码'
|
||||
description={`共 ${countryOptions.length} 个国家/地区,显示国家名与 ISO 代码`}
|
||||
label={t('countries')}
|
||||
description={t('countriesDesc', { count: countryOptions.length })}
|
||||
options={countryOptions}
|
||||
value={node.config.countries}
|
||||
creatablePattern={/^[A-Z]{2}$/}
|
||||
@@ -132,8 +137,8 @@ function PropertyFields({
|
||||
/>
|
||||
<MultiSelect
|
||||
id={`${node.id}-regions`}
|
||||
label='地区代码'
|
||||
description={`共 ${regionOptions.length} 个一级行政区,输入名称或代码搜索`}
|
||||
label={t('regions')}
|
||||
description={t('regionsDesc', { count: regionOptions.length })}
|
||||
options={regionOptions}
|
||||
value={node.config.regions}
|
||||
creatablePattern={/^[A-Z]{2}-[A-Z0-9]{1,3}$/}
|
||||
@@ -149,7 +154,9 @@ function PropertyFields({
|
||||
<FieldGroup>
|
||||
<DisplayNameField node={node} onChange={onChange} />
|
||||
<div className='space-y-1'>
|
||||
<p className='text-xs font-medium text-muted-foreground'>UA 检查</p>
|
||||
<p className='text-xs font-medium text-muted-foreground'>
|
||||
{t('uaCheck')}
|
||||
</p>
|
||||
<Field
|
||||
orientation='horizontal'
|
||||
className='items-center justify-between'
|
||||
@@ -158,8 +165,8 @@ function PropertyFields({
|
||||
htmlFor={`${node.id}-require-ua`}
|
||||
className='flex items-center gap-1.5'
|
||||
>
|
||||
开启 UA 检查
|
||||
<FieldHelp tip='开启后如果请求头不携带 UA 将返回 False' />
|
||||
{t('enableUaCheck')}
|
||||
<FieldHelp tip={t('enableUaCheckTip')} />
|
||||
</FieldLabel>
|
||||
<Switch
|
||||
id={`${node.id}-require-ua`}
|
||||
@@ -175,8 +182,8 @@ function PropertyFields({
|
||||
<Separator />
|
||||
<div className='space-y-3'>
|
||||
<p className='flex items-center gap-1.5 text-xs font-medium text-muted-foreground'>
|
||||
屏蔽
|
||||
<FieldHelp tip='命中返回 false,优先级高于匹配' />
|
||||
{t('block')}
|
||||
<FieldHelp tip={t('blockTip')} />
|
||||
</p>
|
||||
<Field
|
||||
orientation='horizontal'
|
||||
@@ -186,8 +193,8 @@ function PropertyFields({
|
||||
htmlFor={`${node.id}-block-bots`}
|
||||
className='flex items-center gap-1.5'
|
||||
>
|
||||
屏蔽常见爬虫 UA
|
||||
<FieldHelp tip='浏览器或操作系统分类为 Bot(含 bot / spider / crawler / slurp 等特征,如 Googlebot)' />
|
||||
{t('blockBots')}
|
||||
<FieldHelp tip={t('blockBotsTip')} />
|
||||
</FieldLabel>
|
||||
<Switch
|
||||
id={`${node.id}-block-bots`}
|
||||
@@ -208,8 +215,8 @@ function PropertyFields({
|
||||
htmlFor={`${node.id}-block-abnormal`}
|
||||
className='flex items-center gap-1.5'
|
||||
>
|
||||
屏蔽非正常 UA
|
||||
<FieldHelp tip='浏览器分类为 Other 或 Unknown(不含搜索引擎等爬虫 Bot,爬虫请用上方开关)' />
|
||||
{t('blockAbnormal')}
|
||||
<FieldHelp tip={t('blockAbnormalTip')} />
|
||||
</FieldLabel>
|
||||
<Switch
|
||||
id={`${node.id}-block-abnormal`}
|
||||
@@ -230,8 +237,8 @@ function PropertyFields({
|
||||
htmlFor={`${node.id}-block-custom`}
|
||||
className='flex items-center gap-1.5'
|
||||
>
|
||||
屏蔽自定义 UA
|
||||
<FieldHelp tip='原始 User-Agent 命中任一条正则时返回 false(Lua 模式语法)' />
|
||||
{t('blockCustom')}
|
||||
<FieldHelp tip={t('blockCustomTip')} />
|
||||
</FieldLabel>
|
||||
<Switch
|
||||
id={`${node.id}-block-custom`}
|
||||
@@ -250,8 +257,8 @@ function PropertyFields({
|
||||
htmlFor={`${node.id}-custom-patterns`}
|
||||
className='flex items-center gap-1.5'
|
||||
>
|
||||
自定义 UA 正则
|
||||
<FieldHelp tip='每行一条 Lua 模式正则,命中任一条即 false;最多 32 条' />
|
||||
{t('customUaPatterns')}
|
||||
<FieldHelp tip={t('customUaPatternsTip')} />
|
||||
</FieldLabel>
|
||||
<Textarea
|
||||
id={`${node.id}-custom-patterns`}
|
||||
@@ -277,15 +284,15 @@ function PropertyFields({
|
||||
<Separator />
|
||||
<div className='space-y-3'>
|
||||
<p className='text-xs font-medium text-muted-foreground'>
|
||||
UA 匹配
|
||||
{t('uaMatch')}
|
||||
</p>
|
||||
<Field>
|
||||
<FieldLabel
|
||||
htmlFor={`${node.id}-match-mode`}
|
||||
className='flex items-center gap-1.5'
|
||||
>
|
||||
匹配模式
|
||||
<FieldHelp tip='浏览器与操作系统两侧都有选择时生效' />
|
||||
{t('matchMode')}
|
||||
<FieldHelp tip={t('matchModeTip')} />
|
||||
</FieldLabel>
|
||||
<Select
|
||||
value={node.config.match_mode}
|
||||
@@ -304,15 +311,15 @@ function PropertyFields({
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectGroup>
|
||||
<SelectItem value='or'>或(OR)</SelectItem>
|
||||
<SelectItem value='and'>且(AND)</SelectItem>
|
||||
<SelectItem value='or'>{t('matchOr')}</SelectItem>
|
||||
<SelectItem value='and'>{t('matchAnd')}</SelectItem>
|
||||
</SelectGroup>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</Field>
|
||||
<MultiSelect
|
||||
id={`${node.id}-browsers`}
|
||||
label='浏览器'
|
||||
label={t('browsers')}
|
||||
options={UA_BROWSER_OPTIONS.map((option) => ({
|
||||
value: option.value,
|
||||
label: option.label,
|
||||
@@ -325,7 +332,7 @@ function PropertyFields({
|
||||
/>
|
||||
<MultiSelect
|
||||
id={`${node.id}-os`}
|
||||
label='操作系统'
|
||||
label={t('os')}
|
||||
options={UA_OS_OPTIONS.map((option) => ({
|
||||
value: option.value,
|
||||
label: option.label,
|
||||
@@ -350,81 +357,47 @@ function PropertyFields({
|
||||
<DisplayNameField node={node} onChange={onChange} />
|
||||
<div className='space-y-1'>
|
||||
<p className='flex items-center gap-1.5 text-xs font-medium text-muted-foreground'>
|
||||
安全防护
|
||||
<FieldHelp tip='命中任意已启用规则返回 False' />
|
||||
{t('security')}
|
||||
<FieldHelp tip={t('securityTip')} />
|
||||
</p>
|
||||
</div>
|
||||
<Separator />
|
||||
<div className='space-y-3'>
|
||||
<p className='text-xs font-medium text-muted-foreground'>基础防护</p>
|
||||
<p className='text-xs font-medium text-muted-foreground'>
|
||||
{t('basicProtection')}
|
||||
</p>
|
||||
{(
|
||||
[
|
||||
{
|
||||
key: 'path_traversal',
|
||||
label: '路径穿越防护',
|
||||
tip: '检测 Path / Query / Body 中的 ../ 与编码变种',
|
||||
},
|
||||
{
|
||||
key: 'file_inclusion',
|
||||
label: '文件包含(LFI/RFI)',
|
||||
tip: '检测 Path / Query / Body 中的 php://、file://、/etc/passwd 等',
|
||||
},
|
||||
{
|
||||
key: 'sql_injection',
|
||||
label: 'SQL 注入',
|
||||
tip: '检测 Query / Body / Header / Cookie 中的 SQL 注入特征',
|
||||
},
|
||||
{
|
||||
key: 'command_injection',
|
||||
label: '命令注入',
|
||||
tip: '检测 Query / Body / Header 中的 OS 命令注入特征',
|
||||
},
|
||||
{
|
||||
key: 'xss',
|
||||
label: 'XSS',
|
||||
tip: '检测 Query / Body / Header 中的反射型 XSS 特征',
|
||||
},
|
||||
{
|
||||
key: 'ssrf',
|
||||
label: 'SSRF',
|
||||
tip: '检测 Query / Body 中的内网地址与危险协议',
|
||||
},
|
||||
{
|
||||
key: 'malicious_upload',
|
||||
label: '恶意文件上传',
|
||||
tip: '检测 Multipart 文件名与危险扩展名',
|
||||
},
|
||||
{
|
||||
key: 'xxe',
|
||||
label: 'XXE',
|
||||
tip: '检测 XML Body 中的外部实体特征',
|
||||
},
|
||||
{
|
||||
key: 'crlf_injection',
|
||||
label: 'CRLF 注入',
|
||||
tip: '检测 Header / Query / Body 中的换行注入',
|
||||
},
|
||||
'path_traversal',
|
||||
'file_inclusion',
|
||||
'sql_injection',
|
||||
'command_injection',
|
||||
'xss',
|
||||
'ssrf',
|
||||
'malicious_upload',
|
||||
'xxe',
|
||||
'crlf_injection',
|
||||
] as const
|
||||
).map((item) => (
|
||||
).map((key) => (
|
||||
<Field
|
||||
key={item.key}
|
||||
key={key}
|
||||
orientation='horizontal'
|
||||
className='items-center justify-between gap-3'
|
||||
>
|
||||
<FieldLabel
|
||||
htmlFor={`${node.id}-${item.key}`}
|
||||
htmlFor={`${node.id}-${key}`}
|
||||
className='flex items-center gap-1.5'
|
||||
>
|
||||
{item.label}
|
||||
<FieldHelp tip={item.tip} />
|
||||
{t(`securityRules.${key}.label`)}
|
||||
<FieldHelp tip={t(`securityRules.${key}.tip`)} />
|
||||
</FieldLabel>
|
||||
<Switch
|
||||
id={`${node.id}-${item.key}`}
|
||||
checked={node.config[item.key]}
|
||||
id={`${node.id}-${key}`}
|
||||
checked={node.config[key]}
|
||||
onCheckedChange={(checked) =>
|
||||
onChange({
|
||||
...node,
|
||||
config: { ...node.config, [item.key]: checked },
|
||||
config: { ...node.config, [key]: checked },
|
||||
})
|
||||
}
|
||||
/>
|
||||
@@ -438,7 +411,9 @@ function PropertyFields({
|
||||
<FieldGroup>
|
||||
<DisplayNameField node={node} onChange={onChange} />
|
||||
<Field>
|
||||
<FieldLabel htmlFor={`${node.id}-algorithm`}>算法</FieldLabel>
|
||||
<FieldLabel htmlFor={`${node.id}-algorithm`}>
|
||||
{t('algorithm')}
|
||||
</FieldLabel>
|
||||
<Select
|
||||
value={node.config.algorithm}
|
||||
onValueChange={(algorithm: 'fast' | 'slow') =>
|
||||
@@ -450,8 +425,8 @@ function PropertyFields({
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectGroup>
|
||||
<SelectItem value='fast'>快速</SelectItem>
|
||||
<SelectItem value='slow'>稳健</SelectItem>
|
||||
<SelectItem value='fast'>{t('algorithmFast')}</SelectItem>
|
||||
<SelectItem value='slow'>{t('algorithmSlow')}</SelectItem>
|
||||
</SelectGroup>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
@@ -465,9 +440,9 @@ function PropertyFields({
|
||||
max={key === 'difficulty' ? 16 : undefined}
|
||||
label={
|
||||
{
|
||||
difficulty: '难度',
|
||||
session_ttl: '会话 TTL(秒)',
|
||||
challenge_ttl: '挑战 TTL(秒)',
|
||||
difficulty: t('difficulty'),
|
||||
session_ttl: t('sessionTtl'),
|
||||
challenge_ttl: t('challengeTtl'),
|
||||
}[key]
|
||||
}
|
||||
value={node.config[key]}
|
||||
@@ -486,14 +461,14 @@ function PropertyFields({
|
||||
id={`${node.id}-status`}
|
||||
min={400}
|
||||
max={599}
|
||||
label='HTTP 状态码'
|
||||
label={t('statusCode')}
|
||||
value={node.config.status_code}
|
||||
onChange={(status_code) =>
|
||||
onChange({ ...node, config: { ...node.config, status_code } })
|
||||
}
|
||||
/>
|
||||
<Field>
|
||||
<FieldLabel htmlFor={`${node.id}-body`}>HTML 响应体</FieldLabel>
|
||||
<FieldLabel htmlFor={`${node.id}-body`}>{t('responseBody')}</FieldLabel>
|
||||
<Textarea
|
||||
id={`${node.id}-body`}
|
||||
rows={9}
|
||||
@@ -506,8 +481,8 @@ function PropertyFields({
|
||||
}
|
||||
/>
|
||||
<FieldDescription>
|
||||
{new TextEncoder().encode(node.config.response_body).length} / 16384
|
||||
字节
|
||||
{new TextEncoder().encode(node.config.response_body).length} / 16384{' '}
|
||||
{t('bytes')}
|
||||
</FieldDescription>
|
||||
</Field>
|
||||
</FieldGroup>
|
||||
@@ -515,13 +490,14 @@ function PropertyFields({
|
||||
}
|
||||
|
||||
function FieldHelp({ tip }: { tip: string }) {
|
||||
const t = useTranslations('waf.editor');
|
||||
return (
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<button
|
||||
type='button'
|
||||
className='inline-flex size-4 shrink-0 items-center justify-center text-muted-foreground transition-colors hover:text-foreground'
|
||||
aria-label='说明'
|
||||
aria-label={t('help')}
|
||||
onClick={(event) => event.preventDefault()}
|
||||
>
|
||||
<CircleHelp className='size-3.5' />
|
||||
@@ -541,9 +517,10 @@ function DisplayNameField({
|
||||
node: WAFRuleNode;
|
||||
onChange: (node: WAFRuleNode) => void;
|
||||
}) {
|
||||
const t = useTranslations('waf.editor');
|
||||
return (
|
||||
<Field>
|
||||
<FieldLabel htmlFor={`${node.id}-label`}>显示名称</FieldLabel>
|
||||
<FieldLabel htmlFor={`${node.id}-label`}>{t('displayName')}</FieldLabel>
|
||||
<Input
|
||||
id={`${node.id}-label`}
|
||||
value={node.label ?? ''}
|
||||
@@ -565,6 +542,7 @@ function CsvField({
|
||||
value: string[];
|
||||
onChange: (value: string[]) => void;
|
||||
}) {
|
||||
const t = useTranslations('waf.editor');
|
||||
return (
|
||||
<Field>
|
||||
<FieldLabel htmlFor={id}>{label}</FieldLabel>
|
||||
@@ -580,7 +558,7 @@ function CsvField({
|
||||
)
|
||||
}
|
||||
/>
|
||||
<FieldDescription>每行一个值</FieldDescription>
|
||||
<FieldDescription>{t('oneValuePerLine')}</FieldDescription>
|
||||
</Field>
|
||||
);
|
||||
}
|
||||
@@ -633,6 +611,7 @@ function MultiSelect({
|
||||
searchRequired?: boolean;
|
||||
onChange: (value: string[]) => void;
|
||||
}) {
|
||||
const t = useTranslations('waf.editor');
|
||||
const [draft, setDraft] = useState('');
|
||||
const normalized = draft.trim().toUpperCase();
|
||||
const query = draft.trim().toLocaleLowerCase();
|
||||
@@ -661,15 +640,17 @@ function MultiSelect({
|
||||
<Popover>
|
||||
<PopoverTrigger asChild>
|
||||
<Button id={id} variant='outline' className='w-full justify-start'>
|
||||
{value.length ? `已选择 ${value.length} 项` : '请选择'}
|
||||
{value.length
|
||||
? t('selectedCount', { count: value.length })
|
||||
: t('pleaseSelect')}
|
||||
</Button>
|
||||
</PopoverTrigger>
|
||||
<PopoverContent align='start' className='flex w-80 flex-col gap-2 p-3'>
|
||||
{(creatablePattern || options.length > 0) && (
|
||||
<div className='flex gap-2'>
|
||||
<Input
|
||||
aria-label={`新建${label}`}
|
||||
placeholder='搜索名称或代码'
|
||||
aria-label={t('createLabel', { label })}
|
||||
placeholder={t('searchNameOrCode')}
|
||||
value={draft}
|
||||
onChange={(event) => setDraft(event.target.value)}
|
||||
/>
|
||||
@@ -682,7 +663,7 @@ function MultiSelect({
|
||||
setDraft('');
|
||||
}}
|
||||
>
|
||||
添加代码
|
||||
{t('addCode')}
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
@@ -691,8 +672,8 @@ function MultiSelect({
|
||||
{visible.length === 0 ? (
|
||||
<p className='px-1 py-3 text-sm text-muted-foreground'>
|
||||
{searchRequired && !query
|
||||
? `输入名称或代码搜索 ${options.length} 个选项`
|
||||
: '没有匹配的选项'}
|
||||
? t('searchOptions', { count: options.length })
|
||||
: t('noMatchingOptions')}
|
||||
</p>
|
||||
) : (
|
||||
visible.map((option) => (
|
||||
|
||||
@@ -8,6 +8,7 @@ import {
|
||||
useRef,
|
||||
useState,
|
||||
} from 'react';
|
||||
import { useTranslations } from 'next-intl';
|
||||
import {
|
||||
addEdge,
|
||||
applyEdgeChanges,
|
||||
@@ -82,6 +83,7 @@ export function RuleFlowCanvas({
|
||||
onSelect,
|
||||
onSelectEdge,
|
||||
}: RuleFlowCanvasProps) {
|
||||
const t = useTranslations('waf.editor');
|
||||
const instance = useRef<ReactFlowInstance<FlowNode, FlowEdge> | null>(null);
|
||||
const [nodes, setNodes, applyFlowNodeChanges] = useNodesState<FlowNode>(
|
||||
buildFlowNodes(graph, issues, selectedId),
|
||||
@@ -415,7 +417,7 @@ export function RuleFlowCanvas({
|
||||
}}
|
||||
>
|
||||
<Trash2 className='size-4' />
|
||||
{contextCanDeleteNode ? '删除节点' : '系统节点不可删除'}
|
||||
{contextCanDeleteNode ? t('deleteNode') : t('systemNodeLocked')}
|
||||
</button>
|
||||
)}
|
||||
{contextMenu.kind === 'edge' && (
|
||||
@@ -435,7 +437,7 @@ export function RuleFlowCanvas({
|
||||
}}
|
||||
>
|
||||
<Trash2 className='size-4' />
|
||||
删除连线
|
||||
{t('deleteEdge')}
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
@@ -1,14 +1,33 @@
|
||||
import { act, render, screen } from '@testing-library/react';
|
||||
import userEvent from '@testing-library/user-event';
|
||||
import { NextIntlClientProvider } from 'next-intl';
|
||||
import type { ReactElement } from 'react';
|
||||
import { afterEach, expect, it, vi } from 'vitest';
|
||||
|
||||
import mainZh from '@/messages/zh-CN.json';
|
||||
import securityZh from '@/messages/fragments/security.zh-CN.json';
|
||||
|
||||
import { UnsavedChanges } from './unsaved-changes';
|
||||
|
||||
const messages = { ...mainZh, ...securityZh };
|
||||
|
||||
function renderWithIntl(ui: ReactElement) {
|
||||
return render(
|
||||
<NextIntlClientProvider
|
||||
locale='zh-CN'
|
||||
messages={messages}
|
||||
timeZone='Asia/Shanghai'
|
||||
>
|
||||
{ui}
|
||||
</NextIntlClientProvider>,
|
||||
);
|
||||
}
|
||||
|
||||
afterEach(() => vi.restoreAllMocks());
|
||||
|
||||
it('blocks same-origin application links when dirty and confirmation is declined', async () => {
|
||||
const user = userEvent.setup();
|
||||
const { container } = render(
|
||||
const { container } = renderWithIntl(
|
||||
<>
|
||||
<UnsavedChanges dirty />
|
||||
<a href='/waf'>WAF</a>
|
||||
@@ -31,7 +50,7 @@ it('blocks same-origin application links when dirty and confirmation is declined
|
||||
});
|
||||
|
||||
it('does not block application links without changes', () => {
|
||||
const { getByRole } = render(
|
||||
const { getByRole } = renderWithIntl(
|
||||
<>
|
||||
<UnsavedChanges dirty={false} />
|
||||
<a href='/waf'>WAF</a>
|
||||
@@ -51,7 +70,7 @@ it('restores declined Back and Forward transitions by indexed delta', async () =
|
||||
const user = userEvent.setup();
|
||||
const go = vi.spyOn(history, 'go').mockImplementation(() => undefined);
|
||||
history.replaceState({ __wafEditorIndex: 4 }, '');
|
||||
render(<UnsavedChanges dirty />);
|
||||
renderWithIntl(<UnsavedChanges dirty />);
|
||||
|
||||
act(() => {
|
||||
window.dispatchEvent(
|
||||
@@ -80,7 +99,7 @@ it('restores declined Back and Forward transitions by indexed delta', async () =
|
||||
it('prompts and restores the current URL for an unknown unindexed history entry', async () => {
|
||||
history.replaceState({ __wafEditorIndex: 4 }, '', '/waf/rules/editor?id=9');
|
||||
const push = vi.spyOn(history, 'pushState');
|
||||
render(<UnsavedChanges dirty />);
|
||||
renderWithIntl(<UnsavedChanges dirty />);
|
||||
|
||||
act(() => {
|
||||
window.dispatchEvent(
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
'use client';
|
||||
|
||||
import { useTranslations } from 'next-intl';
|
||||
import { useEffect, useRef, useState } from 'react';
|
||||
|
||||
import {
|
||||
@@ -22,6 +23,8 @@ type PendingLeave =
|
||||
| { kind: 'history'; restoreDelta: number };
|
||||
|
||||
export function UnsavedChanges({ dirty }: { dirty: boolean }) {
|
||||
const t = useTranslations('waf.editor');
|
||||
const tCommon = useTranslations('common');
|
||||
const [pendingLeave, setPendingLeave] = useState<PendingLeave | null>(null);
|
||||
const dirtyRef = useRef(dirty);
|
||||
const allowLeaveRef = useRef(false);
|
||||
@@ -180,15 +183,13 @@ export function UnsavedChanges({ dirty }: { dirty: boolean }) {
|
||||
>
|
||||
<AlertDialogContent>
|
||||
<AlertDialogHeader>
|
||||
<AlertDialogTitle>未保存的更改</AlertDialogTitle>
|
||||
<AlertDialogDescription>
|
||||
存在未保存的更改,确定离开吗?
|
||||
</AlertDialogDescription>
|
||||
<AlertDialogTitle>{t('leaveTitle')}</AlertDialogTitle>
|
||||
<AlertDialogDescription>{t('leaveDesc')}</AlertDialogDescription>
|
||||
</AlertDialogHeader>
|
||||
<AlertDialogFooter>
|
||||
<AlertDialogCancel>取消</AlertDialogCancel>
|
||||
<AlertDialogCancel>{tCommon('cancel')}</AlertDialogCancel>
|
||||
<AlertDialogAction onClick={handleConfirmLeave}>
|
||||
确定离开
|
||||
{t('leaveConfirm')}
|
||||
</AlertDialogAction>
|
||||
</AlertDialogFooter>
|
||||
</AlertDialogContent>
|
||||
|
||||
@@ -7,9 +7,12 @@ import {
|
||||
waitFor,
|
||||
} from '@testing-library/react';
|
||||
import { AxiosError, type AxiosResponse } from 'axios';
|
||||
import { NextIntlClientProvider } from 'next-intl';
|
||||
import { beforeEach, expect, it, vi } from 'vitest';
|
||||
|
||||
import type { WAFRule, WAFRuleGraph } from '@/lib/services';
|
||||
import mainZh from '@/messages/zh-CN.json';
|
||||
import securityZh from '@/messages/fragments/security.zh-CN.json';
|
||||
|
||||
import WAFRuleEditorPage from './page';
|
||||
|
||||
@@ -105,9 +108,15 @@ function renderPage() {
|
||||
defaultOptions: { queries: { retry: false }, mutations: { retry: false } },
|
||||
});
|
||||
return render(
|
||||
<QueryClientProvider client={client}>
|
||||
<WAFRuleEditorPage />
|
||||
</QueryClientProvider>,
|
||||
<NextIntlClientProvider
|
||||
locale='zh-CN'
|
||||
messages={{ ...mainZh, ...securityZh }}
|
||||
timeZone='Asia/Shanghai'
|
||||
>
|
||||
<QueryClientProvider client={client}>
|
||||
<WAFRuleEditorPage />
|
||||
</QueryClientProvider>
|
||||
</NextIntlClientProvider>,
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -4,6 +4,7 @@ import { Suspense, useCallback, useEffect, useMemo, useState } from 'react';
|
||||
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
|
||||
import axios from 'axios';
|
||||
import { ArrowLeft, GitBranch, Save } from 'lucide-react';
|
||||
import { useTranslations } from 'next-intl';
|
||||
import { useRouter, useSearchParams } from 'next/navigation';
|
||||
import { toast } from 'sonner';
|
||||
|
||||
@@ -49,6 +50,9 @@ export default function WAFRuleEditorPage() {
|
||||
}
|
||||
|
||||
function EditorContent() {
|
||||
const t = useTranslations('waf.editor');
|
||||
const tWaf = useTranslations('waf');
|
||||
const tCommon = useTranslations('common');
|
||||
const router = useRouter();
|
||||
const searchParams = useSearchParams();
|
||||
const queryClient = useQueryClient();
|
||||
@@ -89,12 +93,12 @@ function EditorContent() {
|
||||
setDirty(false);
|
||||
setConflict(false);
|
||||
queryClient.setQueryData(['waf-rule', id], rule);
|
||||
toast.success('规则图已保存');
|
||||
toast.success(t('saved'));
|
||||
},
|
||||
onError: (error) => {
|
||||
if (axios.isAxiosError(error) && error.response?.status === 409) {
|
||||
setConflict(true);
|
||||
toast.error('规则已在其他页面更新,请重新加载');
|
||||
toast.error(t('conflict'));
|
||||
return;
|
||||
}
|
||||
const payload = axios.isAxiosError(error) ? error.response?.data : error;
|
||||
@@ -114,7 +118,7 @@ function EditorContent() {
|
||||
setSelectedEdgeId(target.id);
|
||||
}
|
||||
setFocusTarget(target ? { ...target } : undefined);
|
||||
toast.error('保存失败,请检查标记的节点和连线');
|
||||
toast.error(t('saveCheckNodes'));
|
||||
},
|
||||
});
|
||||
|
||||
@@ -136,7 +140,7 @@ function EditorContent() {
|
||||
if (context?.previous) {
|
||||
queryClient.setQueryData(ruleQueryKey, context.previous);
|
||||
}
|
||||
toast.error(getErrorMessage(error));
|
||||
toast.error(getErrorMessage(error, tWaf('operationFailed')));
|
||||
},
|
||||
onSuccess: (rule) => {
|
||||
queryClient.setQueryData(ruleQueryKey, rule);
|
||||
@@ -150,7 +154,7 @@ function EditorContent() {
|
||||
void queryClient.invalidateQueries({
|
||||
queryKey: ['openflare', 'config-versions', 'diff'],
|
||||
});
|
||||
toast.success(rule.enabled ? '规则已启用' : '规则已停用');
|
||||
toast.success(rule.enabled ? t('ruleEnabled') : t('ruleDisabled'));
|
||||
},
|
||||
});
|
||||
|
||||
@@ -187,15 +191,15 @@ function EditorContent() {
|
||||
if (!Number.isFinite(id) || id <= 0)
|
||||
return (
|
||||
<div className='w-full px-1 py-6'>
|
||||
<p className='text-sm text-destructive'>缺少有效的规则 ID。</p>
|
||||
<p className='text-sm text-destructive'>{t('missingId')}</p>
|
||||
</div>
|
||||
);
|
||||
if (ruleQuery.isError)
|
||||
return (
|
||||
<div className='flex w-full flex-col items-start gap-3 px-1 py-6'>
|
||||
<p className='text-sm text-destructive'>规则加载失败,请重试。</p>
|
||||
<p className='text-sm text-destructive'>{t('loadFailed')}</p>
|
||||
<Button variant='outline' onClick={() => void ruleQuery.refetch()}>
|
||||
重新加载
|
||||
{t('reload')}
|
||||
</Button>
|
||||
</div>
|
||||
);
|
||||
@@ -213,7 +217,7 @@ function EditorContent() {
|
||||
onClick={leave}
|
||||
>
|
||||
<ArrowLeft className='size-3.5' />
|
||||
返回
|
||||
{t('back')}
|
||||
</Button>
|
||||
<div className='flex items-center justify-between gap-4'>
|
||||
<div className='flex min-w-0 items-center gap-2'>
|
||||
@@ -222,12 +226,14 @@ function EditorContent() {
|
||||
{ruleQuery.data.name}
|
||||
</h1>
|
||||
<Badge variant={ruleQuery.data.enabled ? 'default' : 'secondary'}>
|
||||
{ruleQuery.data.enabled ? '已启用' : '已停用'}
|
||||
{ruleQuery.data.enabled ? tWaf('enabled') : tWaf('disabled')}
|
||||
</Badge>
|
||||
<Badge variant={issues.length === 0 ? 'outline' : 'destructive'}>
|
||||
{issues.length === 0 ? '图校验通过' : `${issues.length} 个问题`}
|
||||
{issues.length === 0
|
||||
? t('graphValid')
|
||||
: t('issueCount', { count: issues.length })}
|
||||
</Badge>
|
||||
{dirty && <Badge variant='secondary'>未保存</Badge>}
|
||||
{dirty && <Badge variant='secondary'>{t('unsaved')}</Badge>}
|
||||
</div>
|
||||
<div className='flex shrink-0 items-center gap-2'>
|
||||
<div className='flex items-center gap-2'>
|
||||
@@ -242,7 +248,7 @@ function EditorContent() {
|
||||
}
|
||||
onCheckedChange={(enabled) => metaMutation.mutate(enabled)}
|
||||
/>
|
||||
<Label htmlFor='rule-enabled'>启用规则</Label>
|
||||
<Label htmlFor='rule-enabled'>{t('enableRule')}</Label>
|
||||
</div>
|
||||
{conflict && (
|
||||
<Button
|
||||
@@ -253,24 +259,24 @@ function EditorContent() {
|
||||
void ruleQuery.refetch();
|
||||
}}
|
||||
>
|
||||
重新加载
|
||||
{t('reload')}
|
||||
</Button>
|
||||
)}
|
||||
<Button
|
||||
type='button'
|
||||
variant='outline'
|
||||
title='自动整理节点与连线布局'
|
||||
title={t('formatTitle')}
|
||||
disabled={!graph}
|
||||
onClick={formatLayout}
|
||||
>
|
||||
格式化
|
||||
{t('format')}
|
||||
</Button>
|
||||
<Button
|
||||
disabled={!dirty || issues.length > 0 || saveMutation.isPending}
|
||||
onClick={() => saveMutation.mutate()}
|
||||
>
|
||||
<Save data-icon='inline-start' />
|
||||
{saveMutation.isPending ? '保存中...' : '保存'}
|
||||
{saveMutation.isPending ? t('saving') : t('save')}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
@@ -298,15 +304,15 @@ function EditorContent() {
|
||||
<AlertDialog open={leaveConfirmOpen} onOpenChange={setLeaveConfirmOpen}>
|
||||
<AlertDialogContent>
|
||||
<AlertDialogHeader>
|
||||
<AlertDialogTitle>未保存的更改</AlertDialogTitle>
|
||||
<AlertDialogTitle>{t('leaveTitle')}</AlertDialogTitle>
|
||||
<AlertDialogDescription>
|
||||
存在未保存的更改,确定离开吗?
|
||||
{t('leaveDesc')}
|
||||
</AlertDialogDescription>
|
||||
</AlertDialogHeader>
|
||||
<AlertDialogFooter>
|
||||
<AlertDialogCancel>取消</AlertDialogCancel>
|
||||
<AlertDialogCancel>{tCommon('cancel')}</AlertDialogCancel>
|
||||
<AlertDialogAction onClick={() => router.push('/waf')}>
|
||||
确定离开
|
||||
{t('leaveConfirm')}
|
||||
</AlertDialogAction>
|
||||
</AlertDialogFooter>
|
||||
</AlertDialogContent>
|
||||
|
||||
Reference in New Issue
Block a user