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,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>,
);
}
+27 -21
View File
@@ -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>