From be9744abc6f19fb37bc5141b3d0ff43daf1401d7 Mon Sep 17 00:00:00 2001 From: ryan Date: Fri, 20 Mar 2026 22:20:13 +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 | 1647 +++++++++++------ .../web/tests/unit/proxy-routes-page.test.tsx | 332 ++-- 2 files changed, 1259 insertions(+), 720 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 43f19c42..9057c362 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 @@ -11,8 +11,8 @@ import { ErrorState } from '@/components/feedback/error-state'; import { InlineMessage } from '@/components/feedback/inline-message'; import { LoadingState } from '@/components/feedback/loading-state'; import { PageHeader } from '@/components/layout/page-header'; -import { AppModal } from '@/components/ui/app-modal'; import { AppCard } from '@/components/ui/app-card'; +import { AppModal } from '@/components/ui/app-modal'; import { StatusBadge } from '@/components/ui/status-badge'; import { getConfigVersionDiff, @@ -52,6 +52,7 @@ import { SecondaryButton, ToggleField, } from '@/features/shared/components/resource-primitives'; +import { cn } from '@/lib/utils/cn'; import { formatDateTime } from '@/lib/utils/date'; const customHeaderSchema = z.object({ @@ -59,22 +60,25 @@ const customHeaderSchema = z.object({ value: z.string(), }); +const originProtocolValues = ['http', 'https'] as const; const cachePolicyValues = [ 'url', 'suffix', 'path_prefix', 'path_exact', ] as const; -const originProtocolValues = ['http', 'https'] as const; + +const originRowSchema = z.object({ + scheme: z.enum(originProtocolValues), + address: z.string(), + port: z.string(), +}); const proxyRouteSchema = z .object({ - managed_domain_id: z.string().trim().min(1, '请选择网站'), + managed_domain_id: z.string().trim().min(1, '请选择目标域名'), subdomain_label: z.string(), - origin_id: z.string(), - origin_scheme: z.enum(originProtocolValues), - origin_address: z.string().trim().min(1, '请输入源站地址'), - origin_port: z.string().trim().min(1, '请输入端口'), + origin_rows: z.array(originRowSchema).min(1), origin_uri: z.string(), origin_host: z .string() @@ -94,7 +98,6 @@ const proxyRouteSchema = z })()), '请输入合法的回源主机名', ), - upstreams_text: z.string(), enabled: z.boolean(), enable_https: z.boolean(), cert_id: z.string(), @@ -114,7 +117,7 @@ const proxyRouteSchema = z context.addIssue({ code: z.ZodIssueCode.custom, path: ['subdomain_label'], - message: '请输入二级域名', + message: '请输入子域名前缀', }); } @@ -126,7 +129,7 @@ const proxyRouteSchema = z context.addIssue({ code: z.ZodIssueCode.custom, path: ['subdomain_label'], - message: '二级域名仅支持单个标签,且只能包含字母、数字和中划线', + message: '子域名前缀仅支持单个标签,且只能包含字母、数字和中划线', }); } @@ -138,35 +141,6 @@ const proxyRouteSchema = z }); } - const normalizedOriginAddress = value.origin_address.trim(); - if ( - normalizedOriginAddress && - (/[/?#]/.test(normalizedOriginAddress) || - normalizedOriginAddress.includes('://')) - ) { - context.addIssue({ - code: z.ZodIssueCode.custom, - path: ['origin_address'], - message: '源站地址仅支持 IP、域名或主机名', - }); - } - - const normalizedOriginPort = value.origin_port.trim(); - const portNumber = Number(normalizedOriginPort); - if ( - normalizedOriginPort && - (!/^\d+$/.test(normalizedOriginPort) || - !Number.isInteger(portNumber) || - portNumber < 1 || - portNumber > 65535) - ) { - context.addIssue({ - code: z.ZodIssueCode.custom, - path: ['origin_port'], - message: '端口需为 1 到 65535 的整数', - }); - } - const normalizedOriginURI = value.origin_uri.trim(); if ( normalizedOriginURI && @@ -176,35 +150,70 @@ const proxyRouteSchema = z context.addIssue({ code: z.ZodIssueCode.custom, path: ['origin_uri'], - message: '源站路径需以 / 或 ? 开头', + message: '回源路径需以 / 或 ? 开头', }); } - const primaryOriginURL = buildOriginUrl( - value.origin_scheme, - value.origin_address, - value.origin_port, - value.origin_uri, - ); - if (!primaryOriginURL) { - context.addIssue({ - code: z.ZodIssueCode.custom, - path: ['origin_address'], - message: '请输入完整的源站信息', - }); - } + value.origin_rows.forEach((row, index) => { + const normalizedAddress = row.address.trim(); + const normalizedPort = row.port.trim(); - const upstreams = parseUpstreamsText( - primaryOriginURL, - value.upstreams_text, - ); - if (upstreams.length === 0) { - context.addIssue({ - code: z.ZodIssueCode.custom, - path: ['origin_address'], - message: '至少需要一个上游地址', - }); - } + if (!normalizedAddress) { + context.addIssue({ + code: z.ZodIssueCode.custom, + path: ['origin_rows', index, 'address'], + message: '请输入源站地址', + }); + } + + if ( + normalizedAddress && + (/[/?#]/.test(normalizedAddress) || normalizedAddress.includes('://')) + ) { + context.addIssue({ + code: z.ZodIssueCode.custom, + path: ['origin_rows', index, 'address'], + message: '源站地址仅支持 IP、域名或主机名', + }); + } + + if (!normalizedPort) { + context.addIssue({ + code: z.ZodIssueCode.custom, + path: ['origin_rows', index, 'port'], + message: '请输入端口', + }); + } + + const portNumber = Number(normalizedPort); + if ( + normalizedPort && + (!/^\d+$/.test(normalizedPort) || + !Number.isInteger(portNumber) || + portNumber < 1 || + portNumber > 65535) + ) { + context.addIssue({ + code: z.ZodIssueCode.custom, + path: ['origin_rows', index, 'port'], + message: '端口需为 1 到 65535 的整数', + }); + } + + const originURL = buildOriginUrl( + row.scheme, + row.address, + row.port, + index === 0 ? value.origin_uri : '', + ); + if (!originURL) { + context.addIssue({ + code: z.ZodIssueCode.custom, + path: ['origin_rows', index, 'address'], + message: '请输入完整的源站信息', + }); + } + }); if (value.cache_enabled) { const cacheRules = parseCacheRulesText(value.cache_rules_text); @@ -252,22 +261,27 @@ const proxyRouteSchema = z }); type ProxyRouteFormValues = z.infer; +type OriginRowFormValue = ProxyRouteFormValues['origin_rows'][number]; type FeedbackState = { tone: 'info' | 'success' | 'danger'; message: string; }; +type SectionIconProps = { + className?: string; +}; + +const INPUT_CLASS_NAME = 'h-10 rounded-xl px-3 py-2 text-sm'; +const PANEL_CLASS_NAME = + 'rounded-2xl border border-[var(--border-default)] bg-[color:color-mix(in_srgb,var(--surface-elevated)_82%,white_18%)] p-4 shadow-[var(--shadow-soft)]'; + const defaultValues: ProxyRouteFormValues = { managed_domain_id: '', subdomain_label: '', - origin_id: '', - origin_scheme: 'https', - origin_address: '', - origin_port: '', + origin_rows: [{ scheme: 'https', address: '', port: '443' }], origin_uri: '', origin_host: '', - upstreams_text: '', enabled: true, enable_https: false, cert_id: '', @@ -285,6 +299,141 @@ const managedDomainsQueryKey = ['managed-domains']; const originsQueryKey = ['origins']; const versionsQueryKey = ['config-versions']; +function GlobeIcon({ className }: SectionIconProps) { + return ( + + ); +} + +function ServerIcon({ className }: SectionIconProps) { + return ( + + ); +} + +function ShieldIcon({ className }: SectionIconProps) { + return ( + + ); +} + +function SlidersIcon({ className }: SectionIconProps) { + return ( + + ); +} + +function SearchIcon({ className }: SectionIconProps) { + return ( + + ); +} + +function PlusIcon({ className }: SectionIconProps) { + return ( + + ); +} + +function TrashIcon({ className }: SectionIconProps) { + return ( + + ); +} + +function ChevronIcon({ + className, + expanded = false, +}: SectionIconProps & { expanded?: boolean }) { + return ( + + ); +} + function hasConfigChanges(diff: { active_version?: string; added_domains: string[]; @@ -353,12 +502,6 @@ function parseUpstreams(rawValue: string) { } } -function parseUpstreamsText(primary: string, value: string) { - return [primary.trim(), ...value.split(/\r?\n/)] - .map((item) => item.trim()) - .filter(Boolean); -} - function buildCachePolicyLabel(policy: string) { switch (policy) { case 'suffix': @@ -426,33 +569,46 @@ function parseOriginUrl(rawValue: string) { return { scheme: 'https' as const, address: '', - port: '', + port: '443', uri: '', }; } } -function toPayload(values: ProxyRouteFormValues): ProxyRouteMutationPayload { +function getDefaultPortForScheme(scheme: 'http' | 'https') { + return scheme === 'http' ? '80' : '443'; +} + +function toPayload( + values: ProxyRouteFormValues, + origins: OriginItem[], +): ProxyRouteMutationPayload { + const primaryOrigin = values.origin_rows[0]; const primaryOriginUrl = buildOriginUrl( - values.origin_scheme, - values.origin_address, - values.origin_port, + primaryOrigin.scheme, + primaryOrigin.address, + primaryOrigin.port, values.origin_uri, ); + const primaryOriginRecord = + origins.find( + (item) => + item.address.toLowerCase() === primaryOrigin.address.trim().toLowerCase(), + ) ?? null; return { domain: buildRouteDomain(values.managed_domain_id, values.subdomain_label), - origin_id: values.origin_id ? Number(values.origin_id) : null, + origin_id: primaryOriginRecord ? primaryOriginRecord.id : null, origin_url: primaryOriginUrl, - origin_scheme: values.origin_scheme, - origin_address: values.origin_address.trim(), - origin_port: values.origin_port.trim(), + origin_scheme: primaryOrigin.scheme, + origin_address: primaryOrigin.address.trim(), + origin_port: primaryOrigin.port.trim(), origin_uri: values.origin_uri.trim(), origin_host: values.origin_host.trim(), - upstreams: parseUpstreamsText( - primaryOriginUrl, - values.upstreams_text, - ).slice(1), + upstreams: values.origin_rows + .slice(1) + .map((row) => buildOriginUrl(row.scheme, row.address, row.port, '')) + .filter(Boolean), enabled: values.enabled, enable_https: values.enable_https, cert_id: @@ -488,18 +644,32 @@ function toFormValues( ); } - const parsedOrigin = parseOriginUrl(route.origin_url); + const primaryOrigin = parseOriginUrl(route.origin_url); + const originRows: OriginRowFormValue[] = [ + { + scheme: primaryOrigin.scheme, + address: primaryOrigin.address, + port: primaryOrigin.port, + }, + ...upstreams.map((upstream) => { + const parsed = parseOriginUrl(upstream); + return { + scheme: parsed.scheme, + address: parsed.address, + port: parsed.port, + }; + }), + ]; return { managed_domain_id: managedDomainMatch.managedDomainId, subdomain_label: managedDomainMatch.subdomainLabel, - origin_id: route.origin_id ? String(route.origin_id) : '', - origin_scheme: parsedOrigin.scheme, - origin_address: parsedOrigin.address, - origin_port: parsedOrigin.port, - origin_uri: parsedOrigin.uri, + origin_rows: + originRows.length > 0 + ? originRows + : [{ scheme: 'https', address: '', port: '443' }], + origin_uri: primaryOrigin.uri, origin_host: route.origin_host || '', - upstreams_text: upstreams.slice(1).join('\n'), enabled: route.enabled, enable_https: route.enable_https, cert_id: route.cert_id ? String(route.cert_id) : '', @@ -520,7 +690,7 @@ function getMatchMessage( enabled: boolean, ) { if (!enabled) { - return '启用 HTTPS 后,系统会根据输入域名尝试自动匹配托管证书。'; + return '若目标域名已绑定证书,HTTPS 会默认开启;你也可以稍后手动开启。'; } if (isMatching) { @@ -528,16 +698,167 @@ function getMatchMessage( } if (!domain.trim()) { - return '输入域名后会自动匹配证书,并优先推荐精确匹配规则。'; + return '输入完整域名后会自动匹配证书,并优先推荐精确匹配规则。'; } if (matchResult?.matched && matchResult.candidate) { - return `已匹配${matchResult.candidate.match_type === 'exact' ? '精确' : '通配符'}规则 ${matchResult.candidate.domain},推荐证书:${matchResult.candidate.certificate_name}`; + return `已匹配${matchResult.candidate.match_type === 'exact' ? '精确' : '通配符'}证书规则 ${matchResult.candidate.domain},默认推荐 ${matchResult.candidate.certificate_name}`; } return '未找到匹配证书,可继续手动选择。'; } +function isLocalOriginAddress(address: string) { + const normalized = address.trim().toLowerCase(); + return ( + normalized === 'localhost' || + normalized.endsWith('.local') || + normalized.endsWith('.internal') || + /^10\./.test(normalized) || + /^192\.168\./.test(normalized) || + /^172\.(1[6-9]|2\d|3[0-1])\./.test(normalized) || + normalized === '::1' || + normalized.startsWith('fc') || + normalized.startsWith('fd') + ); +} + +function ProxyRuleSection({ + title, + icon, + action, + children, +}: { + title: string; + icon: React.ReactNode; + action?: React.ReactNode; + children: React.ReactNode; +}) { + return ( +
+
+
+ + {icon} + +

+ {title} +

+
+ {action} +
+
{children}
+
+ ); +} + +function SearchableManagedDomainField({ + value, + domains, + error, + onSelect, +}: { + value: string; + domains: ManagedDomainItem[]; + error?: string; + onSelect: (value: string) => void; +}) { + const [search, setSearch] = useState(''); + const [isOpen, setIsOpen] = useState(false); + + const selectedDomain = + domains.find((item) => item.domain === value)?.domain ?? value; + const filteredDomains = useMemo(() => { + const keyword = search.trim().toLowerCase(); + if (!keyword) { + return domains; + } + + return domains.filter((item) => + item.domain.toLowerCase().includes(keyword), + ); + }, [domains, search]); + + useEffect(() => { + if (!isOpen) { + setSearch(''); + } + }, [isOpen]); + + return ( + +
+ + + {isOpen ? ( +
+
+ + setSearch(event.target.value)} + className={cn(INPUT_CLASS_NAME, 'pl-9')} + /> +
+
+ {filteredDomains.length > 0 ? ( + filteredDomains.map((domain) => ( + + )) + ) : ( +

+ 未发现匹配域名 +

+ )} +
+
+ ) : null} +
+
+ ); +} + export function ProxyRoutesPage() { const queryClient = useQueryClient(); const [feedback, setFeedback] = useState(null); @@ -546,13 +867,32 @@ export function ProxyRoutesPage() { const [matchResult, setMatchResult] = useState(null); const [isMatching, setIsMatching] = useState(false); + const [isAdvancedOpen, setIsAdvancedOpen] = useState(false); + const [activeOriginRowIndex, setActiveOriginRowIndex] = useState< + number | null + >(null); const form = useForm({ resolver: zodResolver(proxyRouteSchema), defaultValues, }); - const { fields, append, remove, replace } = useFieldArray({ + const { + fields: originFields, + append: appendOrigin, + remove: removeOrigin, + replace: replaceOrigins, + } = useFieldArray({ + control: form.control, + name: 'origin_rows', + }); + + const { + fields: headerFields, + append: appendHeader, + remove: removeHeader, + replace: replaceHeaders, + } = useFieldArray({ control: form.control, name: 'custom_headers', }); @@ -565,17 +905,9 @@ export function ProxyRoutesPage() { control: form.control, name: 'subdomain_label', }); - const watchedOriginAddress = useWatch({ + const watchedOriginRows = useWatch({ control: form.control, - name: 'origin_address', - }); - const watchedOriginScheme = useWatch({ - control: form.control, - name: 'origin_scheme', - }); - const watchedOriginPort = useWatch({ - control: form.control, - name: 'origin_port', + name: 'origin_rows', }); const watchedOriginURI = useWatch({ control: form.control, @@ -620,9 +952,55 @@ export function ProxyRoutesPage() { queryFn: getOrigins, }); + const managedDomains = useMemo( + () => managedDomainsQuery.data ?? [], + [managedDomainsQuery.data], + ); + const origins = useMemo(() => originsQuery.data ?? [], [originsQuery.data]); + const certificates = useMemo( + () => certificatesQuery.data ?? [], + [certificatesQuery.data], + ); + + const selectedManagedDomain = useMemo( + () => + managedDomains.find((item) => item.domain === watchedManagedDomain) ?? + null, + [managedDomains, watchedManagedDomain], + ); + + const selectedManagedDomainValue = + selectedManagedDomain?.domain ?? watchedManagedDomain; + const isWildcardSelection = isWildcardManagedDomain( + selectedManagedDomainValue, + ); + const effectiveDomain = buildRouteDomain( + selectedManagedDomainValue, + watchedSubdomainLabel, + ); + + const primaryOriginRow = + watchedOriginRows?.[0] ?? defaultValues.origin_rows[0]; + const matchedOrigin = useMemo( + () => + origins.find( + (item) => + item.address.toLowerCase() === + primaryOriginRow.address.trim().toLowerCase(), + ) ?? null, + [origins, primaryOriginRow.address], + ); + + const primaryOriginPreview = buildOriginUrl( + primaryOriginRow.scheme, + primaryOriginRow.address, + primaryOriginRow.port, + watchedOriginURI, + ); + const saveMutation = useMutation({ mutationFn: async (values: ProxyRouteFormValues) => { - const payload = toPayload(values); + const payload = toPayload(values, origins); return editingRouteId ? updateProxyRoute(editingRouteId, payload) : createProxyRoute(payload); @@ -635,6 +1013,7 @@ export function ProxyRoutesPage() { setEditingRouteId(null); setIsEditorOpen(false); setMatchResult(null); + setIsAdvancedOpen(false); form.reset(defaultValues); await Promise.all([ queryClient.invalidateQueries({ queryKey: routesQueryKey }), @@ -672,71 +1051,6 @@ export function ProxyRoutesPage() { }, }); - const handlePublish = async () => { - setFeedback(null); - - try { - const diff = await getConfigVersionDiff(); - if (!hasConfigChanges(diff)) { - setFeedback({ - tone: 'info', - message: '当前规则没有变更,已阻止重复发布。', - }); - return; - } - - publishMutation.mutate(); - } catch (error) { - setFeedback({ tone: 'danger', message: getErrorMessage(error) }); - } - }; - - const managedDomains = useMemo( - () => managedDomainsQuery.data ?? [], - [managedDomainsQuery.data], - ); - const origins = useMemo(() => originsQuery.data ?? [], [originsQuery.data]); - - const matchedOrigin = useMemo( - () => - origins.find( - (item) => - item.address.toLowerCase() === - watchedOriginAddress.trim().toLowerCase(), - ) ?? null, - [origins, watchedOriginAddress], - ); - - const selectedManagedDomain = useMemo( - () => - managedDomains.find((item) => item.domain === watchedManagedDomain) ?? - null, - [managedDomains, watchedManagedDomain], - ); - - const selectedManagedDomainValue = - selectedManagedDomain?.domain ?? watchedManagedDomain; - const isWildcardSelection = isWildcardManagedDomain( - selectedManagedDomainValue, - ); - const effectiveDomain = buildRouteDomain( - selectedManagedDomainValue, - watchedSubdomainLabel, - ); - const primaryOriginPreview = buildOriginUrl( - watchedOriginScheme, - watchedOriginAddress, - watchedOriginPort, - watchedOriginURI, - ); - - useEffect(() => { - form.setValue('origin_id', matchedOrigin ? String(matchedOrigin.id) : '', { - shouldDirty: true, - shouldValidate: false, - }); - }, [form, matchedOrigin]); - useEffect(() => { if (!watchedEnableHttps) { setMatchResult(null); @@ -758,6 +1072,7 @@ export function ProxyRoutesPage() { if (cancelled) { return; } + setMatchResult(result); if (result.candidate?.certificate_id && !form.getValues('cert_id')) { form.setValue('cert_id', String(result.candidate.certificate_id), { @@ -781,18 +1096,34 @@ export function ProxyRoutesPage() { cancelled = true; window.clearTimeout(timer); }; - }, [effectiveDomain, watchedEnableHttps, form]); + }, [effectiveDomain, form, watchedEnableHttps]); - const certificates = useMemo( - () => certificatesQuery.data ?? [], - [certificatesQuery.data], - ); + const handlePublish = async () => { + setFeedback(null); + + try { + const diff = await getConfigVersionDiff(); + if (!hasConfigChanges(diff)) { + setFeedback({ + tone: 'info', + message: '当前规则没有变更,已阻止重复发布。', + }); + return; + } + + publishMutation.mutate(); + } catch (error) { + setFeedback({ tone: 'danger', message: getErrorMessage(error) }); + } + }; const handleReset = () => { setFeedback(null); setEditingRouteId(null); setIsEditorOpen(false); setMatchResult(null); + setIsAdvancedOpen(false); + setActiveOriginRowIndex(null); form.reset(defaultValues); }; @@ -800,6 +1131,7 @@ export function ProxyRoutesPage() { setFeedback(null); setEditingRouteId(null); setMatchResult(null); + setIsAdvancedOpen(false); form.reset(defaultValues); setIsEditorOpen(true); }; @@ -815,6 +1147,7 @@ export function ProxyRoutesPage() { setMatchResult(null); try { form.reset(toFormValues(route, managedDomains)); + setIsAdvancedOpen(Boolean(route.origin_host || route.remark)); setIsEditorOpen(true); } catch (error) { setFeedback({ tone: 'danger', message: getErrorMessage(error) }); @@ -831,12 +1164,43 @@ export function ProxyRoutesPage() { }; const handleRemoveHeader = (index: number) => { - if (fields.length === 1) { - replace([{ key: '', value: '' }]); + if (headerFields.length === 1) { + replaceHeaders([{ key: '', value: '' }]); return; } - remove(index); + removeHeader(index); + }; + + const handleManagedDomainSelect = (domainValue: string) => { + const domain = managedDomains.find((item) => item.domain === domainValue); + + form.setValue('managed_domain_id', domainValue, { + shouldDirty: true, + shouldValidate: true, + }); + + if (!isWildcardManagedDomain(domainValue)) { + form.setValue('subdomain_label', '', { + shouldDirty: true, + shouldValidate: true, + }); + } + + const currentCertId = form.getValues('cert_id'); + const autoCertId = domain?.cert_id ? String(domain.cert_id) : ''; + if (autoCertId) { + form.setValue('enable_https', true, { + shouldDirty: true, + shouldValidate: true, + }); + if (!currentCertId) { + form.setValue('cert_id', autoCertId, { + shouldDirty: true, + shouldValidate: true, + }); + } + } }; const routes = routesQuery.data || []; @@ -1001,6 +1365,7 @@ export function ProxyRoutesPage() { )} +
-
- - } + > +
+ { - const nextDomain = event.target.value; + domains={managedDomains} + error={form.formState.errors.managed_domain_id?.message} + onSelect={handleManagedDomainSelect} + /> - form.setValue('managed_domain_id', nextDomain, { - shouldDirty: true, - shouldValidate: true, - }); + {selectedManagedDomain ? ( +
+ {isWildcardSelection ? ( + + + + ) : ( +
+ )} - if (!isWildcardManagedDomain(nextDomain)) { - form.setValue('subdomain_label', '', { + +
+ {effectiveDomain || '请选择目标域名'} +
+
+
+ ) : null} +
+ + + } + action={ + + } + > +
+ {originFields.map((field, index) => { + const currentRow = + watchedOriginRows?.[index] ?? defaultValues.origin_rows[0]; + const keyword = currentRow.address.trim().toLowerCase(); + const suggestions = origins.filter((origin) => { + if (!keyword) { + return false; + } + + return ( + origin.address.toLowerCase().includes(keyword) || + origin.name.toLowerCase().includes(keyword) + ); + }); + + return ( +
+
+
+ {`协议 ${index + 1}`} + { + const nextScheme = + event.target.value as OriginRowFormValue['scheme']; + form.setValue( + `origin_rows.${index}.scheme`, + nextScheme, + { + shouldDirty: true, + shouldValidate: true, + }, + ); + + const currentPort = form.getValues( + `origin_rows.${index}.port`, + ); + if ( + !currentPort || + currentPort === '80' || + currentPort === '443' + ) { + form.setValue( + `origin_rows.${index}.port`, + getDefaultPortForScheme(nextScheme), + { + shouldDirty: true, + shouldValidate: true, + }, + ); + } + }} + > + + + +
+ +
+ {`源站地址 ${index + 1}`} + setActiveOriginRowIndex(index)} + /> + {activeOriginRowIndex === index && keyword ? ( +
+ {suggestions.length > 0 ? ( +
+ {suggestions.map((origin) => ( + + ))} +
+ ) : ( +
+ 未发现匹配资产,请手动输入 +
+ )} +
+ ) : null} +
+ +
+ {`端口 ${index + 1}`} + +
+ + +
+ + {(form.formState.errors.origin_rows?.[index]?.address + ?.message || + form.formState.errors.origin_rows?.[index]?.port + ?.message) && ( +

+ {form.formState.errors.origin_rows?.[index]?.address + ?.message || + form.formState.errors.origin_rows?.[index]?.port + ?.message} +

+ )} +
+ ); + })} + +
+ +
+ {primaryOriginPreview || '填写主源站后显示完整回源地址'} +
+
+ + + + +
+
+
+ + } + > +
+
+ { + form.setValue('enable_https', checked, { shouldDirty: true, shouldValidate: true, }); + + if (checked && !form.getValues('cert_id') && selectedManagedDomain?.cert_id) { + form.setValue('cert_id', String(selectedManagedDomain.cert_id), { + shouldDirty: true, + shouldValidate: true, + }); + } + + if (!checked) { + form.setValue('cert_id', '', { + shouldDirty: true, + shouldValidate: true, + }); + form.setValue('redirect_http', false, { + shouldDirty: true, + shouldValidate: true, + }); + } + }} + /> + + + form.setValue('enabled', checked, { shouldDirty: true }) } - }} - > - - {managedDomains.map((domain) => ( - - ))} - - - - - form.setValue( - 'origin_scheme', - event.target.value as ProxyRouteFormValues['origin_scheme'], - { - shouldDirty: true, - shouldValidate: true, - }, - ) - } - > - - - - -
- -
- - - - {origins.map((origin) => ( - - - - - -
- - {selectedManagedDomain ? ( - isWildcardSelection ? ( -
- - - - -

- {effectiveDomain - ? `当前将生成规则域名 ${effectiveDomain}` - : `请输入二级域名前缀,系统会自动生成 *.${selectedManagedDomain.domain.slice(2)} 下的规则域名。`} -

-
+ />
- ) : ( - -

- {`当前将直接使用 ${selectedManagedDomain.domain} 作为规则域名,无需再填写网站名。`} -

-
- ) - ) : null} -
- - - - -

- {primaryOriginPreview || - '选择协议、输入源站和端口后,这里会显示最终主源站地址。'} -

-
-
- - - - - - - - - -
- - form.setValue('enabled', checked, { shouldDirty: true }) - } - /> - { - form.setValue('enable_https', checked, { - shouldDirty: true, - shouldValidate: true, - }); - if (!checked) { - form.setValue('cert_id', '', { - shouldDirty: true, - shouldValidate: true, - }); - form.setValue('redirect_http', false, { - shouldDirty: true, - shouldValidate: true, - }); - } - }} - /> -
- -
- - - form.setValue('cert_id', event.target.value, { - shouldDirty: true, - shouldValidate: true, - }) - } - > - - {certificates.map((certificate) => ( - - ))} - - - - form.setValue('redirect_http', checked, { - shouldDirty: true, - shouldValidate: true, - }) - } - /> -
- -
- { - form.setValue('cache_enabled', checked, { - shouldDirty: true, - shouldValidate: true, - }); - if (!checked) { - form.setValue('cache_policy', 'url', { - shouldDirty: true, - shouldValidate: true, - }); - form.setValue('cache_rules_text', '', { - shouldDirty: true, - shouldValidate: true, - }); - } - }} - /> - - - form.setValue( - 'cache_policy', - event.target.value as ProxyRouteFormValues['cache_policy'], - { - shouldDirty: true, - shouldValidate: true, - }, - ) - } - > - - - - - - -
- - - - - -
-
-
-

- 自定义请求头 -

-

- 可为空。若填写,请保证 Header 名称合法且不包含换行。 -

-
- append({ key: '', value: '' })} - className="px-3 py-2 text-xs" - > - 添加请求头 - -
- -
- {fields.map((field, index) => ( -
+ {watchedEnableHttps ? ( +
- - - - - -
- handleRemoveHeader(index)} - className="w-full px-3 py-3 text-xs md:w-auto" + + form.setValue('cert_id', event.target.value, { + shouldDirty: true, + shouldValidate: true, + }) + } > - 删除 - -
-
- ))} -
-
+ + {certificates.map((certificate) => ( + + ))} + + + + + form.setValue('redirect_http', checked, { + shouldDirty: true, + shouldValidate: true, + }) + } + /> +
+ ) : null} - -
-

- {getMatchMessage( - matchResult, - isMatching, - effectiveDomain, - watchedEnableHttps, - )} -

{matchResult?.matched && matchResult.candidates.length > 1 ? (
{matchResult.candidates.map((candidate) => ( @@ -1436,24 +1763,194 @@ export function ProxyRoutesPage() { ))}
) : null} -
- 当前可选证书: - {certificatesQuery.isLoading - ? '加载中...' - : `${certificates.length} 张`} -
-
+ - - - +
+ + + {isAdvancedOpen ? ( +
+
+
+ + + + +
+
+
+

+ Custom Request Headers +

+

+ 使用 Key | Value 横向排列,方便快速录入运维透传头。 +

+
+ +
+ +
+ {headerFields.map((field, index) => ( +
+ + + + + {(form.formState.errors.custom_headers?.[index]?.key + ?.message || + form.formState.errors.custom_headers?.[index] + ?.value?.message) && ( +

+ {form.formState.errors.custom_headers?.[index] + ?.key?.message || + form.formState.errors.custom_headers?.[index] + ?.value?.message} +

+ )} +
+ ))} +
+
+
+ +
+ { + form.setValue('cache_enabled', checked, { + shouldDirty: true, + shouldValidate: true, + }); + if (!checked) { + form.setValue('cache_policy', 'url', { + shouldDirty: true, + shouldValidate: true, + }); + form.setValue('cache_rules_text', '', { + shouldDirty: true, + shouldValidate: true, + }); + } + }} + /> + + + + form.setValue( + 'cache_policy', + event.target + .value as ProxyRouteFormValues['cache_policy'], + { + shouldDirty: true, + shouldValidate: true, + }, + ) + } + > + + + + + + + + + + +
+
+ + + + +
+ ) : 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 ca2757db..40ecab29 100644 --- a/openflare_server/web/tests/unit/proxy-routes-page.test.tsx +++ b/openflare_server/web/tests/unit/proxy-routes-page.test.tsx @@ -40,78 +40,117 @@ describe('ProxyRoutesPage', () => { ); } + function buildBaseFetchStub({ + managedDomains, + origins = [], + certificates = [{ id: 1, name: 'example-cert', not_after: null }], + matchResult, + }: { + managedDomains: Array>; + origins?: Array>; + certificates?: Array>; + matchResult?: Record; + }) { + return vi.fn((input: RequestInfo | URL) => { + const url = String(input); + + if (url.includes('/proxy-routes/')) { + return Promise.resolve( + new Response(JSON.stringify({ success: true, message: '', data: [] })), + ); + } + + if (url.includes('/managed-domains/match?')) { + return Promise.resolve( + new Response( + JSON.stringify({ + success: true, + message: '', + data: + matchResult ?? + { + domain: 'a.example.com', + matched: true, + candidate: { + managed_domain_id: 2, + domain: 'a.example.com', + match_type: 'exact', + certificate_id: 1, + certificate_name: 'example-cert', + }, + candidates: [], + }, + }), + ), + ); + } + + if (url.includes('/managed-domains/')) { + return Promise.resolve( + new Response( + JSON.stringify({ + success: true, + message: '', + data: managedDomains, + }), + ), + ); + } + + if (url.includes('/origins/')) { + return Promise.resolve( + new Response( + JSON.stringify({ + success: true, + message: '', + data: origins, + }), + ), + ); + } + + if (url.includes('/tls-certificates/')) { + return Promise.resolve( + new Response( + JSON.stringify({ + success: true, + message: '', + data: certificates, + }), + ), + ); + } + + return Promise.reject(new Error(`Unhandled fetch: ${url}`)); + }); + } + it('shows wildcard subdomain input after selecting a wildcard website', async () => { stubMatchMedia(); vi.stubGlobal( 'fetch', - vi.fn((input: RequestInfo | URL) => { - const url = String(input); - - if (url.includes('/proxy-routes/')) { - return Promise.resolve( - new Response( - JSON.stringify({ success: true, message: '', data: [] }), - ), - ); - } - - if (url.includes('/managed-domains/')) { - return Promise.resolve( - new Response( - JSON.stringify({ - success: true, - message: '', - data: [ - { - 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: 'a.example.com', - cert_id: 1, - enabled: true, - remark: '', - created_at: '2026-03-20T08:00:00Z', - updated_at: '2026-03-20T08:00:00Z', - }, - ], - }), - ), - ); - } - - if (url.includes('/origins/')) { - return Promise.resolve( - new Response( - JSON.stringify({ - success: true, - message: '', - data: [], - }), - ), - ); - } - - if (url.includes('/tls-certificates/')) { - return Promise.resolve( - new Response( - JSON.stringify({ - success: true, - message: '', - data: [{ id: 1, name: 'example-cert', not_after: null }], - }), - ), - ); - } - - return Promise.reject(new Error(`Unhandled fetch: ${url}`)); + 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: 'a.example.com', + cert_id: 1, + enabled: true, + remark: '', + created_at: '2026-03-20T08:00:00Z', + updated_at: '2026-03-20T08:00:00Z', + }, + ], }), ); @@ -121,23 +160,17 @@ describe('ProxyRoutesPage', () => { await user.click(await screen.findByRole('button', { name: '新增规则' })); const dialog = await screen.findByRole('dialog', { name: '新增规则' }); - await user.selectOptions( - within(dialog).getAllByRole('combobox')[0], - '*.example.com', + await user.click( + within(dialog).getByRole('button', { name: /Select Target Domain/ }), ); + await user.click(await screen.findByText('*.example.com')); - expect(await screen.findByPlaceholderText('ai')).toBeInTheDocument(); - expect( - screen.getByText( - '当前网站为通配符 *.example.com,这里只需填写前缀,例如 ai。', - ), - ).toBeInTheDocument(); + expect(await screen.findByPlaceholderText('e.g. ai')).toBeInTheDocument(); + expect(screen.getByText('*.example.com')).toBeInTheDocument(); - await user.type(screen.getByPlaceholderText('ai'), 'ai'); + await user.type(screen.getByPlaceholderText('e.g. ai'), 'ai'); - expect( - screen.getByText('当前将生成规则域名 ai.example.com'), - ).toBeInTheDocument(); + expect(screen.getByText('ai.example.com')).toBeInTheDocument(); }); it('uses exact website directly without showing subdomain input', async () => { @@ -145,64 +178,18 @@ describe('ProxyRoutesPage', () => { vi.stubGlobal( 'fetch', - vi.fn((input: RequestInfo | URL) => { - const url = String(input); - - if (url.includes('/proxy-routes/')) { - return Promise.resolve( - new Response( - JSON.stringify({ success: true, message: '', data: [] }), - ), - ); - } - - if (url.includes('/managed-domains/')) { - return Promise.resolve( - new Response( - JSON.stringify({ - success: true, - message: '', - data: [ - { - id: 2, - domain: 'a.example.com', - cert_id: 1, - enabled: true, - remark: '', - created_at: '2026-03-20T08:00:00Z', - updated_at: '2026-03-20T08:00:00Z', - }, - ], - }), - ), - ); - } - - if (url.includes('/origins/')) { - return Promise.resolve( - new Response( - JSON.stringify({ - success: true, - message: '', - data: [], - }), - ), - ); - } - - if (url.includes('/tls-certificates/')) { - return Promise.resolve( - new Response( - JSON.stringify({ - success: true, - message: '', - data: [{ id: 1, name: 'example-cert', not_after: null }], - }), - ), - ); - } - - return Promise.reject(new Error(`Unhandled fetch: ${url}`)); + buildBaseFetchStub({ + managedDomains: [ + { + id: 2, + domain: 'a.example.com', + cert_id: 1, + enabled: true, + remark: '', + created_at: '2026-03-20T08:00:00Z', + updated_at: '2026-03-20T08:00:00Z', + }, + ], }), ); @@ -212,16 +199,71 @@ describe('ProxyRoutesPage', () => { await user.click(await screen.findByRole('button', { name: '新增规则' })); const dialog = await screen.findByRole('dialog', { name: '新增规则' }); - await user.selectOptions( - within(dialog).getAllByRole('combobox')[0], - 'a.example.com', + await user.click( + within(dialog).getByRole('button', { name: /Select Target Domain/ }), + ); + await user.click(await screen.findByText('a.example.com')); + + expect(screen.queryByPlaceholderText('e.g. ai')).not.toBeInTheDocument(); + expect((await screen.findAllByText('a.example.com')).length).toBeGreaterThan(0); + expect(await screen.findByText('Select Certificate')).toBeInTheDocument(); + }); + + it('shows origin autocomplete suggestions and empty state', 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', + }, + ], + origins: [ + { + id: 1, + name: 'Main Backend', + address: '192.168.1.45', + remark: '', + route_count: 1, + created_at: '2026-03-20T08:00:00Z', + updated_at: '2026-03-20T08:00:00Z', + }, + ], + }), ); - expect(screen.queryByPlaceholderText('ai')).not.toBeInTheDocument(); + renderProxyRoutesPage(); + + 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 addressInput = screen.getByPlaceholderText('192.168.1.45'); + await user.click(addressInput); + await user.type(addressInput, '192.'); + + expect(await screen.findByText('192.168.1.45')).toBeInTheDocument(); + expect(await screen.findByText('(Main Backend)')).toBeInTheDocument(); + expect(await screen.findByText('Local')).toBeInTheDocument(); + + await user.clear(addressInput); + await user.type(addressInput, '10.10.'); + expect( - await screen.findByText( - '当前将直接使用 a.example.com 作为规则域名,无需再填写网站名。', - ), + await screen.findByText('未发现匹配资产,请手动输入'), ).toBeInTheDocument(); }); });