- Global Rule Group
+ {uiText('ui_globalRuleGroup')}
{wafQuery.data.global_rule_group.name}
diff --git a/frontend/app/(main)/rate-limits/components/analysis-tab.tsx b/frontend/app/(main)/rate-limits/components/analysis-tab.tsx
index 55baa97e..a069d6b8 100644
--- a/frontend/app/(main)/rate-limits/components/analysis-tab.tsx
+++ b/frontend/app/(main)/rate-limits/components/analysis-tab.tsx
@@ -1,5 +1,6 @@
'use client';
+import { getFormattingLocale } from '@/i18n/format';
import { Gauge } from 'lucide-react';
import { useTranslations } from 'next-intl';
@@ -31,9 +32,11 @@ function toAvgRpsItems(items: DistributionItem[] | undefined, hours: number) {
function formatRps(value: number) {
if (!Number.isFinite(value)) return '—';
if (value >= 100) {
- return value.toLocaleString('zh-CN', { maximumFractionDigits: 1 });
+ return value.toLocaleString(getFormattingLocale(), {
+ maximumFractionDigits: 1,
+ });
}
- return value.toLocaleString('zh-CN', {
+ return value.toLocaleString(getFormattingLocale(), {
maximumFractionDigits: 3,
minimumFractionDigits: 0,
});
diff --git a/frontend/app/(main)/rate-limits/components/rate-pressure-chart.tsx b/frontend/app/(main)/rate-limits/components/rate-pressure-chart.tsx
index d28d02f0..4ee87e2e 100644
--- a/frontend/app/(main)/rate-limits/components/rate-pressure-chart.tsx
+++ b/frontend/app/(main)/rate-limits/components/rate-pressure-chart.tsx
@@ -1,5 +1,6 @@
'use client';
+import { getFormattingLocale } from '@/i18n/format';
import { useCallback, useMemo, useRef } from 'react';
import type { EChartsOption } from 'echarts';
import type { EChartsType } from 'echarts/core';
@@ -48,7 +49,7 @@ function formatAxisTime(value: string, hours: number) {
function formatRps(value: number) {
if (!Number.isFinite(value)) return '—';
if (value >= 100) return formatCompactNumber(value);
- return value.toLocaleString('zh-CN', {
+ return value.toLocaleString(getFormattingLocale(), {
maximumFractionDigits: 2,
minimumFractionDigits: 0,
});
diff --git a/frontend/app/(main)/waf/components/helpers.ts b/frontend/app/(main)/waf/components/helpers.ts
index 9ca465c6..f05047b0 100644
--- a/frontend/app/(main)/waf/components/helpers.ts
+++ b/frontend/app/(main)/waf/components/helpers.ts
@@ -1,7 +1,10 @@
+import { runtimeText } from '@/i18n/runtime';
import type { WAFIPGroup, WAFIPGroupPayload } from '@/lib/services/openflare';
export function getErrorMessage(error: unknown, fallback?: string) {
- return error instanceof Error ? error.message : (fallback ?? '操作失败');
+ return error instanceof Error
+ ? error.message
+ : (fallback ?? runtimeText('operation_failed_0c3b4cf7'));
}
export function listToText(items: string[] | undefined) {
@@ -17,7 +20,9 @@ export function parseTextareaList(text: string) {
export function parseAutomaticConfig(
text: string,
- invalidMessage = '自动配置必须是 JSON 对象。',
+ invalidMessage = runtimeText(
+ 'automatic_configuration_must_be_a_json_object_90ba17a4',
+ ),
): Record {
const parsed = JSON.parse(text || '{}') as unknown;
if (!parsed || Array.isArray(parsed) || typeof parsed !== 'object') {
@@ -28,12 +33,16 @@ export function parseAutomaticConfig(
export const automaticPresetRules = [
{
- name: '单 IP 404 高频扫描',
+ get name() {
+ return runtimeText('high_frequency_404_scan_from_one_ip_f7d27ffb');
+ },
labelKey: 'presets.scan404' as const,
expr: 'request_count > 100 && StatusRatio(404) >= 0.8',
},
{
- name: '单 IP 直连访问异常',
+ get name() {
+ return runtimeText('abnormal_direct_access_from_one_ip_732dbeee');
+ },
labelKey: 'presets.directAccess' as const,
expr: 'ip_host_count > 50 && ip_host_ratio > 0.5',
},
@@ -75,7 +84,7 @@ export function formatIPGroupBanRemaining(
): string {
const reference = now ?? new Date();
if (ttlSeconds <= 0) {
- return t ? t('ban.permanent') : '永久';
+ return t ? t('ban.permanent') : runtimeText('permanent_3e71ccc8');
}
const capturedDate = new Date(capturedAt);
if (Number.isNaN(capturedDate.getTime())) {
@@ -83,7 +92,7 @@ export function formatIPGroupBanRemaining(
}
const expireDate = new Date(capturedDate.getTime() + ttlSeconds * 1000);
if (expireDate.getTime() <= reference.getTime()) {
- return t ? t('ban.expired') : '已过期';
+ return t ? t('ban.expired') : runtimeText('expired_2fe0e333');
}
const diffMins = Math.round(
(expireDate.getTime() - reference.getTime()) / (60 * 1000),
@@ -91,10 +100,12 @@ export function formatIPGroupBanRemaining(
if (diffMins < 60) {
return t
? t('ban.minutesLater', { count: diffMins })
- : `${diffMins} 分钟后`;
+ : runtimeText('in_value0_minutes_fd2a5b53', { value0: diffMins });
}
const hours = Math.round(diffMins / 60);
- return t ? t('ban.hoursLater', { count: hours }) : `${hours} 小时后`;
+ return t
+ ? t('ban.hoursLater', { count: hours })
+ : runtimeText('in_value0_hours_5ca37144', { value0: hours });
}
export function getIPGroupViewEntries(
diff --git a/frontend/app/(main)/waf/rules/editor/components/editor-behavior.ts b/frontend/app/(main)/waf/rules/editor/components/editor-behavior.ts
index 97fc0548..32a622b8 100644
--- a/frontend/app/(main)/waf/rules/editor/components/editor-behavior.ts
+++ b/frontend/app/(main)/waf/rules/editor/components/editor-behavior.ts
@@ -1,7 +1,9 @@
+import { locales } from '@/i18n/config';
import type { EdgeChange, Node, NodeChange } from '@xyflow/react';
import type { WAFRuleGraph, WAFRuleNode } from '@/lib/services/openflare';
import { wouldCreateCycle } from './graph-validation';
+import { getRuntimeTranslator } from '@/i18n/runtime';
export type GraphErrorTarget = { kind: 'node' | 'edge'; id: string };
@@ -109,9 +111,9 @@ export function findGraphErrorTarget(
}
const messages = collectMessages(payload);
for (const message of messages) {
- const nodeId = findMessageId(message, '节点', nodeIds);
+ const nodeId = findMessageId(message, 'node', nodeIds);
if (nodeId) return { kind: 'node', id: nodeId };
- const edgeId = findMessageId(message, '边', edgeIds);
+ const edgeId = findMessageId(message, 'edge', edgeIds);
if (edgeId) return { kind: 'edge', id: edgeId };
}
return undefined;
@@ -132,16 +134,22 @@ function collectMessages(value: unknown): string[] {
function findMessageId(
message: string,
- prefix: '节点' | '边',
+ prefix: 'node' | 'edge',
ids: string[],
): string | undefined {
- const token = prefix === '边' ? '边(?:\\s+ID)?' : '节点';
+ const tokenKey = prefix === 'edge' ? 'edge_099db313' : 'node_2410d860';
return [...ids]
.sort((a, b) => b.length - a.length)
.find((id) =>
- new RegExp(`${token}\\s+${escapeRegExp(id)}(?=$|[\\s,。,::的])`).test(
- message,
- ),
+ locales.some((locale) => {
+ const t = getRuntimeTranslator(locale);
+ const token = `${escapeRegExp(t(tokenKey))}(?:\\s+ID)?`;
+ const pattern = t
+ .raw('value0_s_value1_s_40960c98')
+ .replace('{value0}', token)
+ .replace('{value1}', escapeRegExp(id));
+ return new RegExp(pattern, 'i').test(message);
+ }),
);
}
diff --git a/frontend/app/(main)/waf/rules/editor/components/geo-options.test.ts b/frontend/app/(main)/waf/rules/editor/components/geo-options.test.ts
index a5756ee8..fb5666c4 100644
--- a/frontend/app/(main)/waf/rules/editor/components/geo-options.test.ts
+++ b/frontend/app/(main)/waf/rules/editor/components/geo-options.test.ts
@@ -1,8 +1,22 @@
import { describe, expect, it } from 'vitest';
-import { countryOptions, regionOptions } from './geo-options';
+import { createGeoOptions } from './geo-options';
+
+const { countryOptions, regionOptions } = createGeoOptions('zh-CN');
describe('geo options', () => {
+ it('uses English country names when requested without changing country codes', () => {
+ const english = createGeoOptions('en');
+ expect(english.countryOptions).toContainEqual(
+ expect.objectContaining({ value: 'CN', label: 'China' }),
+ );
+ expect(english.regionOptions).toContainEqual(
+ expect.objectContaining({
+ value: 'CN-BJ',
+ searchText: expect.stringContaining('China'),
+ }),
+ );
+ });
it('contains the complete country list with localized names', () => {
expect(countryOptions).toHaveLength(249);
expect(countryOptions).toContainEqual(
diff --git a/frontend/app/(main)/waf/rules/editor/components/geo-options.ts b/frontend/app/(main)/waf/rules/editor/components/geo-options.ts
index 06694c86..095517a7 100644
--- a/frontend/app/(main)/waf/rules/editor/components/geo-options.ts
+++ b/frontend/app/(main)/waf/rules/editor/components/geo-options.ts
@@ -8,46 +8,50 @@ export interface GeoOption {
const countryCodePattern = /^[A-Z]{2}$/;
const regionCodePattern = /^[A-Z0-9]{1,3}$/;
-const countryDisplayNames = new Intl.DisplayNames(['zh-CN'], {
- type: 'region',
-});
+export function createGeoOptions(locale: string) {
+ const countryDisplayNames = new Intl.DisplayNames([locale], {
+ type: 'region',
+ });
-function localizedCountryName(code: string, fallback: string) {
- const localized = countryDisplayNames.of(code);
- return !localized || localized === code ? fallback : localized;
+ function localizedCountryName(code: string, fallback: string) {
+ const localized = countryDisplayNames.of(code);
+ return !localized || localized === code ? fallback : localized;
+ }
+
+ const countryOptions: GeoOption[] = allCountries
+ .filter(([, code]) => countryCodePattern.test(code))
+ .map(([englishName, code]) => {
+ const label = localizedCountryName(code, englishName);
+ return {
+ value: code,
+ label,
+ searchText: `${label} ${englishName} ${code}`,
+ };
+ })
+ .sort((left, right) => left.label.localeCompare(right.label, locale));
+
+ const regionOptions: GeoOption[] = allCountries.flatMap(
+ ([englishCountryName, countryCode, regions]) => {
+ const countryName = localizedCountryName(countryCode, englishCountryName);
+ return regions.flatMap(([regionName, shortCode]) => {
+ const normalizedShortCode = shortCode?.toUpperCase();
+ if (
+ !normalizedShortCode ||
+ !regionCodePattern.test(normalizedShortCode)
+ ) {
+ return [];
+ }
+ const value = `${countryCode}-${normalizedShortCode}`;
+ return [
+ {
+ value,
+ label: `${regionName} · ${countryName}`,
+ searchText: `${regionName} ${countryName} ${englishCountryName} ${value}`,
+ },
+ ];
+ });
+ },
+ );
+
+ return { countryOptions, regionOptions };
}
-
-export const countryOptions: GeoOption[] = allCountries
- .filter(([, code]) => countryCodePattern.test(code))
- .map(([englishName, code]) => {
- const label = localizedCountryName(code, englishName);
- return {
- value: code,
- label,
- searchText: `${label} ${englishName} ${code}`,
- };
- })
- .sort((left, right) => left.label.localeCompare(right.label, 'zh-CN'));
-
-export const regionOptions: GeoOption[] = allCountries.flatMap(
- ([englishCountryName, countryCode, regions]) => {
- const countryName = localizedCountryName(countryCode, englishCountryName);
- return regions.flatMap(([regionName, shortCode]) => {
- const normalizedShortCode = shortCode?.toUpperCase();
- if (
- !normalizedShortCode ||
- !regionCodePattern.test(normalizedShortCode)
- ) {
- return [];
- }
- const value = `${countryCode}-${normalizedShortCode}`;
- return [
- {
- value,
- label: `${regionName} · ${countryName}`,
- searchText: `${regionName} ${countryName} ${englishCountryName} ${value}`,
- },
- ];
- });
- },
-);
diff --git a/frontend/app/(main)/waf/rules/editor/components/graph-validation.ts b/frontend/app/(main)/waf/rules/editor/components/graph-validation.ts
index 4c1be9bd..0173f711 100644
--- a/frontend/app/(main)/waf/rules/editor/components/graph-validation.ts
+++ b/frontend/app/(main)/waf/rules/editor/components/graph-validation.ts
@@ -1,3 +1,4 @@
+import { runtimeText } from '@/i18n/runtime';
import type { WAFRuleGraph, WAFRuleNode } from '@/lib/services/openflare';
import { UA_BROWSER_LABELS, UA_OS_LABELS } from './ua-options';
@@ -39,25 +40,33 @@ export function validateGraph(graph: WAFRuleGraph): GraphIssue[] {
const issues: GraphIssue[] = [];
const nodeMap = new Map(graph.nodes.map((node) => [node.id, node]));
if (graph.schema_version !== 1)
- issues.push({ code: 'schema', message: '规则图 schema_version 必须为 1' });
+ issues.push({
+ code: 'schema',
+ message: runtimeText('rule_graph_schema_version_must_be_1_ea9a1161'),
+ });
if (
graph.nodes.length > 128 ||
graph.edges.length > 256 ||
new TextEncoder().encode(JSON.stringify(graph)).length > 256 * 1024
)
- issues.push({ code: 'size_limit', message: '规则图超过大小限制' });
+ issues.push({
+ code: 'size_limit',
+ message: runtimeText('rule_graph_exceeds_the_size_limit_337651b9'),
+ });
const nodeIds = new Set();
for (const node of graph.nodes) {
if (!node.id.trim())
issues.push({
code: 'empty_id',
- message: '节点 ID 不能为空',
+ message: runtimeText('node_id_must_not_be_empty_51a5de58'),
nodeId: node.id,
});
if (nodeIds.has(node.id))
issues.push({
code: 'duplicate_node_id',
- message: `节点 ID ${node.id} 重复`,
+ message: runtimeText('duplicate_node_id_value0_621230af', {
+ value0: node.id,
+ }),
nodeId: node.id,
});
nodeIds.add(node.id);
@@ -74,20 +83,22 @@ export function validateGraph(graph: WAFRuleGraph): GraphIssue[] {
if (!edge.id.trim())
issues.push({
code: 'empty_id',
- message: '连线 ID 不能为空',
+ message: runtimeText('edge_id_must_not_be_empty_bc8f69cb'),
edgeId: edge.id,
});
if (edgeIds.has(edge.id))
issues.push({
code: 'duplicate_edge_id',
- message: `连线 ID ${edge.id} 重复`,
+ message: runtimeText('duplicate_edge_id_value0_0a9d1c7c', {
+ value0: edge.id,
+ }),
edgeId: edge.id,
});
edgeIds.add(edge.id);
if (nodeMap.get(edge.target)?.type === 'start')
issues.push({
code: 'start_incoming',
- message: '开始节点不能有入边',
+ message: runtimeText('the_start_node_must_not_have_incoming_50a07b17'),
edgeId: edge.id,
nodeId: edge.target,
});
@@ -95,12 +106,12 @@ export function validateGraph(graph: WAFRuleGraph): GraphIssue[] {
if (graph.nodes.filter((node) => node.type === 'start').length !== 1)
issues.push({
code: 'start_count',
- message: '规则图必须恰好有一个开始节点',
+ message: runtimeText('the_rule_graph_must_contain_exactly_one_7dfbc027'),
});
if (graph.nodes.filter((node) => node.type === 'allow').length !== 1)
issues.push({
code: 'allow_count',
- message: '规则图必须恰好有一个通过节点',
+ message: runtimeText('the_rule_graph_must_contain_exactly_one_0036eb82'),
});
for (const edge of graph.edges) {
@@ -112,7 +123,10 @@ export function validateGraph(graph: WAFRuleGraph): GraphIssue[] {
) {
issues.push({
code: 'invalid_edge',
- message: `连线 ${edge.id} 的端点或出口无效`,
+ message: runtimeText(
+ 'edge_value0_has_an_invalid_endpoint_or_9d84eb7e',
+ { value0: edge.id },
+ ),
edgeId: edge.id,
});
}
@@ -125,13 +139,19 @@ export function validateGraph(graph: WAFRuleGraph): GraphIssue[] {
if (outgoing.length === 0)
issues.push({
code: 'missing_handle',
- message: `节点 ${node.id} 的 ${handle} 出口未连接`,
+ message: runtimeText(
+ 'node_value0_output_value1_is_not_connected_c3038e81',
+ { value0: node.id, value1: handle },
+ ),
nodeId: node.id,
});
if (outgoing.length > 1)
issues.push({
code: 'duplicate_handle',
- message: `节点 ${node.id} 的 ${handle} 出口只能连接一次`,
+ message: runtimeText(
+ 'node_value0_output_value1_can_only_be_903e54ce',
+ { value0: node.id, value1: handle },
+ ),
nodeId: node.id,
});
}
@@ -151,7 +171,10 @@ export function validateGraph(graph: WAFRuleGraph): GraphIssue[] {
if (!reachable.has(node.id))
issues.push({
code: 'unreachable',
- message: `节点 ${node.id} 无法从开始节点到达`,
+ message: runtimeText(
+ 'node_value0_is_unreachable_from_the_start_086531dc',
+ { value0: node.id },
+ ),
nodeId: node.id,
});
const terminals = graph.nodes
@@ -162,18 +185,27 @@ export function validateGraph(graph: WAFRuleGraph): GraphIssue[] {
if (!canTerminate.has(node.id))
issues.push({
code: 'non_terminating',
- message: `节点 ${node.id} 无法抵达终止节点`,
+ message: runtimeText(
+ 'node_value0_cannot_reach_a_terminal_node_8de2ff6e',
+ { value0: node.id },
+ ),
nodeId: node.id,
});
if (hasCycle(graph))
- issues.push({ code: 'cycle', message: '规则图不能包含循环' });
+ issues.push({
+ code: 'cycle',
+ message: runtimeText('the_rule_graph_must_not_contain_cycles_fd6fe797'),
+ });
return issues;
}
function validateNodeConfig(node: WAFRuleNode): string | undefined {
if (node.type === 'ip_match') {
if (node.config.ips.some((value) => !isIP(value)))
- return `节点 ${node.id} 包含无效 IP`;
+ return runtimeText(
+ 'node_value0_contains_an_invalid_ip_address_1873ab94',
+ { value0: node.id },
+ );
if (
node.config.cidrs.some((value) => {
const parts = value.split('/');
@@ -186,9 +218,13 @@ function validateNodeConfig(node: WAFRuleNode): string | undefined {
);
})
)
- return `节点 ${node.id} 包含无效 CIDR`;
+ return runtimeText('node_value0_contains_an_invalid_cidr_00c880d9', {
+ value0: node.id,
+ });
if (node.config.ip_group_ids.some((id) => !Number.isInteger(id) || id <= 0))
- return `节点 ${node.id} 包含无效 IP 组`;
+ return runtimeText('node_value0_contains_an_invalid_ip_group_589219a5', {
+ value0: node.id,
+ });
}
if (
node.type === 'geo_match' &&
@@ -197,7 +233,10 @@ function validateNodeConfig(node: WAFRuleNode): string | undefined {
(code) => !/^[A-Z]{2}-[A-Z0-9]{1,3}$/.test(code),
))
)
- return `节点 ${node.id} 包含无效地域代码`;
+ return runtimeText(
+ 'node_value0_contains_an_invalid_geography_code_1a9f469b',
+ { value0: node.id },
+ );
if (
node.type === 'pow' &&
(!['fast', 'slow'].includes(node.config.algorithm) ||
@@ -205,37 +244,61 @@ function validateNodeConfig(node: WAFRuleNode): string | undefined {
!isIntegerInRange(node.config.session_ttl, 60) ||
!isIntegerInRange(node.config.challenge_ttl, 30))
)
- return `节点 ${node.id} 的 PoW 配置超出范围`;
+ return runtimeText('node_value0_has_pow_settings_outside_the_9fd2a4cb', {
+ value0: node.id,
+ });
if (
node.type === 'block' &&
(!isIntegerInRange(node.config.status_code, 400, 599) ||
new TextEncoder().encode(node.config.response_body).length > 16 * 1024)
)
- return `节点 ${node.id} 的阻止响应配置无效`;
+ return runtimeText(
+ 'node_value0_has_invalid_block_response_settings_ae4c35ca',
+ { value0: node.id },
+ );
if (node.type === 'ua_check') {
if (!['and', 'or'].includes(node.config.match_mode))
- return `节点 ${node.id} 的匹配模式必须为 and 或 or`;
+ return runtimeText('node_value0_match_mode_must_be_and_242c1ae7', {
+ value0: node.id,
+ });
if (node.config.browsers.some((label) => !UA_BROWSER_LABELS.has(label)))
- return `节点 ${node.id} 包含无效浏览器标签`;
+ return runtimeText(
+ 'node_value0_contains_an_invalid_browser_tag_4d0e6740',
+ { value0: node.id },
+ );
if (node.config.operating_systems.some((label) => !UA_OS_LABELS.has(label)))
- return `节点 ${node.id} 包含无效操作系统标签`;
+ return runtimeText(
+ 'node_value0_contains_an_invalid_operating_system_45bea1fb',
+ { value0: node.id },
+ );
if (node.config.custom_ua_patterns.length > 32)
- return `节点 ${node.id} 的自定义 UA 正则不能超过 32 条`;
+ return runtimeText('node_value0_allows_at_most_32_custom_497938c6', {
+ value0: node.id,
+ });
for (const pattern of node.config.custom_ua_patterns) {
- if (!pattern.trim()) return `节点 ${node.id} 的自定义 UA 正则不能为空`;
+ if (!pattern.trim())
+ return runtimeText('node_value0_custom_ua_pattern_must_not_a72718be', {
+ value0: node.id,
+ });
if (new TextEncoder().encode(pattern).length > 256)
- return `节点 ${node.id} 的自定义 UA 正则过长`;
+ return runtimeText('node_value0_custom_ua_pattern_is_too_c2c83c0e', {
+ value0: node.id,
+ });
try {
void new RegExp(pattern);
} catch {
- return `节点 ${node.id} 的自定义 UA 正则无效`;
+ return runtimeText('node_value0_has_an_invalid_custom_ua_c47ffd0d', {
+ value0: node.id,
+ });
}
}
if (
node.config.block_custom_ua &&
node.config.custom_ua_patterns.length === 0
)
- return `节点 ${node.id} 开启屏蔽自定义 UA 时至少需要一条正则`;
+ return runtimeText('node_value0_requires_at_least_one_pattern_6114bb93', {
+ value0: node.id,
+ });
}
return undefined;
}
diff --git a/frontend/app/(main)/waf/rules/editor/components/node-factory.test.ts b/frontend/app/(main)/waf/rules/editor/components/node-factory.test.ts
index 744547ce..70180f2d 100644
--- a/frontend/app/(main)/waf/rules/editor/components/node-factory.test.ts
+++ b/frontend/app/(main)/waf/rules/editor/components/node-factory.test.ts
@@ -1,29 +1,38 @@
import { describe, expect, it } from 'vitest';
+import { getRuntimeTranslator } from '@/i18n/runtime';
import {
createRuleNode,
displayNodeTitle,
- NODE_TYPE_LABELS,
parseAddableNodeType,
} from './node-factory';
describe('displayNodeTitle', () => {
it('uses trimmed label when present', () => {
expect(
- displayNodeTitle({
- type: 'ip_match',
- label: ' 办公室 ',
- }),
+ displayNodeTitle(
+ {
+ type: 'ip_match',
+ label: ' 办公室 ',
+ },
+ getRuntimeTranslator('en'),
+ ),
).toBe('办公室');
});
it('falls back to type default when label empty', () => {
expect(
- displayNodeTitle({
- type: 'block',
- label: ' ',
- }),
- ).toBe(NODE_TYPE_LABELS.block);
+ displayNodeTitle(
+ {
+ type: 'block',
+ label: ' ',
+ },
+ getRuntimeTranslator('en'),
+ ),
+ ).toBe('Block');
+ expect(
+ displayNodeTitle({ type: 'block' }, getRuntimeTranslator('zh-CN')),
+ ).toBe('阻止');
});
});
diff --git a/frontend/app/(main)/waf/rules/editor/components/node-factory.ts b/frontend/app/(main)/waf/rules/editor/components/node-factory.ts
index d203764f..85e42bd1 100644
--- a/frontend/app/(main)/waf/rules/editor/components/node-factory.ts
+++ b/frontend/app/(main)/waf/rules/editor/components/node-factory.ts
@@ -7,22 +7,25 @@ export type AddableNodeType = Extract<
'ip_match' | 'geo_match' | 'ua_check' | 'security_check' | 'pow' | 'block'
>;
-export const NODE_TYPE_LABELS: Record = {
- start: '开始',
- ip_match: 'IP 匹配',
- geo_match: '地域匹配',
- ua_check: 'UA 检查',
- security_check: '安全防护',
- pow: 'PoW 挑战',
- allow: '通过',
- block: '阻止',
-};
+export const NODE_TYPE_LABEL_KEYS = {
+ start: 'start_d2bb025a',
+ ip_match: 'ip_match_43f4b6d4',
+ geo_match: 'geography_match_9acb08c4',
+ ua_check: 'ua_check_b56dac23',
+ security_check: 'security_checks_e09822e6',
+ pow: 'pow_challenge_3c043088',
+ allow: 'allow_1e9f2561',
+ block: 'block_2a366d09',
+} as const satisfies Record;
export function displayNodeTitle(
node: Pick,
+ translate: (
+ key: (typeof NODE_TYPE_LABEL_KEYS)[WAFRuleNode['type']],
+ ) => string,
): string {
const custom = node.label?.trim();
- return custom || NODE_TYPE_LABELS[node.type];
+ return custom || translate(NODE_TYPE_LABEL_KEYS[node.type]);
}
export function createRuleNode(
diff --git a/frontend/app/(main)/waf/rules/editor/components/node-library.tsx b/frontend/app/(main)/waf/rules/editor/components/node-library.tsx
index 76f243e0..193848d2 100644
--- a/frontend/app/(main)/waf/rules/editor/components/node-library.tsx
+++ b/frontend/app/(main)/waf/rules/editor/components/node-library.tsx
@@ -1,3 +1,4 @@
+import { useTranslations } from 'next-intl';
import {
Ban,
Fingerprint,
@@ -10,7 +11,7 @@ import {
import { Button } from '@/components/ui/button';
import {
- NODE_TYPE_LABELS,
+ NODE_TYPE_LABEL_KEYS,
WAF_NODE_DRAG_MIME,
type AddableNodeType,
} from './node-factory';
@@ -25,6 +26,7 @@ const items = [
] satisfies { type: AddableNodeType; icon: typeof Fingerprint }[];
export function NodeLibrary() {
+ const rt = useTranslations('runtime');
return (
{items.map(({ type, icon: Icon }) => (
@@ -42,7 +44,7 @@ export function NodeLibrary() {
}}
>
- {NODE_TYPE_LABELS[type]}
+ {rt(NODE_TYPE_LABEL_KEYS[type])}
))}
diff --git a/frontend/app/(main)/waf/rules/editor/components/node-properties.tsx b/frontend/app/(main)/waf/rules/editor/components/node-properties.tsx
index 5a498a93..a5319625 100644
--- a/frontend/app/(main)/waf/rules/editor/components/node-properties.tsx
+++ b/frontend/app/(main)/waf/rules/editor/components/node-properties.tsx
@@ -1,6 +1,6 @@
import { CircleHelp, Settings2 } from 'lucide-react';
-import { useTranslations } from 'next-intl';
-import { useState } from 'react';
+import { useLocale, useTranslations } from 'next-intl';
+import { useMemo, useState } from 'react';
import { Button } from '@/components/ui/button';
import { Checkbox } from '@/components/ui/checkbox';
@@ -35,8 +35,8 @@ import {
} from '@/components/ui/tooltip';
import type { WAFIPGroup, WAFRuleNode } from '@/lib/services/openflare';
-import { countryOptions, regionOptions, type GeoOption } from './geo-options';
-import { NODE_TYPE_LABELS } from './node-factory';
+import { createGeoOptions, type GeoOption } from './geo-options';
+import { NODE_TYPE_LABEL_KEYS } from './node-factory';
import { UA_BROWSER_OPTIONS, UA_OS_OPTIONS } from './ua-options';
export function NodeProperties({
@@ -78,6 +78,14 @@ function PropertyFields({
onChange: (node: WAFRuleNode) => void;
}) {
const t = useTranslations('waf.editor');
+ const locale = useLocale();
+ const { countryOptions, regionOptions } = useMemo(
+ () =>
+ node.type === 'geo_match'
+ ? createGeoOptions(locale)
+ : { countryOptions: [], regionOptions: [] },
+ [locale, node.type],
+ );
if (node.type === 'start' || node.type === 'allow')
return (
{t('systemNodeNoConfig')}
@@ -518,13 +526,14 @@ function DisplayNameField({
onChange: (node: WAFRuleNode) => void;
}) {
const t = useTranslations('waf.editor');
+ const rt = useTranslations('runtime');
return (
{t('displayName')}
onChange({ ...node, label: event.target.value })}
/>
diff --git a/frontend/app/(main)/waf/rules/editor/components/rule-node.tsx b/frontend/app/(main)/waf/rules/editor/components/rule-node.tsx
index e7b72782..56d3ee50 100644
--- a/frontend/app/(main)/waf/rules/editor/components/rule-node.tsx
+++ b/frontend/app/(main)/waf/rules/editor/components/rule-node.tsx
@@ -1,4 +1,5 @@
import { Handle, Position, type NodeProps } from '@xyflow/react';
+import { useTranslations } from 'next-intl';
import {
Ban,
Fingerprint,
@@ -42,10 +43,11 @@ const outputHandles: Partial> = {
};
export function RuleNode({ data, selected }: NodeProps) {
+ const rt = useTranslations('runtime');
const value = data as RuleFlowNodeData;
const { rule, issues } = value;
const { icon: Icon } = meta[rule.type];
- const title = displayNodeTitle(rule);
+ const title = displayNodeTitle(rule, rt);
return (
(
diff --git a/frontend/app/(main)/waf/rules/editor/page.tsx b/frontend/app/(main)/waf/rules/editor/page.tsx
index bd0f365b..de5ecd66 100644
--- a/frontend/app/(main)/waf/rules/editor/page.tsx
+++ b/frontend/app/(main)/waf/rules/editor/page.tsx
@@ -219,8 +219,8 @@ function EditorContent() {
{t('back')}
-
-
+
+
{ruleQuery.data.name}
@@ -235,7 +235,7 @@ function EditorContent() {
{dirty && {t('unsaved')}}
-
+
value
- .toLocaleDateString('en-GB', { day: 'numeric', month: 'long' })
+ .toLocaleDateString(getFormattingLocale(), {
+ day: 'numeric',
+ month: 'long',
+ })
.toUpperCase();
return `${fmt(start)} — ${fmt(end)}`;
}
diff --git a/frontend/app/(main)/websites/components/quick-create-zone-domain-dialog.tsx b/frontend/app/(main)/websites/components/quick-create-zone-domain-dialog.tsx
index a0142bc7..ebc8a4ee 100644
--- a/frontend/app/(main)/websites/components/quick-create-zone-domain-dialog.tsx
+++ b/frontend/app/(main)/websites/components/quick-create-zone-domain-dialog.tsx
@@ -65,6 +65,8 @@ export function QuickCreateZoneDomainDialog({
zones?: ZoneItem[];
onCreated(domain: ZoneDomainItem): void | Promise;
}) {
+ const uiText = useTranslations('runtime');
+
const t = useTranslations('websites');
const tc = useTranslations('common');
const queryClient = useQueryClient();
@@ -214,7 +216,7 @@ export function QuickCreateZoneDomainDialog({
>
{!fixedZoneId ? (
-
+