'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 { Controller, 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 { getManagedDomains } from '@/features/managed-domains/api/managed-domains'; import { getProxyRoute, updateProxyRoute, } from '@/features/proxy-routes/api/proxy-routes'; import { getWAFSiteRuleGroups, replaceWAFSiteRuleGroups, } from '@/features/waf/api/waf'; import { buildDomainRowsFromRoute, DomainListInput, type DomainListRow, } from '@/features/proxy-routes/components/domain-list-input'; import { buildPayloadFromRoute, customHeadersToText, getErrorMessage, getWebsiteConfigSection, linesFromTextarea, normalizeLimitRate, parseCustomHeadersText, parseOriginUrl, parseOriginUrls, validateCacheRules, validateDomains, validateLimitRate, validateOriginHost, websiteConfigSections, } from '@/features/proxy-routes/helpers'; import type { ProxyRouteItem, ProxyRouteMutationPayload, } from '@/features/proxy-routes/types'; import { getTlsCertificates } from '@/features/tls-certificates/api/tls-certificates'; import type { TlsCertificateItem } from '@/features/tls-certificates/types'; import { PrimaryButton, ResourceField, ResourceInput, ResourceSelect, ResourceTextarea, SecondaryButton, ToggleField, } from '@/features/shared/components/resource-primitives'; import { cn } from '@/lib/utils/cn'; 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 个字符'), domain_rows: z .array( z.object({ domain: z.string(), certificateId: z.string(), }), ) .min(1), enabled: z.boolean(), redirect_http: z.boolean(), }) .superRefine((value, context) => { const domains = value.domain_rows .map((item) => item.domain.trim().toLowerCase()) .filter(Boolean); const error = validateDomains(domains); if (error) { context.addIssue({ code: z.ZodIssueCode.custom, path: ['domain_rows'], message: error, }); } const selectedCertificateCount = new Set( value.domain_rows .map((item) => Number(item.certificateId)) .filter((item) => Number.isFinite(item) && item > 0), ).size; if (value.redirect_http && selectedCertificateCount === 0) { context.addIssue({ code: z.ZodIssueCode.custom, path: ['redirect_http'], message: '启用 HTTP 跳转前,请先为域名选择证书', }); } }); 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 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 CacheValues = z.infer; function normalizeSelectedCertificateIDs(rows: DomainListRow[]) { return Array.from( new Set( rows .filter((item) => item.domain.trim() !== '') .map((item) => Number(item.certificateId)) .filter((item) => Number.isFinite(item) && item > 0), ), ); } function buildDomainCertificateIDs(rows: DomainListRow[]) { return rows .filter((item) => item.domain.trim() !== '') .map((item) => { const certificateID = Number(item.certificateId); return Number.isFinite(certificateID) && certificateID > 0 ? certificateID : 0; }); } function buildDomainRows(route: ProxyRouteItem) { const selectedCertIDs = route.cert_ids.length > 0 ? route.cert_ids : route.cert_id ? [route.cert_id] : []; return buildDomainRowsFromRoute( route.domains, route.domain_cert_ids, selectedCertIDs, ); } function ConfigSectionShell({ title, description, formId, saving, children, }: { title: string; description: string; formId: string; saving: boolean; children: ReactNode; }) { return ( {saving ? '保存中...' : '保存'} } > {children} ); } function DomainSettingsSection({ route, certificates, saving, onSave, suggestionSources, }: { route: ProxyRouteItem; certificates: TlsCertificateItem[]; saving: boolean; onSave: SaveHandler; suggestionSources: string[]; }) { const form = useForm({ resolver: zodResolver(domainSettingsSchema), defaultValues: { site_name: route.site_name, domain_rows: buildDomainRows(route), enabled: route.enabled, redirect_http: route.redirect_http, }, }); useEffect(() => { form.reset({ site_name: route.site_name, domain_rows: buildDomainRows(route), enabled: route.enabled, redirect_http: route.redirect_http, }); }, [form, route]); const selectedCertificateIDs = normalizeSelectedCertificateIDs( form.watch('domain_rows'), ); return (
{ const domains = values.domain_rows .map((item) => item.domain.trim().toLowerCase()) .filter(Boolean); const domainCertIDs = buildDomainCertificateIDs(values.domain_rows); const certIDs = normalizeSelectedCertificateIDs(values.domain_rows); onSave( buildPayloadFromRoute(route, { site_name: values.site_name.trim(), domain: domains[0], domains, enabled: values.enabled, enable_https: certIDs.length > 0, cert_id: certIDs[0] ?? null, cert_ids: certIDs, domain_cert_ids: domainCertIDs, redirect_http: certIDs.length > 0 ? values.redirect_http : false, }), { message: '域名设置已保存。' }, ); })} > form.setValue('enabled', checked, { shouldDirty: true }) } /> ( )} /> 0 ? '开启后会额外生成 80 端口重定向规则。' : '至少为一个域名选择证书后才能启用。' } checked={form.watch('redirect_http')} disabled={selectedCertificateIDs.length === 0} onChange={(checked) => form.setValue('redirect_http', 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 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 }) } />
); } function WAFBindingSection({ route }: { route: ProxyRouteItem }) { const queryClient = useQueryClient(); const [selectedIDs, setSelectedIDs] = useState([]); const wafQuery = useQuery({ queryKey: ['waf', 'site-rule-groups', route.id], queryFn: () => getWAFSiteRuleGroups(route.id), }); const wafMutation = useMutation({ mutationFn: (ids: number[]) => replaceWAFSiteRuleGroups(route.id, ids), onSuccess: async (result) => { setSelectedIDs(result.applied_ids); await Promise.all([ queryClient.invalidateQueries({ queryKey: ['waf', 'site-rule-groups', route.id], }), queryClient.invalidateQueries({ queryKey: ['waf', 'rule-groups'] }), queryClient.invalidateQueries({ queryKey: ['config-versions', 'diff'], }), ]); }, }); useEffect(() => { if (wafQuery.data) { setSelectedIDs(wafQuery.data.applied_ids); } }, [wafQuery.data]); const selectedSet = useMemo(() => new Set(selectedIDs), [selectedIDs]); return ( {wafQuery.isLoading ? ( ) : wafQuery.isError ? ( ) : (
{ e.preventDefault(); wafMutation.mutate(selectedIDs); }} > {wafMutation.isError ? ( ) : null} {wafMutation.isSuccess ? ( ) : null} {wafQuery.data?.global_rule_group ? (

Global Rule Group

{wafQuery.data.global_rule_group.name}

始终生效
) : null}
{(wafQuery.data?.rule_groups ?? []).map((group) => ( ))}
{(wafQuery.data?.rule_groups ?? []).length === 0 ? ( ) : null} )}
); } const basicAuthSchema = z .object({ basic_auth_enabled: z.boolean(), basic_auth_username: z.string(), basic_auth_password: z.string(), }) .superRefine((value, context) => { if (value.basic_auth_enabled) { if (!value.basic_auth_username.trim()) { context.addIssue({ code: z.ZodIssueCode.custom, path: ['basic_auth_username'], message: '请输入账号', }); } if (!value.basic_auth_password.trim()) { context.addIssue({ code: z.ZodIssueCode.custom, path: ['basic_auth_password'], message: '请输入密码', }); } } }); type BasicAuthValues = z.infer; function BasicAuthSection({ route, saving, onSave, }: { route: ProxyRouteItem; saving: boolean; onSave: SaveHandler; }) { const form = useForm({ resolver: zodResolver(basicAuthSchema), defaultValues: { basic_auth_enabled: route.basic_auth_enabled, basic_auth_username: route.basic_auth_username || '', basic_auth_password: route.basic_auth_password || '', }, }); useEffect(() => { form.reset({ basic_auth_enabled: route.basic_auth_enabled, basic_auth_username: route.basic_auth_username || '', basic_auth_password: route.basic_auth_password || '', }); }, [form, route]); const watchedEnabled = form.watch('basic_auth_enabled'); return (
{ onSave( buildPayloadFromRoute(route, { basic_auth_enabled: values.basic_auth_enabled, basic_auth_username: values.basic_auth_username.trim(), basic_auth_password: values.basic_auth_password.trim(), }), { message: '认证配置已保存。' }, ); })} > form.setValue('basic_auth_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 managedDomainsQuery = useQuery({ queryKey: ['managed-domains'], queryFn: getManagedDomains, }); 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], ); const domainSuggestionSources = useMemo( () => [ ...(route?.domains ?? []), ...(managedDomainsQuery.data?.map((item) => item.domain) ?? []), ], [managedDomainsQuery.data, route?.domains], ); if (!Number.isFinite(numericRouteID) || numericRouteID <= 0) { return ( ); } if (routeQuery.isLoading || certificatesQuery.isLoading) { return ; } if (routeQuery.isError) { return ( ); } if (certificatesQuery.isError) { return ( ); } if (!route) { return ( ); } 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 === 'cache' ? ( saveMutation.mutate({ payload, context }) } /> ) : null} {currentSection === 'waf' ? ( ) : null} {currentSection === 'auth' ? ( saveMutation.mutate({ payload, context }) } /> ) : null}
); }