'use client'; import Link from 'next/link'; import { zodResolver } from '@hookform/resolvers/zod'; import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'; import { useEffect, useMemo, useState } from 'react'; import type { ReactNode } from 'react'; import { useForm } from 'react-hook-form'; import { z } from 'zod'; 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 { StatusBadge } from '@/components/ui/status-badge'; import { getTlsCertificates } from '@/features/tls-certificates/api/tls-certificates'; import type { TlsCertificateItem } from '@/features/tls-certificates/types'; import { getProxyRoute, updateProxyRoute, } from '@/features/proxy-routes/api/proxy-routes'; import { buildPayloadFromRoute, customHeadersToText, getErrorMessage, getWebsiteConfigSection, getWebsiteStatusBadges, linesFromTextarea, normalizeLimitRate, parseCustomHeadersText, parseOriginUrl, parseOriginUrls, validateCacheRules, validateDomains, validateLimitRate, validateOriginHost, websiteConfigSections, type WebsiteConfigSectionKey, } from '@/features/proxy-routes/helpers'; import type { ProxyRouteItem, ProxyRouteMutationPayload, } from '@/features/proxy-routes/types'; import { PrimaryButton, ResourceField, ResourceInput, ResourceSelect, ResourceTextarea, SecondaryButton, ToggleField, } from '@/features/shared/components/resource-primitives'; import { cn } from '@/lib/utils/cn'; import { formatDateTime } from '@/lib/utils/date'; type FeedbackState = { tone: 'success' | 'danger'; message: string; }; type SaveContext = { message: string; }; type SaveHandler = ( payload: ProxyRouteMutationPayload, context: SaveContext, ) => void; const domainSettingsSchema = z .object({ site_name: z.string().trim().min(1, '请输入站点标识').max(255, '站点标识不能超过 255 个字符'), domains_text: z.string().trim().min(1, '请至少填写一个域名'), enabled: z.boolean(), }) .superRefine((value, context) => { const domains = linesFromTextarea(value.domains_text).map((item) => item.toLowerCase(), ); const error = validateDomains(domains); if (error) { context.addIssue({ code: z.ZodIssueCode.custom, path: ['domains_text'], message: error, }); } }); const rateLimitSchema = z .object({ limit_conn_per_server: z.string(), limit_conn_per_ip: z.string(), limit_rate: z.string(), }) .superRefine((value, context) => { for (const field of ['limit_conn_per_server', 'limit_conn_per_ip'] as const) { const rawValue = value[field].trim(); if (!rawValue) { continue; } if (!/^\d+$/.test(rawValue)) { context.addIssue({ code: z.ZodIssueCode.custom, path: [field], message: '请输入大于等于 0 的整数', }); } } const limitRateError = validateLimitRate(value.limit_rate); if (limitRateError) { context.addIssue({ code: z.ZodIssueCode.custom, path: ['limit_rate'], message: limitRateError, }); } }); const reverseProxySchema = z .object({ origin_urls_text: z.string().trim().min(1, '请至少填写一个上游地址'), origin_host: z.string(), custom_headers_text: z.string(), remark: z.string().max(255, '备注不能超过 255 个字符'), }) .superRefine((value, context) => { const { error } = parseOriginUrls(value.origin_urls_text); if (error) { context.addIssue({ code: z.ZodIssueCode.custom, path: ['origin_urls_text'], message: error, }); } const originHostError = validateOriginHost(value.origin_host); if (originHostError) { context.addIssue({ code: z.ZodIssueCode.custom, path: ['origin_host'], message: originHostError, }); } const { error: headerError } = parseCustomHeadersText( value.custom_headers_text, ); if (headerError) { context.addIssue({ code: z.ZodIssueCode.custom, path: ['custom_headers_text'], message: headerError, }); } }); const httpsSchema = z .object({ enable_https: z.boolean(), cert_id: z.string(), redirect_http: z.boolean(), }) .superRefine((value, context) => { if (value.enable_https && !value.cert_id.trim()) { context.addIssue({ code: z.ZodIssueCode.custom, path: ['cert_id'], message: '启用 HTTPS 时必须选择证书', }); } }); const cacheSchema = z .object({ cache_enabled: z.boolean(), cache_policy: z.enum(['url', 'suffix', 'path_prefix', 'path_exact']), cache_rules_text: z.string(), }) .superRefine((value, context) => { if (!value.cache_enabled) { return; } const rules = linesFromTextarea(value.cache_rules_text); const error = validateCacheRules(value.cache_policy, rules); if (error) { context.addIssue({ code: z.ZodIssueCode.custom, path: ['cache_rules_text'], message: error, }); } }); type DomainSettingsValues = z.infer; type RateLimitValues = z.infer; type ReverseProxyValues = z.infer; type HTTPSValues = z.infer; type CacheValues = z.infer; function ConfigSectionShell({ title, description, formId, saving, children, }: { title: string; description: string; formId: string; saving: boolean; children: ReactNode; }) { return ( {saving ? '保存中...' : '保存'} } > {children} ); } function DomainSettingsSection({ route, saving, onSave, }: { route: ProxyRouteItem; saving: boolean; onSave: SaveHandler; }) { const form = useForm({ resolver: zodResolver(domainSettingsSchema), defaultValues: { site_name: route.site_name, domains_text: route.domains.join('\n'), enabled: route.enabled, }, }); useEffect(() => { form.reset({ site_name: route.site_name, domains_text: route.domains.join('\n'), enabled: route.enabled, }); }, [form, route]); return (
{ const domains = linesFromTextarea(values.domains_text).map((item) => item.toLowerCase(), ); onSave( buildPayloadFromRoute(route, { site_name: values.site_name.trim(), domain: domains[0], domains, enabled: values.enabled, }), { message: '域名设置已保存。' }, ); })} > form.setValue('enabled', checked, { shouldDirty: true }) } />
); } function RateLimitSection({ route, saving, onSave, }: { route: ProxyRouteItem; saving: boolean; onSave: SaveHandler; }) { const form = useForm({ resolver: zodResolver(rateLimitSchema), defaultValues: { limit_conn_per_server: route.limit_conn_per_server ? String(route.limit_conn_per_server) : '', limit_conn_per_ip: route.limit_conn_per_ip ? String(route.limit_conn_per_ip) : '', limit_rate: route.limit_rate || '', }, }); useEffect(() => { form.reset({ limit_conn_per_server: route.limit_conn_per_server ? String(route.limit_conn_per_server) : '', limit_conn_per_ip: route.limit_conn_per_ip ? String(route.limit_conn_per_ip) : '', limit_rate: route.limit_rate || '', }); }, [form, route]); return (
{ onSave( buildPayloadFromRoute(route, { limit_conn_per_server: Number( values.limit_conn_per_server.trim() || '0', ), limit_conn_per_ip: Number(values.limit_conn_per_ip.trim() || '0'), limit_rate: normalizeLimitRate(values.limit_rate), }), { message: '流量限制已保存。' }, ); })} >
); } function ReverseProxySection({ route, saving, onSave, }: { route: ProxyRouteItem; saving: boolean; onSave: SaveHandler; }) { const form = useForm({ resolver: zodResolver(reverseProxySchema), defaultValues: { origin_urls_text: route.upstream_list.join('\n'), origin_host: route.origin_host || '', custom_headers_text: customHeadersToText(route.custom_header_list), remark: route.remark || '', }, }); useEffect(() => { form.reset({ origin_urls_text: route.upstream_list.join('\n'), origin_host: route.origin_host || '', custom_headers_text: customHeadersToText(route.custom_header_list), remark: route.remark || '', }); }, [form, route]); return (
{ const { urls } = parseOriginUrls(values.origin_urls_text); const primaryOrigin = parseOriginUrl(urls[0]); const { headers } = parseCustomHeadersText(values.custom_headers_text); onSave( buildPayloadFromRoute(route, { origin_id: null, origin_url: urls[0], origin_scheme: primaryOrigin.scheme, origin_address: primaryOrigin.address, origin_port: primaryOrigin.port, origin_uri: primaryOrigin.uri, origin_host: values.origin_host.trim(), upstreams: urls.slice(1), custom_headers: headers, remark: values.remark.trim(), }), { message: '反向代理设置已保存。' }, ); })} >
); } function HTTPSSection({ route, certificates, saving, onSave, }: { route: ProxyRouteItem; certificates: TlsCertificateItem[]; saving: boolean; onSave: SaveHandler; }) { const form = useForm({ resolver: zodResolver(httpsSchema), defaultValues: { enable_https: route.enable_https, cert_id: route.cert_id ? String(route.cert_id) : '', redirect_http: route.redirect_http, }, }); useEffect(() => { form.reset({ enable_https: route.enable_https, cert_id: route.cert_id ? String(route.cert_id) : '', redirect_http: route.redirect_http, }); }, [form, route]); const watchedEnableHTTPS = form.watch('enable_https'); return (
{ onSave( buildPayloadFromRoute(route, { 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, }), { message: 'HTTPS 设置已保存。' }, ); })} > { form.setValue('enable_https', checked, { shouldDirty: true }); if (!checked) { form.setValue('cert_id', '', { shouldDirty: true }); form.setValue('redirect_http', false, { shouldDirty: true }); } }} /> {certificates.map((certificate) => ( ))} form.setValue('redirect_http', checked, { shouldDirty: true }) } />
); } function CacheSection({ route, saving, onSave, }: { route: ProxyRouteItem; saving: boolean; onSave: SaveHandler; }) { const form = useForm({ resolver: zodResolver(cacheSchema), defaultValues: { cache_enabled: route.cache_enabled, cache_policy: (route.cache_policy || 'url') as CacheValues['cache_policy'], cache_rules_text: route.cache_rule_list.join('\n'), }, }); useEffect(() => { form.reset({ cache_enabled: route.cache_enabled, cache_policy: (route.cache_policy || 'url') as CacheValues['cache_policy'], cache_rules_text: route.cache_rule_list.join('\n'), }); }, [form, route]); const watchedEnabled = form.watch('cache_enabled'); const watchedPolicy = form.watch('cache_policy'); return (
{ const rules = linesFromTextarea(values.cache_rules_text); onSave( buildPayloadFromRoute(route, { cache_enabled: values.cache_enabled, cache_policy: values.cache_enabled ? values.cache_policy : 'url', cache_rules: values.cache_enabled && values.cache_policy !== 'url' ? rules : [], }), { message: '缓存设置已保存。' }, ); })} > form.setValue('cache_enabled', checked, { shouldDirty: true }) } />
); } export function ProxyRouteConfigPage({ routeId, initialSection, }: { routeId: string; initialSection?: string; }) { const queryClient = useQueryClient(); const [feedback, setFeedback] = useState(null); const numericRouteID = Number(routeId); const currentSection = getWebsiteConfigSection(initialSection); const routeQuery = useQuery({ queryKey: ['proxy-routes', 'detail', numericRouteID], queryFn: () => getProxyRoute(numericRouteID), enabled: Number.isFinite(numericRouteID) && numericRouteID > 0, }); const certificatesQuery = useQuery({ queryKey: ['tls-certificates', 'list'], queryFn: getTlsCertificates, }); const saveMutation = useMutation({ mutationFn: async ({ payload, context, }: { payload: Parameters[1]; context: SaveContext; }) => { const updatedRoute = await updateProxyRoute(numericRouteID, payload); return { updatedRoute, context }; }, onSuccess: async ({ updatedRoute, context }) => { queryClient.setQueryData( ['proxy-routes', 'detail', numericRouteID], updatedRoute, ); setFeedback({ tone: 'success', message: context.message }); await Promise.all([ queryClient.invalidateQueries({ queryKey: ['proxy-routes'] }), queryClient.invalidateQueries({ queryKey: ['config-versions', 'diff'] }), ]); }, onError: (error) => { setFeedback({ tone: 'danger', message: getErrorMessage(error) }); }, }); const route = routeQuery.data; const certificates = useMemo( () => certificatesQuery.data ?? [], [certificatesQuery.data], ); if (!Number.isFinite(numericRouteID) || numericRouteID <= 0) { return ( ); } if (routeQuery.isLoading || certificatesQuery.isLoading) { return ; } if (routeQuery.isError) { return ( ); } if (certificatesQuery.isError) { return ( ); } if (!route) { return ( ); } const currentSectionMeta = websiteConfigSections.find((section) => section.key === currentSection) ?? websiteConfigSections[0]; return (
返回列表 queryClient.invalidateQueries({ queryKey: ['proxy-routes', 'detail', numericRouteID], }) } > 刷新详情
} /> {feedback ? ( ) : null}
{currentSection === 'domains' ? ( saveMutation.mutate({ payload, context }) } /> ) : null} {currentSection === 'limits' ? ( saveMutation.mutate({ payload, context }) } /> ) : null} {currentSection === 'proxy' ? ( saveMutation.mutate({ payload, context }) } /> ) : null} {currentSection === 'https' ? ( saveMutation.mutate({ payload, context }) } /> ) : null} {currentSection === 'cache' ? ( saveMutation.mutate({ payload, context }) } /> ) : null}
); }