'use client'; import { zodResolver } from '@hookform/resolvers/zod'; import { useMutation, useQuery } from '@tanstack/react-query'; import { useEffect, useMemo } from 'react'; import { Controller, useForm } from 'react-hook-form'; import { z } from 'zod'; import { Drawer } from '@/components/ui/drawer'; import { getManagedDomains } from '@/features/managed-domains/api/managed-domains'; import { createProxyRoute } from '@/features/proxy-routes/api/proxy-routes'; import { DomainListInput, type DomainListRow, } from '@/features/proxy-routes/components/domain-list-input'; import { buildOriginUrl, getErrorMessage, parseOriginUrl, parseOriginUrls, validateDomains, } from '@/features/proxy-routes/helpers'; import { getNodes } from '@/features/nodes/api/nodes'; import type { ProxyRouteItem } from '@/features/proxy-routes/types'; import { getTlsCertificates } from '@/features/tls-certificates/api/tls-certificates'; import { PrimaryButton, ResourceField, ResourceInput, ResourceTextarea, ToggleField, } from '@/features/shared/components/resource-primitives'; const domainRowSchema = z.object({ domain: z.string(), certificateId: z.string(), }); const createWebsiteSchema = z .object({ site_name: z.string().trim().max(255, '站点标识不能超过 255 个字符'), domain_rows: z.array(domainRowSchema).min(1), upstream_type: z.enum(['direct', 'tunnel']), origin_urls_text: z.string().trim(), tunnel_id: z.string().optional(), tunnel_target_addr: z.string().trim().optional(), tunnel_target_protocol: z.enum(['http', 'https']).optional(), enabled: z.boolean(), redirect_http: z.boolean(), remark: z.string().max(255, '备注不能超过 255 个字符'), }) .superRefine((value, context) => { const domains = value.domain_rows .map((item) => item.domain.trim().toLowerCase()) .filter(Boolean); const domainError = validateDomains(domains); if (domainError) { context.addIssue({ code: z.ZodIssueCode.custom, path: ['domain_rows'], message: domainError, }); } if (value.upstream_type === 'direct') { if (!value.origin_urls_text.trim()) { context.addIssue({ code: z.ZodIssueCode.custom, path: ['origin_urls_text'], message: '请至少填写一个上游地址', }); } else { const { error } = parseOriginUrls(value.origin_urls_text); if (error) { context.addIssue({ code: z.ZodIssueCode.custom, path: ['origin_urls_text'], message: error, }); } } } else { if (!value.tunnel_id) { context.addIssue({ code: z.ZodIssueCode.custom, path: ['tunnel_id'], message: '请选择内网穿透隧道', }); } if (!value.tunnel_target_addr) { context.addIssue({ code: z.ZodIssueCode.custom, path: ['tunnel_target_addr'], message: '请填写内网服务地址 (如 127.0.0.1:8080)', }); } } 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 跳转前,请先为域名选择证书', }); } }); type CreateWebsiteFormValues = z.infer; const defaultValues: CreateWebsiteFormValues = { site_name: '', domain_rows: [{ domain: '', certificateId: '' }], upstream_type: 'direct', origin_urls_text: '', tunnel_id: '', tunnel_target_addr: '', tunnel_target_protocol: 'http', enabled: true, redirect_http: false, remark: '', }; 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; }); } export function ProxyRouteCreateDrawer({ open, onOpenChange, onCreated, domainSuggestionSources = [], }: { open: boolean; onOpenChange: (open: boolean) => void; onCreated: (route: ProxyRouteItem) => void; domainSuggestionSources?: string[]; }) { const form = useForm({ resolver: zodResolver(createWebsiteSchema), defaultValues, }); const managedDomainsQuery = useQuery({ queryKey: ['managed-domains'], queryFn: getManagedDomains, enabled: open, }); const certificatesQuery = useQuery({ queryKey: ['tls-certificates', 'list'], queryFn: getTlsCertificates, enabled: open, }); const tunnelsQuery = useQuery({ queryKey: ['nodes'], queryFn: getNodes, enabled: open, }); const tunnelClients = (tunnelsQuery.data ?? []).filter((node) => node.node_type === 'tunnel_client'); const combinedDomainSuggestions = useMemo( () => [ ...domainSuggestionSources, ...(managedDomainsQuery.data?.map((item) => item.domain) ?? []), ], [domainSuggestionSources, managedDomainsQuery.data], ); const selectedCertificateIDs = normalizeSelectedCertificateIDs( form.watch('domain_rows'), ); const createMutation = useMutation({ mutationFn: async (values: CreateWebsiteFormValues) => { const domains = values.domain_rows .map((item) => item.domain.trim().toLowerCase()) .filter(Boolean); const domainCertIDs = buildDomainCertificateIDs(values.domain_rows); const selectedCertIDs = normalizeSelectedCertificateIDs( values.domain_rows, ); let originUrl = ''; const originHost = ''; let upstreams: string[] = []; if (values.upstream_type === 'direct') { const { urls } = parseOriginUrls(values.origin_urls_text); const primaryOrigin = parseOriginUrl(urls[0]); originUrl = buildOriginUrl( primaryOrigin.scheme, primaryOrigin.address, primaryOrigin.port, primaryOrigin.uri, ); upstreams = urls.slice(1); } else { originUrl = `${values.tunnel_target_protocol}://${values.tunnel_target_addr}`; } return createProxyRoute({ site_name: values.site_name.trim() || domains[0], domain: domains[0], domains, origin_id: null, origin_url: originUrl, origin_scheme: values.upstream_type === 'direct' ? parseOriginUrl(originUrl).scheme : 'http', origin_address: values.upstream_type === 'direct' ? parseOriginUrl(originUrl).address : values.tunnel_target_addr || '', origin_port: values.upstream_type === 'direct' ? parseOriginUrl(originUrl).port : '80', origin_uri: values.upstream_type === 'direct' ? parseOriginUrl(originUrl).uri : '', origin_host: originHost, upstreams, enabled: values.enabled, enable_https: selectedCertIDs.length > 0, cert_id: selectedCertIDs.length > 0 ? selectedCertIDs[0] : null, cert_ids: selectedCertIDs, domain_cert_ids: domainCertIDs, redirect_http: selectedCertIDs.length > 0 ? values.redirect_http : false, limit_conn_per_server: 0, limit_conn_per_ip: 0, limit_rate: '', cache_enabled: false, cache_policy: 'url', cache_rules: [], custom_headers: [], pow_enabled: false, pow_config: '{}', basic_auth_enabled: false, remark: values.remark.trim(), upstream_type: values.upstream_type, tunnel_node_id: values.upstream_type === 'tunnel' && values.tunnel_id ? Number(values.tunnel_id) : null, tunnel_target_addr: values.upstream_type === 'tunnel' ? values.tunnel_target_addr : '', tunnel_target_protocol: values.upstream_type === 'tunnel' ? values.tunnel_target_protocol : '', }); }, onSuccess: (route) => { form.reset(defaultValues); onOpenChange(false); onCreated(route); }, }); useEffect(() => { if (!open) { form.reset(defaultValues); } }, [form, open]); return ( {createMutation.isPending ? '创建中...' : '创建'} } >
createMutation.mutate(values))} > ( )} /> 0 ? '勾选后会额外生成 80 端口重定向规则。' : '至少为一个域名选择证书后才能启用。' } checked={form.watch('redirect_http')} disabled={selectedCertificateIDs.length === 0} onChange={(checked) => form.setValue('redirect_http', checked, { shouldDirty: true }) } />
{form.watch('upstream_type') === 'direct' ? ( ) : (
)} form.setValue('enabled', checked, { shouldDirty: true }) } /> {createMutation.isError ? (

{getErrorMessage(createMutation.error)}

) : null}
); }