mirror of
https://github.com/Rain-kl/OpenFlare.git
synced 2026-10-13 10:26:38 +08:00
feat(frontend): 控制台 next-intl 中英双语
接入无 URL 前缀的 zh-CN/en,顶栏与外观设置可切换语言;选择写入 cookie 后刷新生效。
This commit is contained in:
@@ -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>
|
||||
|
||||
Reference in New Issue
Block a user