From 1fd4b22b9cf0dbe711b608014eeb033bf3f6a829 Mon Sep 17 00:00:00 2001 From: ryan Date: Fri, 20 Mar 2026 22:37:02 +0800 Subject: [PATCH] =?UTF-8?q?[=E5=8A=9F=E8=83=BD]=20=E9=87=8D=E6=9E=84?= =?UTF-8?q?=E4=BB=A3=E7=90=86=E8=B7=AF=E7=94=B1=E9=A1=B5=E9=9D=A2=E7=9A=84?= =?UTF-8?q?=E5=8D=95=E5=85=83=E6=B5=8B=E8=AF=95=EF=BC=8C=E4=BC=98=E5=8C=96?= =?UTF-8?q?fetch=E6=A8=A1=E6=8B=9F=E5=92=8C=E8=BE=93=E5=85=A5=E9=AA=8C?= =?UTF-8?q?=E8=AF=81=E9=80=BB=E8=BE=91?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../components/proxy-routes-page.tsx | 225 ++++++++++++------ .../web/tests/unit/proxy-routes-page.test.tsx | 112 +++++++-- 2 files changed, 245 insertions(+), 92 deletions(-) diff --git a/openflare_server/web/features/proxy-routes/components/proxy-routes-page.tsx b/openflare_server/web/features/proxy-routes/components/proxy-routes-page.tsx index 9057c362..e2326609 100644 --- a/openflare_server/web/features/proxy-routes/components/proxy-routes-page.tsx +++ b/openflare_server/web/features/proxy-routes/components/proxy-routes-page.tsx @@ -2,7 +2,7 @@ import { zodResolver } from '@hookform/resolvers/zod'; import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'; -import { useEffect, useMemo, useState } from 'react'; +import { useEffect, useMemo, useRef, useState } from 'react'; import { useFieldArray, useForm, useWatch } from 'react-hook-form'; import { z } from 'zod'; @@ -763,13 +763,15 @@ function SearchableManagedDomainField({ error?: string; onSelect: (value: string) => void; }) { - const [search, setSearch] = useState(''); + const [inputValue, setInputValue] = useState(''); const [isOpen, setIsOpen] = useState(false); + const [highlightedIndex, setHighlightedIndex] = useState(0); + const containerRef = useRef(null); const selectedDomain = domains.find((item) => item.domain === value)?.domain ?? value; const filteredDomains = useMemo(() => { - const keyword = search.trim().toLowerCase(); + const keyword = inputValue.trim().toLowerCase(); if (!keyword) { return domains; } @@ -777,73 +779,157 @@ function SearchableManagedDomainField({ return domains.filter((item) => item.domain.toLowerCase().includes(keyword), ); - }, [domains, search]); + }, [domains, inputValue]); + const highlightedDomain = + filteredDomains[Math.min(highlightedIndex, Math.max(filteredDomains.length - 1, 0))] ?? + null; + + useEffect(() => { + if (value) { + setInputValue(value); + return; + } + + if (!isOpen) { + setInputValue(''); + } + }, [isOpen, value]); + + useEffect(() => { + setHighlightedIndex(0); + }, [inputValue]); + + useEffect(() => { + if (highlightedIndex > filteredDomains.length - 1) { + setHighlightedIndex(0); + } + }, [filteredDomains.length, highlightedIndex]); useEffect(() => { if (!isOpen) { - setSearch(''); + return; } + + const handlePointerDown = (event: MouseEvent) => { + if (!containerRef.current?.contains(event.target as Node)) { + setIsOpen(false); + } + }; + + document.addEventListener('mousedown', handlePointerDown); + return () => { + document.removeEventListener('mousedown', handlePointerDown); + }; }, [isOpen]); return ( -
- + +
{isOpen ? (
-
- - setSearch(event.target.value)} - className={cn(INPUT_CLASS_NAME, 'pl-9')} - /> +
+ + {highlightedDomain + ? `按 Enter 选择 ${highlightedDomain.domain}` + : '继续输入以匹配目标域名'} + + {`${filteredDomains.length} 个结果`}
-
+
{filteredDomains.length > 0 ? ( - filteredDomains.map((domain) => ( + filteredDomains.map((domain, index) => ( )) ) : ( @@ -1405,46 +1491,35 @@ export function ProxyRoutesPage() { icon={} >
- - - {selectedManagedDomain ? ( -
- {isWildcardSelection ? ( - - - - ) : ( -
- )} +
+ + {selectedManagedDomain && isWildcardSelection ? ( -
- {effectiveDomain || '请选择目标域名'} -
+
-
- ) : null} + ) : null} +
diff --git a/openflare_server/web/tests/unit/proxy-routes-page.test.tsx b/openflare_server/web/tests/unit/proxy-routes-page.test.tsx index 40ecab29..9999b241 100644 --- a/openflare_server/web/tests/unit/proxy-routes-page.test.tsx +++ b/openflare_server/web/tests/unit/proxy-routes-page.test.tsx @@ -160,17 +160,15 @@ describe('ProxyRoutesPage', () => { await user.click(await screen.findByRole('button', { name: '新增规则' })); const dialog = await screen.findByRole('dialog', { name: '新增规则' }); - await user.click( - within(dialog).getByRole('button', { name: /Select Target Domain/ }), - ); - await user.click(await screen.findByText('*.example.com')); + const domainInput = within(dialog).getByPlaceholderText('输入并搜索目标域名'); + await user.type(domainInput, 'example'); + await user.keyboard('{Enter}'); expect(await screen.findByPlaceholderText('e.g. ai')).toBeInTheDocument(); - expect(screen.getByText('*.example.com')).toBeInTheDocument(); + expect(domainInput).toHaveValue('*.example.com'); await user.type(screen.getByPlaceholderText('e.g. ai'), 'ai'); - - expect(screen.getByText('ai.example.com')).toBeInTheDocument(); + expect(screen.getByPlaceholderText('e.g. ai')).toHaveValue('ai'); }); it('uses exact website directly without showing subdomain input', async () => { @@ -199,13 +197,12 @@ describe('ProxyRoutesPage', () => { await user.click(await screen.findByRole('button', { name: '新增规则' })); const dialog = await screen.findByRole('dialog', { name: '新增规则' }); - await user.click( - within(dialog).getByRole('button', { name: /Select Target Domain/ }), - ); - await user.click(await screen.findByText('a.example.com')); + const domainInput = within(dialog).getByPlaceholderText('输入并搜索目标域名'); + await user.type(domainInput, 'a.example'); + await user.keyboard('{Enter}'); expect(screen.queryByPlaceholderText('e.g. ai')).not.toBeInTheDocument(); - expect((await screen.findAllByText('a.example.com')).length).toBeGreaterThan(0); + expect(domainInput).toHaveValue('a.example.com'); expect(await screen.findByText('Select Certificate')).toBeInTheDocument(); }); @@ -245,11 +242,9 @@ describe('ProxyRoutesPage', () => { const user = userEvent.setup(); await user.click(await screen.findByRole('button', { name: '新增规则' })); - const domainTrigger = await screen.findByRole('button', { - name: /Select Target Domain/, - }); - await user.click(domainTrigger); - await user.click(await screen.findByText('*.example.com')); + const domainInput = await screen.findByPlaceholderText('输入并搜索目标域名'); + await user.type(domainInput, 'example'); + await user.keyboard('{Enter}'); const addressInput = screen.getByPlaceholderText('192.168.1.45'); await user.click(addressInput); @@ -266,4 +261,87 @@ describe('ProxyRoutesPage', () => { await screen.findByText('未发现匹配资产,请手动输入'), ).toBeInTheDocument(); }); + + it('closes managed domain suggestions when clicking outside', async () => { + stubMatchMedia(); + + vi.stubGlobal( + 'fetch', + buildBaseFetchStub({ + managedDomains: [ + { + id: 1, + domain: '*.example.com', + cert_id: 1, + enabled: true, + remark: '', + created_at: '2026-03-20T08:00:00Z', + updated_at: '2026-03-20T08:00:00Z', + }, + ], + }), + ); + + renderProxyRoutesPage(); + + const user = userEvent.setup(); + await user.click(await screen.findByRole('button', { name: '新增规则' })); + const domainInput = await screen.findByPlaceholderText('输入并搜索目标域名'); + + await user.click(domainInput); + await user.type(domainInput, 'exam'); + expect(await screen.findByText('*.example.com')).toBeInTheDocument(); + + await user.click( + within(await screen.findByRole('dialog', { name: '新增规则' })).getByRole( + 'heading', + { name: 'Protocol' }, + ), + ); + expect(screen.queryByText('*.example.com')).not.toBeInTheDocument(); + }); + + it('supports arrow key navigation for managed domain suggestions', async () => { + stubMatchMedia(); + + vi.stubGlobal( + 'fetch', + buildBaseFetchStub({ + managedDomains: [ + { + id: 1, + domain: '*.example.com', + cert_id: 1, + enabled: true, + remark: '', + created_at: '2026-03-20T08:00:00Z', + updated_at: '2026-03-20T08:00:00Z', + }, + { + id: 2, + domain: 'app.example.com', + cert_id: 1, + enabled: true, + remark: '', + created_at: '2026-03-20T08:00:00Z', + updated_at: '2026-03-20T08:00:00Z', + }, + ], + }), + ); + + renderProxyRoutesPage(); + + const user = userEvent.setup(); + await user.click(await screen.findByRole('button', { name: '新增规则' })); + const dialog = await screen.findByRole('dialog', { name: '新增规则' }); + const domainInput = within(dialog).getByPlaceholderText('输入并搜索目标域名'); + + await user.type(domainInput, 'example'); + await user.keyboard('{ArrowDown}'); + await user.keyboard('{Enter}'); + + expect(domainInput).toHaveValue('app.example.com'); + expect(screen.queryByPlaceholderText('e.g. ai')).not.toBeInTheDocument(); + }); });