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 e2326609..f0f7feef 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 @@ -1,5 +1,7 @@ 'use client'; +import { Input, Textarea } from '@heroui/input'; +import { Select, SelectItem, SelectSection } from '@heroui/select'; import { zodResolver } from '@hookform/resolvers/zod'; import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'; import { useEffect, useMemo, useRef, useState } from 'react'; @@ -46,9 +48,6 @@ import { DangerButton, PrimaryButton, ResourceField, - ResourceInput, - ResourceSelect, - ResourceTextarea, SecondaryButton, ToggleField, } from '@/features/shared/components/resource-primitives'; @@ -275,6 +274,31 @@ type SectionIconProps = { 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 HERO_INPUT_CLASS_NAMES = { + base: 'w-full', + mainWrapper: 'w-full', + inputWrapper: + 'min-h-10 rounded-xl border border-[var(--border-default)] shadow-none', + input: 'text-sm', +} as const; +const HERO_TEXTAREA_CLASS_NAMES = { + base: 'w-full', + mainWrapper: 'w-full', + inputWrapper: + 'min-h-28 rounded-xl border border-[var(--border-default)] shadow-none', + input: 'text-sm', +} as const; +const HERO_SELECT_CLASS_NAMES = { + base: 'w-full', + trigger: cn( + INPUT_CLASS_NAME, + 'min-h-10 rounded-xl border border-[var(--border-default)] bg-[var(--surface-elevated)] px-3 shadow-none data-[hover=true]:bg-[var(--surface-elevated)] data-[open=true]:border-[var(--border-strong)]', + ), + value: 'text-sm text-[var(--foreground-primary)]', + popoverContent: + 'rounded-2xl border border-[var(--border-default)] bg-[var(--surface-panel)] shadow-[var(--shadow-soft)]', + listboxWrapper: 'p-1', +} as const; const defaultValues: ProxyRouteFormValues = { managed_domain_id: '', @@ -645,21 +669,17 @@ function toFormValues( } 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, - }; - }), - ]; + const normalizedOrigins = Array.from( + new Set([route.origin_url, ...upstreams].filter(Boolean)), + ); + const originRows: OriginRowFormValue[] = normalizedOrigins.map((origin) => { + const parsed = parseOriginUrl(origin); + return { + scheme: parsed.scheme, + address: parsed.address, + port: parsed.port, + }; + }); return { managed_domain_id: managedDomainMatch.managedDomainId, @@ -752,58 +772,68 @@ function ProxyRuleSection({ ); } -function SearchableManagedDomainField({ - value, - domains, +type SearchOption = { + key: string; + label: string; + keywords?: string[]; + selected?: boolean; + content?: React.ReactNode; + trailing?: React.ReactNode; +}; + +function SearchableSelectInput({ + label, + hint, error, + showFieldMeta = true, + value, + placeholder, + emptyText, + options, + onValueChange, onSelect, }: { - value: string; - domains: ManagedDomainItem[]; + label: string; + hint?: string; error?: string; - onSelect: (value: string) => void; + showFieldMeta?: boolean; + value: string; + placeholder: string; + emptyText: string; + options: SearchOption[]; + onValueChange: (value: string) => void; + onSelect: (option: SearchOption) => void; }) { - 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 = inputValue.trim().toLowerCase(); + const filteredOptions = useMemo(() => { + const keyword = value.trim().toLowerCase(); if (!keyword) { - return domains; + return options; } - return domains.filter((item) => - item.domain.toLowerCase().includes(keyword), - ); - }, [domains, inputValue]); - const highlightedDomain = - filteredDomains[Math.min(highlightedIndex, Math.max(filteredDomains.length - 1, 0))] ?? - null; + return options.filter((option) => { + const haystacks = [option.label, ...(option.keywords ?? [])]; + return haystacks.some((item) => item.toLowerCase().includes(keyword)); + }); + }, [options, value]); - useEffect(() => { - if (value) { - setInputValue(value); - return; - } - - if (!isOpen) { - setInputValue(''); - } - }, [isOpen, value]); + const highlightedOption = + filteredOptions[ + Math.min(highlightedIndex, Math.max(filteredOptions.length - 1, 0)) + ] ?? null; useEffect(() => { setHighlightedIndex(0); - }, [inputValue]); + }, [value]); useEffect(() => { - if (highlightedIndex > filteredDomains.length - 1) { + if (highlightedIndex > filteredOptions.length - 1) { setHighlightedIndex(0); } - }, [filteredDomains.length, highlightedIndex]); + }, [filteredOptions.length, highlightedIndex]); useEffect(() => { if (!isOpen) { @@ -822,126 +852,302 @@ function SearchableManagedDomainField({ }; }, [isOpen]); + const content = ( +
+
+ + setIsOpen(true)} + onChange={(event) => { + onValueChange(event.target.value); + setIsOpen(true); + }} + onKeyDown={(event) => { + if (event.key === 'ArrowDown' && filteredOptions.length > 0) { + event.preventDefault(); + setIsOpen(true); + setHighlightedIndex((current) => + current >= filteredOptions.length - 1 ? 0 : current + 1, + ); + return; + } + + if (event.key === 'ArrowUp' && filteredOptions.length > 0) { + event.preventDefault(); + setIsOpen(true); + setHighlightedIndex((current) => + current <= 0 ? filteredOptions.length - 1 : current - 1, + ); + return; + } + + if (event.key === 'Enter' && highlightedOption) { + event.preventDefault(); + onSelect(highlightedOption); + setIsOpen(false); + setHighlightedIndex(0); + return; + } + + if (event.key === 'Escape') { + setIsOpen(false); + } + }} + /> + +
+ + {isOpen ? ( +
+
+ + {highlightedOption + ? `按 Enter 选择 ${highlightedOption.label}` + : '继续输入以匹配候选项'} + + {`${filteredOptions.length} 个结果`} +
+
+ {filteredOptions.length > 0 ? ( + filteredOptions.map((option, index) => ( + + )) + ) : ( +

+ {emptyText} +

+ )} +
+
+ ) : null} +
+ ); + + if (!showFieldMeta) { + return content; + } + return ( - + {content} + + ); +} + +function SearchableManagedDomainField({ + value, + domains, + error, + onSelect, +}: { + value: string; + domains: ManagedDomainItem[]; + error?: string; + onSelect: (value: string) => void; +}) { + const [inputValue, setInputValue] = useState(''); + + useEffect(() => { + if (value) { + setInputValue(value); + return; + } + + setInputValue(''); + }, [value]); + + return ( + -
-
- - setIsOpen(true)} - onChange={(event) => { - setInputValue(event.target.value); - setIsOpen(true); - }} - onKeyDown={(event) => { - if (event.key === 'ArrowDown' && filteredDomains.length > 0) { - event.preventDefault(); - setIsOpen(true); - setHighlightedIndex((current) => - current >= filteredDomains.length - 1 ? 0 : current + 1, - ); - return; - } + value={inputValue} + placeholder="输入并搜索目标域名" + emptyText="未发现匹配域名" + options={domains.map((domain) => ({ + key: String(domain.id), + label: domain.domain, + keywords: [domain.domain], + selected: value === domain.domain, + trailing: domain.cert_id ? ( + + TLS + + ) : null, + }))} + onValueChange={setInputValue} + onSelect={(option) => { + onSelect(option.label); + setInputValue(option.label); + }} + /> + ); +} - if (event.key === 'ArrowUp' && filteredDomains.length > 0) { - event.preventDefault(); - setIsOpen(true); - setHighlightedIndex((current) => - current <= 0 ? filteredDomains.length - 1 : current - 1, - ); - return; - } - - if (event.key === 'Enter' && highlightedDomain) { - event.preventDefault(); - onSelect(highlightedDomain.domain); - setInputValue(highlightedDomain.domain); - setIsOpen(false); - setHighlightedIndex(0); - return; - } - - if (event.key === 'Escape') { - setIsOpen(false); - } - }} - /> - -
- - {isOpen ? ( -
-
- - {highlightedDomain - ? `按 Enter 选择 ${highlightedDomain.domain}` - : '继续输入以匹配目标域名'} +function SearchableOriginAddressField({ + value, + origins, + onValueChange, +}: { + value: string; + origins: OriginItem[]; + onValueChange: (value: string) => void; +}) { + return ( + ({ + key: String(origin.id), + label: origin.address, + keywords: [origin.address, origin.name], + selected: value.trim().toLowerCase() === origin.address.toLowerCase(), + content: ( + + {origin.address} + {origin.name ? ( + + ({origin.name}) - {`${filteredDomains.length} 个结果`} -
-
- {filteredDomains.length > 0 ? ( - filteredDomains.map((domain, index) => ( - - )) - ) : ( -

- 未发现匹配域名 -

- )} -
-
- ) : null} -
- + ) : null} + + ), + trailing: isLocalOriginAddress(origin.address) ? ( + + Local + + ) : null, + }))} + onValueChange={onValueChange} + onSelect={(option) => onValueChange(option.label)} + /> + ); +} + +function HeroTextInput( + props: React.ComponentProps & { className?: string }, +) { + const { className, ...restProps } = props; + + return ( + + ); +} + +function HeroTextArea( + props: React.ComponentProps & { className?: string }, +) { + const { className, ...restProps } = props; + + return ( +