diff --git a/docs/changelog/index.md b/docs/changelog/index.md index 18b332fb..4fa0f6f9 100644 --- a/docs/changelog/index.md +++ b/docs/changelog/index.md @@ -29,6 +29,7 @@ sidebar: false ### 变更 - 优化了 WAF 规则编辑器的初始视图和操作方式,编辑规则时可看到更多上下文并可直接管理节点、连线和启用状态。 +- WAF 地域匹配编辑器改用完整国家与一级行政区数据,国家选项同时显示中文名称和 ISO 代码,行政区支持按名称或代码搜索。 - Agent 现内置国家和城市地址库,首次启动无需下载即可使用地区匹配功能,并会在后续自动更新数据。 - 默认关闭 Redis maintenance notifications 自动协商,减少不支持该功能的 Redis 服务产生兼容性警告。 diff --git a/docs/design/waf-orchestration-design.md b/docs/design/waf-orchestration-design.md index 586ebf8a..101eecb8 100644 --- a/docs/design/waf-orchestration-design.md +++ b/docs/design/waf-orchestration-design.md @@ -89,6 +89,7 @@ React Flow 编辑页采用全宽画布和固定右侧属性栏: * “添加处理单元”提供 IP 匹配、地域匹配、PoW 和阻止;开始与通过由默认图提供且不可删除或重复添加。 * 选中普通节点或连线后可使用画布删除按钮或 Delete/Backspace 删除;删除节点时同步移除关联连线。 * 右侧属性栏默认隐藏,选中节点后才显示并用于编辑配置;点击连线或画布空白处时收起。 +* 地域匹配属性使用完整国家与 ISO 3166-2 一级行政区数据;国家选项同时显示本地化名称与代码,行政区支持按国家名、行政区名或代码搜索,避免一次渲染数千个选项。 * 离开存在未保存变更的页面前必须提示;保存冲突和 Server 校验错误应定位到相关节点或边。 WAF 列表展示规则名称、启用状态、节点数量、应用路由数量和更新时间。新建规则的对话框只有名称字段,成功后立即导航到编排页面。 diff --git a/docs/guide/waf-usage.md b/docs/guide/waf-usage.md index 557c786a..297ffc62 100644 --- a/docs/guide/waf-usage.md +++ b/docs/guide/waf-usage.md @@ -8,7 +8,7 @@ OpenFlare WAF 使用可视化有向无环图编排规则。新建规则时只填 - **通过**:结束当前规则;若路由仍有后续规则则继续执行。 - **阻止**:立即按配置的状态码和 HTML 响应终止请求。 - **IP 匹配**:配置 IP、CIDR 或 IP 组,分别连接 `true`、`false`。 -- **地域匹配**:按国家或地区代码分支;Country 与 City MMDB 缺失时由 Agent 从程序内嵌数据库初始化,并按配置周期更新。City MMDB 不可用时按未匹配处理。 +- **地域匹配**:按国家或 ISO 3166-2 一级行政区代码分支;国家列表同时显示中文名称与代码,行政区可按国家名、行政区名或代码搜索。Country 与 City MMDB 缺失时由 Agent 从程序内嵌数据库初始化,并按配置周期更新。City MMDB 不可用时按未匹配处理。 - **PoW**:未完成挑战时接管请求,验证通过后沿 `next` 继续。 服务端会拒绝循环、悬空出口、不可达节点、重复端口连接和无效配置。保存时携带页面加载得到的 `revision`;发生 409 冲突时应重新加载,避免覆盖他人修改。 diff --git a/docs/plan/20260713-waf-orchestration.md b/docs/plan/20260713-waf-orchestration.md index 7bdb2959..2aac610c 100644 --- a/docs/plan/20260713-waf-orchestration.md +++ b/docs/plan/20260713-waf-orchestration.md @@ -10,7 +10,7 @@ ## 实现状态(2026-07-13) -Tasks 1–11 已实现,包含三段数据库迁移、图模型与编译器、规则 API、发布快照、OpenResty 内存执行器、IP 组协调刷新、React Flow 编辑器、有序绑定、GeoLite2 City/Country 支持以及中文文档与 Swagger 更新。React Flow 画布使用本地受控节点状态处理拖动,并支持显式或键盘删除普通节点与连线。Country 与 City MMDB 均随 Agent 内嵌,缺失文件在启动时从程序内初始化,网络仅用于后续周期更新。 +Tasks 1–11 已实现,包含三段数据库迁移、图模型与编译器、规则 API、发布快照、OpenResty 内存执行器、IP 组协调刷新、React Flow 编辑器、有序绑定、GeoLite2 City/Country 支持以及中文文档与 Swagger 更新。React Flow 画布使用本地受控节点状态处理拖动,并支持显式或键盘删除普通节点与连线。Country 与 City MMDB 均随 Agent 内嵌,缺失文件在启动时从程序内初始化,网络仅用于后续周期更新。地域属性栏使用完整国家与 ISO 3166-2 一级行政区数据,国家同时展示中文名称与代码,行政区支持按名称或代码搜索。 当前工作区已完成 `go test ./...`、前端全量 Vitest(56 项)、`make swagger`、`make code-check` 与 `git diff --check` 验证。Next.js 生产构建在本机持续停留于 Turbopack 的 `Creating an optimized production build ...`,未返回编译错误或成功状态,故不计为通过。 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 new file mode 100644 index 00000000..a5756ee8 --- /dev/null +++ b/frontend/app/(main)/waf/rules/editor/components/geo-options.test.ts @@ -0,0 +1,23 @@ +import { describe, expect, it } from 'vitest'; + +import { countryOptions, regionOptions } from './geo-options'; + +describe('geo options', () => { + it('contains the complete country list with localized names', () => { + expect(countryOptions).toHaveLength(249); + expect(countryOptions).toContainEqual( + expect.objectContaining({ value: 'CN', label: '中国' }), + ); + }); + + it('contains searchable ISO subdivision options', () => { + expect(regionOptions.length).toBeGreaterThan(4000); + expect(regionOptions).toContainEqual( + expect.objectContaining({ + value: 'CN-BJ', + label: expect.stringContaining('Beijing'), + searchText: expect.stringContaining('中国'), + }), + ); + }); +}); diff --git a/frontend/app/(main)/waf/rules/editor/components/geo-options.ts b/frontend/app/(main)/waf/rules/editor/components/geo-options.ts new file mode 100644 index 00000000..06694c86 --- /dev/null +++ b/frontend/app/(main)/waf/rules/editor/components/geo-options.ts @@ -0,0 +1,53 @@ +import { allCountries } from 'country-region-data'; + +export interface GeoOption { + value: string; + label: string; + searchText: string; +} + +const countryCodePattern = /^[A-Z]{2}$/; +const regionCodePattern = /^[A-Z0-9]{1,3}$/; +const countryDisplayNames = new Intl.DisplayNames(['zh-CN'], { + type: 'region', +}); + +function localizedCountryName(code: string, fallback: string) { + const localized = countryDisplayNames.of(code); + return !localized || localized === code ? fallback : localized; +} + +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/node-properties.test.tsx b/frontend/app/(main)/waf/rules/editor/components/node-properties.test.tsx index 3e40bbbe..936dabf8 100644 --- a/frontend/app/(main)/waf/rules/editor/components/node-properties.test.tsx +++ b/frontend/app/(main)/waf/rules/editor/components/node-properties.test.tsx @@ -52,7 +52,7 @@ it('creates any normalized valid geography code', async () => { const onChange = vi.fn(); render(); fireEvent.click(screen.getByRole('button', { name: '国家代码' })); - fireEvent.change(await screen.findByPlaceholderText('输入代码并添加'), { + fireEvent.change(await screen.findByPlaceholderText('搜索名称或代码'), { target: { value: 'nz' }, }); fireEvent.click(screen.getByRole('button', { name: '添加代码' })); @@ -62,3 +62,17 @@ it('creates any normalized valid geography code', async () => { }), ); }); + +it('shows localized country names together with their codes', async () => { + const node: WAFRuleNode = { + id: 'geo', + type: 'geo_match', + position: { x: 0, y: 0 }, + config: { countries: [], regions: [] }, + }; + render(); + fireEvent.click(screen.getByRole('button', { name: '国家代码' })); + expect(await screen.findByText('中国')).toBeInTheDocument(); + expect(screen.getByText('CN')).toBeInTheDocument(); + expect(screen.getByText(/共 249 个国家\/地区/)).toBeInTheDocument(); +}); 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 3cae0111..60100aea 100644 --- a/frontend/app/(main)/waf/rules/editor/components/node-properties.tsx +++ b/frontend/app/(main)/waf/rules/editor/components/node-properties.tsx @@ -28,32 +28,7 @@ import { Separator } from '@/components/ui/separator'; import { Textarea } from '@/components/ui/textarea'; import type { WAFIPGroup, WAFRuleNode } from '@/lib/services/openflare'; -const countries = [ - 'CN', - 'US', - 'JP', - 'SG', - 'DE', - 'FR', - 'GB', - 'CA', - 'AU', - 'BR', - 'IN', - 'KR', -].map((value) => ({ value, label: value })); -const regions = [ - 'CN-BJ', - 'CN-SH', - 'CN-GD', - 'CN-ZJ', - 'US-CA', - 'US-NY', - 'US-TX', - 'JP-13', - 'DE-BE', - 'GB-ENG', -].map((value) => ({ value, label: value })); +import { countryOptions, regionOptions, type GeoOption } from './geo-options'; export function NodeProperties({ node, @@ -119,6 +94,7 @@ function PropertyFields({ options={ipGroups.map((group) => ({ value: String(group.id), label: group.name, + searchText: `${group.name} ${group.id}`, }))} value={node.config.ip_group_ids.map(String)} onChange={(values) => @@ -136,7 +112,8 @@ function PropertyFields({ @@ -146,9 +123,11 @@ function PropertyFields({ onChange({ ...node, config: { ...node.config, regions } }) } @@ -297,28 +276,41 @@ function NumberField({ function MultiSelect({ id, label, + description, options, value, creatablePattern, + searchRequired = false, onChange, }: { id: string; label: string; - options: { value: string; label: string }[]; + description?: string; + options: GeoOption[]; value: string[]; creatablePattern?: RegExp; + searchRequired?: boolean; onChange: (value: string[]) => void; }) { const [draft, setDraft] = useState(''); const normalized = draft.trim().toUpperCase(); - const visible = [ + const query = draft.trim().toLocaleLowerCase(); + const available = [ ...options, ...value .filter( (selected) => !options.some((option) => option.value === selected), ) - .map((selected) => ({ value: selected, label: selected })), + .map((selected) => ({ + value: selected, + label: selected, + searchText: selected, + })), ]; + const visible = available.filter((option) => { + if (!query) return !searchRequired || value.includes(option.value); + return option.searchText.toLocaleLowerCase().includes(query); + }); const canCreate = Boolean( creatablePattern?.test(normalized) && !value.includes(normalized), ); @@ -331,57 +323,68 @@ function MultiSelect({ {value.length ? `已选择 ${value.length} 项` : '请选择'} - - {creatablePattern && ( + + {(creatablePattern || options.length > 0) && (
setDraft(event.target.value)} /> - + {creatablePattern && ( + + )}
)} - {visible.length === 0 ? ( -

暂无可选项

- ) : ( - visible.map((option) => ( - - )) - )} +
+ {visible.length === 0 ? ( +

+ {searchRequired && !query + ? `输入名称或代码搜索 ${options.length} 个选项` + : '没有匹配的选项'} +

+ ) : ( + visible.map((option) => ( + + )) + )} +
+ {description && {description}} ); } diff --git a/frontend/package.json b/frontend/package.json index 57a813f0..4a199d34 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -51,6 +51,7 @@ "class-variance-authority": "^0.7.1", "clsx": "^2.1.1", "cmdk": "^1.1.1", + "country-region-data": "^4.1.0", "date-fns": "^4.1.0", "echarts": "^6.1.0", "echarts-for-react": "^3.0.6", diff --git a/frontend/pnpm-lock.yaml b/frontend/pnpm-lock.yaml index fec2f776..d2ce924d 100644 --- a/frontend/pnpm-lock.yaml +++ b/frontend/pnpm-lock.yaml @@ -122,6 +122,9 @@ importers: cmdk: specifier: ^1.1.1 version: 1.1.1(@types/react-dom@19.2.2(@types/react@19.2.2))(@types/react@19.2.2)(react-dom@19.2.3(react@19.2.3))(react@19.2.3) + country-region-data: + specifier: ^4.1.0 + version: 4.1.0 date-fns: specifier: ^4.1.0 version: 4.1.0 @@ -2399,6 +2402,9 @@ packages: core-js@3.47.0: resolution: {integrity: sha512-c3Q2VVkGAUyupsjRnaNX6u8Dq2vAdzm9iuPj5FW0fRxzlxgq9Q39MDq10IvmQSpLgHQNyQzQmOo6bgGHmH3NNg==} + country-region-data@4.1.0: + resolution: {integrity: sha512-20fGwnpj5Q3HlQTHdMUstpD4pixphymWBj1f9/EonKEvnEDCaq1S3peE1O7NLxHI1zSa6W+5do/u7xS7UVfZtw==} + crelt@1.0.6: resolution: {integrity: sha512-VQ2MBenTq1fWZUH9DJNGti7kKv6EeAuYr3cLwxUWhIu1baTaXh4Ib5W2CqHVqib4/MqbYGJqiL3Zb8GJZr3l4g==} @@ -6670,6 +6676,8 @@ snapshots: core-js@3.47.0: optional: true + country-region-data@4.1.0: {} + crelt@1.0.6: {} cross-spawn@7.0.6: