'use client'; 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'; import { useFieldArray, useForm, useWatch } from 'react-hook-form'; import { z } from 'zod'; import { Separator } from "@/components/ui/separator" import { EmptyState } from '@/components/feedback/empty-state'; 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 { AppCard } from '@/components/ui/app-card'; import { Input } from '@/components/ui/input'; import { Label } from '@/components/ui/label'; import { AppModal } from '@/components/ui/app-modal'; import { StatusBadge } from '@/components/ui/status-badge'; import { Switch } from '@/components/ui/switch'; import { Textarea } from '@/components/ui/textarea'; import { getConfigVersionDiff, publishConfigVersion, } from '@/features/config-versions/api/config-versions'; import { getManagedDomains } from '@/features/managed-domains/api/managed-domains'; import type { ManagedDomainItem } from '@/features/managed-domains/types'; import { getOrigins } from '@/features/origins/api/origins'; import type { OriginItem } from '@/features/origins/types'; import { createProxyRoute, deleteProxyRoute, getProxyRoutes, getTlsCertificates, matchManagedDomainCertificate, updateProxyRoute, } from '@/features/proxy-routes/api/proxy-routes'; import type { ManagedDomainMatchResult, ProxyRouteCustomHeader, ProxyRouteItem, ProxyRouteMutationPayload, TlsCertificateItem, } from '@/features/proxy-routes/types'; import { buildRouteDomain, findManagedDomainForRoute, isWildcardManagedDomain, } from '@/features/proxy-routes/utils'; import { DangerButton, PrimaryButton, ResourceField, SecondaryButton, ToggleField, } from '@/features/shared/components/resource-primitives'; import { cn } from '@/lib/utils/cn'; import { formatDateTime } from '@/lib/utils/date'; const customHeaderSchema = z.object({ key: z.string(), value: z.string(), }); const originProtocolValues = ['http', 'https'] as const; const cachePolicyValues = [ 'url', 'suffix', 'path_prefix', 'path_exact', ] 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, '请选择目标域名'), subdomain_label: z.string(), origin_rows: z.array(originRowSchema).min(1), origin_uri: z.string(), origin_host: z .string() .trim() .refine( (value) => !value || (!/[\/\\\s]/.test(value) && !value.includes('://') && (() => { try { const parsed = new URL(`http://${value}`); return parsed.host === value && Boolean(parsed.hostname); } catch { return false; } })()), '请输入合法的回源主机名', ), enabled: z.boolean(), enable_https: z.boolean(), cert_id: z.string(), redirect_http: z.boolean(), cache_enabled: z.boolean(), cache_policy: z.enum(cachePolicyValues), cache_rules_text: z.string(), custom_headers: z.array(customHeaderSchema).min(1), remark: z.string().max(255, '备注不能超过 255 个字符'), }) .superRefine((value, context) => { const selectedManagedDomain = value.managed_domain_id.trim(); const subdomainLabel = value.subdomain_label.trim(); const isWildcard = selectedManagedDomain.startsWith('*.'); if (isWildcard && !subdomainLabel) { context.addIssue({ code: z.ZodIssueCode.custom, path: ['subdomain_label'], message: '请输入子域名前缀', }); } if ( isWildcard && subdomainLabel && !/^[A-Za-z0-9](?:[A-Za-z0-9-]{0,61}[A-Za-z0-9])?$/.test(subdomainLabel) ) { context.addIssue({ code: z.ZodIssueCode.custom, path: ['subdomain_label'], message: '子域名前缀仅支持单个标签,且只能包含字母、数字和中划线', }); } if (value.enable_https && !value.cert_id) { context.addIssue({ code: z.ZodIssueCode.custom, path: ['cert_id'], message: '启用 HTTPS 时必须选择证书', }); } const normalizedOriginURI = value.origin_uri.trim(); if ( normalizedOriginURI && !normalizedOriginURI.startsWith('/') && !normalizedOriginURI.startsWith('?') ) { context.addIssue({ code: z.ZodIssueCode.custom, path: ['origin_uri'], message: '回源路径需以 / 或 ? 开头', }); } value.origin_rows.forEach((row, index) => { const normalizedAddress = row.address.trim(); const normalizedPort = row.port.trim(); 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); if (value.cache_policy !== 'url' && cacheRules.length === 0) { context.addIssue({ code: z.ZodIssueCode.custom, path: ['cache_rules_text'], message: '当前缓存策略至少需要填写一条规则', }); } } value.custom_headers.forEach((header, index) => { const key = header.key.trim(); const headerValue = header.value.trim(); if (!key && !headerValue) { return; } if (!key) { context.addIssue({ code: z.ZodIssueCode.custom, path: ['custom_headers', index, 'key'], message: '请求头名称不能为空', }); } if (key && !/^[A-Za-z0-9_-]+$/.test(key)) { context.addIssue({ code: z.ZodIssueCode.custom, path: ['custom_headers', index, 'key'], message: '请求头名称仅支持字母、数字、下划线和中划线', }); } if (/\r|\n/.test(key) || /\r|\n/.test(headerValue)) { context.addIssue({ code: z.ZodIssueCode.custom, path: ['custom_headers', index, 'value'], message: '请求头不能包含换行', }); } }); }); 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 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: '', subdomain_label: '', origin_rows: [{ scheme: 'https', address: '', port: '443' }], origin_uri: '', origin_host: '', enabled: true, enable_https: false, cert_id: '', redirect_http: false, cache_enabled: false, cache_policy: 'url', cache_rules_text: '', custom_headers: [{ key: '', value: '' }], remark: '', }; const routesQueryKey = ['proxy-routes']; const certificatesQueryKey = ['tls-certificates']; 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[]; removed_domains: string[]; modified_domains: string[]; main_config_changed: boolean; changed_option_keys: string[]; }) { return ( diff.added_domains.length > 0 || diff.removed_domains.length > 0 || diff.modified_domains.length > 0 || diff.main_config_changed || diff.changed_option_keys.length > 0 || !diff.active_version ); } function getErrorMessage(error: unknown) { return error instanceof Error ? error.message : '请求失败,请稍后重试。'; } function parseCustomHeaders(rawValue: string) { if (!rawValue) { return [] as ProxyRouteCustomHeader[]; } try { const parsed = JSON.parse(rawValue) as ProxyRouteCustomHeader[]; return Array.isArray(parsed) ? parsed : []; } catch { return []; } } function parseCacheRules(rawValue: string) { if (!rawValue) { return [] as string[]; } try { const parsed = JSON.parse(rawValue) as string[]; return Array.isArray(parsed) ? parsed.filter(Boolean) : []; } catch { return []; } } function parseCacheRulesText(value: string) { return value .split(/\r?\n/) .map((item) => item.trim()) .filter(Boolean); } function parseUpstreams(rawValue: string) { if (!rawValue) { return [] as string[]; } try { const parsed = JSON.parse(rawValue) as string[]; return Array.isArray(parsed) ? parsed.filter(Boolean) : []; } catch { return []; } } function buildCachePolicyLabel(policy: string) { switch (policy) { case 'suffix': return '按后缀'; case 'path_prefix': return '按前缀'; case 'path_exact': return '按路径'; default: return '按 URL'; } } function getCacheRulesHint(policy: string) { switch (policy) { case 'suffix': return '每行一个后缀,例如:jpg、css、js。'; case 'path_prefix': return '每行一个路径前缀,例如:/assets、/static/images。'; case 'path_exact': return '每行一个精确路径,例如:/robots.txt、/manifest.json。'; default: return '按 URL 缓存时无需额外规则,系统会按请求 URL 粒度缓存。'; } } function buildCertificateLabel(certificate: TlsCertificateItem) { return certificate.not_after ? `${certificate.name}(到期:${formatDateTime(certificate.not_after)})` : certificate.name; } function buildOriginUrl( scheme: 'http' | 'https', address: string, port: string, uri: string, ) { const normalizedAddress = address.trim(); const normalizedPort = port.trim(); if (!normalizedAddress || !normalizedPort) { return ''; } const host = normalizedAddress.includes(':') && !normalizedAddress.startsWith('[') ? `[${normalizedAddress}]` : normalizedAddress; const normalizedURI = uri.trim(); return `${scheme}://${host}:${normalizedPort}${normalizedURI}`; } function parseOriginUrl(rawValue: string) { try { const parsed = new URL(rawValue); const uri = parsed.pathname === '/' ? '' : parsed.pathname; return { scheme: (parsed.protocol.replace(':', '') || 'https') as 'http' | 'https', address: parsed.hostname, port: parsed.port || (parsed.protocol === 'http:' ? '80' : '443'), uri: parsed.search ? `${uri}${parsed.search}` || parsed.search : uri, }; } catch { return { scheme: 'https' as const, address: '', port: '443', uri: '', }; } } 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( 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: primaryOriginRecord ? primaryOriginRecord.id : null, origin_url: primaryOriginUrl, 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: 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: values.enable_https && values.cert_id ? Number(values.cert_id) : null, redirect_http: values.enable_https ? values.redirect_http : false, cache_enabled: values.cache_enabled, cache_policy: values.cache_enabled ? values.cache_policy : 'url', cache_rules: values.cache_enabled ? parseCacheRulesText(values.cache_rules_text) : [], custom_headers: values.custom_headers .map((item) => ({ key: item.key.trim(), value: item.value.trim() })) .filter((item) => item.key || item.value), remark: values.remark.trim(), }; } function toFormValues( route: ProxyRouteItem, managedDomains: ManagedDomainItem[], ): ProxyRouteFormValues { const headers = parseCustomHeaders(route.custom_headers); const cacheRules = parseCacheRules(route.cache_rules); const upstreams = parseUpstreams(route.upstreams); const managedDomainMatch = findManagedDomainForRoute( route.domain, managedDomains, ); if (!managedDomainMatch) { throw new Error( `规则 ${route.domain} 未匹配到网站,请先补充对应网站后再编辑。`, ); } const primaryOrigin = parseOriginUrl(route.origin_url); 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, subdomain_label: managedDomainMatch.subdomainLabel, origin_rows: originRows.length > 0 ? originRows : [{ scheme: 'https', address: '', port: '443' }], origin_uri: primaryOrigin.uri, origin_host: route.origin_host || '', enabled: route.enabled, enable_https: route.enable_https, cert_id: route.cert_id ? String(route.cert_id) : '', redirect_http: route.redirect_http, cache_enabled: route.cache_enabled, cache_policy: (route.cache_policy || 'url') as ProxyRouteFormValues['cache_policy'], cache_rules_text: cacheRules.join('\n'), custom_headers: headers.length > 0 ? headers : [{ key: '', value: '' }], remark: route.remark || '', }; } function getMatchMessage( matchResult: ManagedDomainMatchResult | null, isMatching: boolean, domain: string, enabled: boolean, ) { if (!enabled) { return '若目标域名已绑定证书,HTTPS 会默认开启;你也可以稍后手动开启。'; } if (isMatching) { return '正在按域名自动匹配托管证书...'; } if (!domain.trim()) { return '输入完整域名后会自动匹配证书,并优先推荐精确匹配规则。'; } if (matchResult?.matched && matchResult.candidate) { 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}
); } 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, }: { label: string; hint?: string; error?: string; showFieldMeta?: boolean; value: string; placeholder: string; emptyText: string; options: SearchOption[]; onValueChange: (value: string) => void; onSelect: (option: SearchOption) => void; }) { const [isOpen, setIsOpen] = useState(false); const [highlightedIndex, setHighlightedIndex] = useState(0); const containerRef = useRef(null); const filteredOptions = useMemo(() => { const keyword = value.trim().toLowerCase(); if (!keyword) { return options; } return options.filter((option) => { const haystacks = [option.label, ...(option.keywords ?? [])]; return haystacks.some((item) => item.toLowerCase().includes(keyword)); }); }, [options, value]); const highlightedOption = filteredOptions[ Math.min(highlightedIndex, Math.max(filteredOptions.length - 1, 0)) ] ?? null; useEffect(() => { setHighlightedIndex(0); }, [value]); useEffect(() => { if (highlightedIndex > filteredOptions.length - 1) { setHighlightedIndex(0); } }, [filteredOptions.length, highlightedIndex]); useEffect(() => { if (!isOpen) { 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]); 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 ( ({ 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); }} /> ); } 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}) ) : 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 (