diff --git a/README.md b/README.md index 2aef9cf7..743db103 100644 --- a/README.md +++ b/README.md @@ -24,6 +24,12 @@

+> [!NOTE] +> 当前项目处于快速迭代期,设计与实现均不稳定,请确保使用最新版本并关注更新日志。 + +> [!WARNING] +> 使用 root 用户初次登录系统后,务必修改默认密码 `123456`,并且确保关闭新用户注册功能。 + ## 为什么存在 OpenFlare 解决的是一类朴素但高频的运维问题: diff --git a/openflare_server/web/app/(dashboard)/proxy-route/detail/page.tsx b/openflare_server/web/app/(dashboard)/proxy-route/detail/page.tsx new file mode 100644 index 00000000..aea4014e --- /dev/null +++ b/openflare_server/web/app/(dashboard)/proxy-route/detail/page.tsx @@ -0,0 +1,16 @@ +'use client'; + +import { useSearchParams } from 'next/navigation'; + +import { ProxyRouteConfigPage } from '@/features/proxy-routes/components/proxy-route-config-page'; + +export default function ProxyRouteDetailRoute() { + const searchParams = useSearchParams(); + + return ( + + ); +} diff --git a/openflare_server/web/features/proxy-routes/components/proxy-route-config-page.tsx b/openflare_server/web/features/proxy-routes/components/proxy-route-config-page.tsx new file mode 100644 index 00000000..26b4a485 --- /dev/null +++ b/openflare_server/web/features/proxy-routes/components/proxy-route-config-page.tsx @@ -0,0 +1,969 @@ +'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} +
+
+ + ); +} diff --git a/openflare_server/web/features/proxy-routes/components/proxy-route-create-drawer.tsx b/openflare_server/web/features/proxy-routes/components/proxy-route-create-drawer.tsx new file mode 100644 index 00000000..9d051c6c --- /dev/null +++ b/openflare_server/web/features/proxy-routes/components/proxy-route-create-drawer.tsx @@ -0,0 +1,219 @@ +'use client'; + +import { zodResolver } from '@hookform/resolvers/zod'; +import { useMutation } from '@tanstack/react-query'; +import { useEffect } from 'react'; +import { useForm } from 'react-hook-form'; +import { z } from 'zod'; + +import { Drawer } from '@/components/ui/drawer'; +import { createProxyRoute } from '@/features/proxy-routes/api/proxy-routes'; +import { + buildOriginUrl, + getErrorMessage, + linesFromTextarea, + parseOriginUrl, + parseOriginUrls, + validateDomains, +} from '@/features/proxy-routes/helpers'; +import type { ProxyRouteItem } from '@/features/proxy-routes/types'; +import { + PrimaryButton, + ResourceField, + ResourceTextarea, + ToggleField, +} from '@/features/shared/components/resource-primitives'; + +const createWebsiteSchema = z + .object({ + site_name: z.string().trim().max(255, '站点标识不能超过 255 个字符'), + domains_text: z.string().trim().min(1, '请至少填写一个域名'), + origin_urls_text: z.string().trim().min(1, '请至少填写一个上游地址'), + enabled: z.boolean(), + remark: z.string().max(255, '备注不能超过 255 个字符'), + }) + .superRefine((value, context) => { + const domains = linesFromTextarea(value.domains_text).map((item) => + item.toLowerCase(), + ); + const domainError = validateDomains(domains); + if (domainError) { + context.addIssue({ + code: z.ZodIssueCode.custom, + path: ['domains_text'], + message: domainError, + }); + } + + const { error } = parseOriginUrls(value.origin_urls_text); + if (error) { + context.addIssue({ + code: z.ZodIssueCode.custom, + path: ['origin_urls_text'], + message: error, + }); + } + }); + +type CreateWebsiteFormValues = z.infer; + +const defaultValues: CreateWebsiteFormValues = { + site_name: '', + domains_text: '', + origin_urls_text: '', + enabled: true, + remark: '', +}; + +export function ProxyRouteCreateDrawer({ + open, + onOpenChange, + onCreated, +}: { + open: boolean; + onOpenChange: (open: boolean) => void; + onCreated: (route: ProxyRouteItem) => void; +}) { + const form = useForm({ + resolver: zodResolver(createWebsiteSchema), + defaultValues, + }); + + const createMutation = useMutation({ + mutationFn: async (values: CreateWebsiteFormValues) => { + const domains = linesFromTextarea(values.domains_text).map((item) => + item.toLowerCase(), + ); + const { urls } = parseOriginUrls(values.origin_urls_text); + const primaryOrigin = parseOriginUrl(urls[0]); + + return createProxyRoute({ + site_name: values.site_name.trim() || domains[0], + domain: domains[0], + domains, + origin_id: null, + origin_url: buildOriginUrl( + primaryOrigin.scheme, + primaryOrigin.address, + primaryOrigin.port, + primaryOrigin.uri, + ), + origin_scheme: primaryOrigin.scheme, + origin_address: primaryOrigin.address, + origin_port: primaryOrigin.port, + origin_uri: primaryOrigin.uri, + origin_host: '', + upstreams: urls.slice(1), + enabled: values.enabled, + enable_https: false, + cert_id: null, + 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: [], + remark: values.remark.trim(), + }); + }, + onSuccess: (route) => { + form.reset(defaultValues); + onOpenChange(false); + onCreated(route); + }, + }); + + useEffect(() => { + if (!open) { + form.reset(defaultValues); + } + }, [form, open]); + + return ( + + + {createMutation.isPending ? '创建中...' : '创建并进入配置'} + + + } + > +
createMutation.mutate(values))} + > + + + + + + + + + + + + + + form.setValue('enabled', checked, { shouldDirty: true }) + } + /> + + + + + + {createMutation.isError ? ( +

+ {getErrorMessage(createMutation.error)} +

+ ) : null} + +
+ ); +} 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 86bbc47c..b1ea9c70 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,12 +1,9 @@ 'use client'; -import { ListBox, Select } from '@heroui/react'; -import { zodResolver } from '@hookform/resolvers/zod'; +import Link from 'next/link'; +import { useRouter } from 'next/navigation'; 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 { useMemo, useState } from 'react'; import { EmptyState } from '@/components/feedback/empty-state'; import { ErrorState } from '@/components/feedback/error-state'; @@ -14,442 +11,40 @@ 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 { Drawer } from '@/components/ui/drawer'; -import { Input } from '@/components/ui/input'; -import { Label } from '@/components/ui/label'; 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 { ProxyRouteCreateDrawer } from '@/features/proxy-routes/components/proxy-route-create-drawer'; +import { + getErrorMessage, + getUpstreamSummary, + getWebsiteStatusBadges, +} from '@/features/proxy-routes/helpers'; 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 type { ProxyRouteItem } from '@/features/proxy-routes/types'; import { DangerButton, PrimaryButton, - ResourceField, + ResourceInput, SecondaryButton, - ToggleField, } from '@/features/shared/components/resource-primitives'; -import { cn } from '@/lib/utils/cn'; import { formatDateTime } from '@/lib/utils/date'; -const SelectItem = ListBox.Item; - -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_TRIGGER_CLASS_NAME = cn( - INPUT_CLASS_NAME, - 'flex min-h-10 w-full items-center justify-between rounded-xl border border-[var(--border-default)] bg-[var(--surface-elevated)] px-3 shadow-none transition-colors data-[hovered=true]:bg-[var(--surface-elevated)] data-[focus-visible=true]:border-[var(--border-strong)] data-[open=true]:border-[var(--border-strong)]', -); -const HERO_SELECT_VALUE_CLASS_NAME = - 'truncate text-left text-sm text-[var(--foreground-primary)]'; -const HERO_SELECT_POPOVER_CLASS_NAME = - 'rounded-2xl border border-[var(--border-default)] bg-[var(--surface-panel)] shadow-[var(--shadow-soft)]'; -const HERO_SELECT_LISTBOX_CLASS_NAME = 'p-1'; - -const defaultValues: ProxyRouteFormValues = { - managed_domain_id: '', - subdomain_label: '', - origin_rows: [{ scheme: 'http', address: '', port: '80' }], - 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_sites: string[]; + removed_sites: string[]; + modified_sites: string[]; added_domains: string[]; removed_domains: string[]; modified_domains: string[]; @@ -457,6 +52,9 @@ function hasConfigChanges(diff: { changed_option_keys: string[]; }) { return ( + diff.added_sites.length > 0 || + diff.removed_sites.length > 0 || + diff.modified_sites.length > 0 || diff.added_domains.length > 0 || diff.removed_domains.length > 0 || diff.modified_domains.length > 0 || @@ -466,853 +64,30 @@ function hasConfigChanges(diff: { ); } -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 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 ( -