From 77457250cf589cd2d1a2f4abe72244b05728781e Mon Sep 17 00:00:00 2001 From: ryan Date: Wed, 1 Apr 2026 09:27:41 +0800 Subject: [PATCH] =?UTF-8?q?[=E5=8A=9F=E8=83=BD]=20=E6=9B=B4=E6=96=B0?= =?UTF-8?q?=E5=9F=9F=E5=90=8D=E5=88=97=E8=A1=A8=E8=BE=93=E5=85=A5=E7=BB=84?= =?UTF-8?q?=E4=BB=B6=EF=BC=8C=E6=94=AF=E6=8C=81=E4=B8=BA=E6=AF=8F=E4=B8=AA?= =?UTF-8?q?=E5=9F=9F=E5=90=8D=E9=80=89=E6=8B=A9=E8=AF=81=E4=B9=A6=E5=B9=B6?= =?UTF-8?q?=E4=BC=98=E5=8C=96=E7=9B=B8=E5=85=B3=E9=80=BB=E8=BE=91?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../components/domain-list-input.tsx | 213 ++++++++---- .../components/proxy-route-config-page.tsx | 327 +++++++----------- .../components/proxy-route-create-drawer.tsx | 118 +++++-- .../web/features/proxy-routes/helpers.ts | 25 +- .../web/tests/unit/proxy-routes-page.test.tsx | 42 ++- 5 files changed, 399 insertions(+), 326 deletions(-) diff --git a/openflare_server/web/features/proxy-routes/components/domain-list-input.tsx b/openflare_server/web/features/proxy-routes/components/domain-list-input.tsx index dc1ec49e..a4788e0b 100644 --- a/openflare_server/web/features/proxy-routes/components/domain-list-input.tsx +++ b/openflare_server/web/features/proxy-routes/components/domain-list-input.tsx @@ -1,15 +1,23 @@ 'use client'; import { useId, useMemo } from 'react'; +import { Minus, Plus } from 'lucide-react'; +import type { TlsCertificateItem } from '@/features/tls-certificates/types'; import { ResourceInput, + ResourceSelect, SecondaryButton, } from '@/features/shared/components/resource-primitives'; +export type DomainListRow = { + domain: string; + certificateId: string; +}; -function splitDomainRows(value: string) { - const rows = value.split(/\r?\n/); - return rows.length > 0 ? rows : ['']; +const actionButtonClassName = 'h-12 w-[72px] shrink-0 rounded-2xl px-0'; + +function ensureRows(rows: DomainListRow[]) { + return rows.length > 0 ? rows : [{ domain: '', certificateId: '' }]; } function buildSuggestionSources(domains: string[]) { @@ -32,7 +40,11 @@ function buildSuggestionSources(domains: string[]) { return Array.from(values); } -function buildDomainSuggestions(input: string, sources: string[], rows: string[]) { +function buildDomainSuggestions( + input: string, + sources: string[], + rows: DomainListRow[], +) { const normalizedInput = input.trim().toLowerCase(); if (!normalizedInput) { return []; @@ -40,7 +52,7 @@ function buildDomainSuggestions(input: string, sources: string[], rows: string[] const existingDomains = new Set( rows - .map((row) => row.trim().toLowerCase()) + .map((row) => row.domain.trim().toLowerCase()) .filter((row) => row && row !== normalizedInput), ); const suggestions: string[] = []; @@ -72,114 +84,171 @@ function buildDomainSuggestions(input: string, sources: string[], rows: string[] }); } +export function buildDomainRowsFromRoute( + domains: string[], + certIDs: number[], +): DomainListRow[] { + if (domains.length === 0) { + return ensureRows([]); + } + + if (certIDs.length === 0) { + return domains.map((domain) => ({ domain, certificateId: '' })); + } + + if (certIDs.length === 1) { + return domains.map((domain) => ({ + domain, + certificateId: String(certIDs[0]), + })); + } + + return domains.map((domain, index) => ({ + domain, + certificateId: certIDs[index] ? String(certIDs[index]) : '', + })); +} + export function DomainListInput({ - value, + rows, onChange, onBlur, suggestionSources = [], - placeholder = 'app.example.com', + certificates = [], + domainPlaceholder = 'app.example.com', }: { - value: string; - onChange: (value: string) => void; + rows: DomainListRow[]; + onChange: (rows: DomainListRow[]) => void; onBlur?: () => void; suggestionSources?: string[]; - placeholder?: string; + certificates?: TlsCertificateItem[]; + domainPlaceholder?: string; }) { const listId = useId(); - const rows = splitDomainRows(value); + const safeRows = ensureRows(rows); const normalizedSources = useMemo( () => buildSuggestionSources(suggestionSources), [suggestionSources], ); - const updateRows = (nextRows: string[]) => { - onChange(nextRows.join('\n')); + const updateRows = (nextRows: DomainListRow[]) => { + onChange(ensureRows(nextRows)); }; return ( -
- {rows.map((row, index) => { - const suggestions = buildDomainSuggestions(row, normalizedSources, rows).slice( - 0, - 4, - ); +
+ {safeRows.map((row, index) => { + const suggestions = buildDomainSuggestions( + row.domain, + normalizedSources, + safeRows, + ).slice(0, 4); return ( -
-
-
+
+
+ { + if (safeRows.length === 1) { + updateRows([{ domain: '', certificateId: '' }]); + return; + } + + updateRows( + safeRows.filter((_, rowIndex) => rowIndex !== index), + ); + }} + > + + +
{ - const nextRows = rows.slice(); - nextRows[index] = event.target.value; + const nextRows = safeRows.slice(); + nextRows[index] = { + ...nextRows[index], + domain: event.target.value, + }; updateRows(nextRows); }} + className="h-12" /> {suggestions.map((suggestion) => ( + + {suggestions.length > 0 ? ( +
+ {suggestions.map((suggestion) => ( + + ))} +
+ ) : null}
- { - const nextRows = rows.slice(); - nextRows.splice(index + 1, 0, ''); + { + const nextRows = safeRows.slice(); + nextRows[index] = { + ...nextRows[index], + certificateId: event.target.value, + }; updateRows(nextRows); }} + className="h-12" > - + - - - { - if (rows.length === 1) { - updateRows(['']); - return; - } - - updateRows(rows.filter((_, rowIndex) => rowIndex !== index)); - }} - > - - - -
- - {suggestions.length > 0 ? ( -
- {suggestions.map((suggestion) => ( - + + {certificates.map((certificate) => ( + ))} -
- ) : null} - + +
); })} + + { + updateRows([...safeRows, { domain: '', certificateId: '' }]); + }} + > +
); } 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 index 23795538..21f3fb70 100644 --- 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 @@ -15,13 +15,15 @@ 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 { 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 { DomainListInput } from '@/features/proxy-routes/components/domain-list-input'; +import { + buildDomainRowsFromRoute, + DomainListInput, + type DomainListRow, +} from '@/features/proxy-routes/components/domain-list-input'; import { buildPayloadFromRoute, customHeadersToText, @@ -42,6 +44,8 @@ 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, @@ -69,22 +73,47 @@ type SaveHandler = ( const domainSettingsSchema = z .object({ - site_name: z.string().trim().min(1, '请输入站点标识').max(255, '站点标识不能超过 255 个字符'), - domains_text: z.string().trim().min(1, '请至少填写一个域名'), + 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 = linesFromTextarea(value.domains_text).map((item) => - item.toLowerCase(), - ); + 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: ['domains_text'], + 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 @@ -156,22 +185,6 @@ const reverseProxySchema = z } }); -const httpsSchema = z - .object({ - enable_https: z.boolean(), - cert_ids: z.array(z.string()), - redirect_http: z.boolean(), - }) - .superRefine((value, context) => { - if (value.enable_https && value.cert_ids.length === 0) { - context.addIssue({ - code: z.ZodIssueCode.custom, - path: ['cert_ids'], - message: '启用 HTTPS 时必须选择证书', - }); - } - }); - const cacheSchema = z .object({ cache_enabled: z.boolean(), @@ -197,9 +210,29 @@ const cacheSchema = z type DomainSettingsValues = z.infer; type RateLimitValues = z.infer; type ReverseProxyValues = z.infer; -type HTTPSValues = z.infer; type CacheValues = z.infer; +function normalizeSelectedCertificateIDs(rows: DomainListRow[]) { + return Array.from( + new Set( + rows + .map((item) => Number(item.certificateId)) + .filter((item) => Number.isFinite(item) && item > 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, selectedCertIDs); +} + function ConfigSectionShell({ title, description, @@ -230,11 +263,13 @@ function ConfigSectionShell({ function DomainSettingsSection({ route, + certificates, saving, onSave, suggestionSources, }: { route: ProxyRouteItem; + certificates: TlsCertificateItem[]; saving: boolean; onSave: SaveHandler; suggestionSources: string[]; @@ -243,23 +278,29 @@ function DomainSettingsSection({ resolver: zodResolver(domainSettingsSchema), defaultValues: { site_name: route.site_name, - domains_text: route.domains.join('\n'), + domain_rows: buildDomainRows(route), enabled: route.enabled, + redirect_http: route.redirect_http, }, }); useEffect(() => { form.reset({ site_name: route.site_name, - domains_text: route.domains.join('\n'), + domain_rows: buildDomainRows(route), enabled: route.enabled, + redirect_http: route.redirect_http, }); }, [form, route]); + const selectedCertificateIDs = normalizeSelectedCertificateIDs( + form.watch('domain_rows'), + ); + return ( @@ -267,9 +308,10 @@ function DomainSettingsSection({ id="proxy-route-domains-form" className="space-y-5" onSubmit={form.handleSubmit((values) => { - const domains = linesFromTextarea(values.domains_text).map((item) => - item.toLowerCase(), - ); + const domains = values.domain_rows + .map((item) => item.domain.trim().toLowerCase()) + .filter(Boolean); + const certIDs = normalizeSelectedCertificateIDs(values.domain_rows); onSave( buildPayloadFromRoute(route, { @@ -277,19 +319,24 @@ function DomainSettingsSection({ domain: domains[0], domains, enabled: values.enabled, + enable_https: certIDs.length > 0, + cert_id: certIDs[0] ?? null, + cert_ids: certIDs, + redirect_http: certIDs.length > 0 ? values.redirect_http : false, }), { message: '域名设置已保存。' }, ); })} > - form.setValue('enabled', checked, { shouldDirty: true }) - } + label="启用站点" + description="关闭后会保留配置,但不会参与发布。" + checked={form.watch('enabled')} + onChange={(checked) => + 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 }) + } + /> ); @@ -362,7 +423,7 @@ function RateLimitSection({ return ( @@ -384,7 +445,7 @@ function RateLimitSection({ > @@ -448,7 +509,7 @@ function ReverseProxySection({ return ( @@ -513,10 +574,7 @@ function ReverseProxySection({ /> - + ({ - resolver: zodResolver(httpsSchema), - defaultValues: { - enable_https: route.enable_https, - cert_ids: - route.cert_ids.length > 0 - ? route.cert_ids.map((certID) => String(certID)) - : route.cert_id - ? [String(route.cert_id)] - : [], - redirect_http: route.redirect_http, - }, - }); - - useEffect(() => { - form.reset({ - enable_https: route.enable_https, - cert_ids: - route.cert_ids.length > 0 - ? route.cert_ids.map((certID) => String(certID)) - : route.cert_id - ? [String(route.cert_id)] - : [], - redirect_http: route.redirect_http, - }); - }, [form, route]); - - const watchedEnableHTTPS = form.watch('enable_https'); - const watchedCertIDs = form.watch('cert_ids'); - - return ( - -
{ - onSave( - buildPayloadFromRoute(route, { - enable_https: values.enable_https, - cert_id: - values.enable_https && - values.cert_ids.some((value) => Number(value) > 0) - ? Number( - values.cert_ids.find((value) => Number(value) > 0) ?? 0, - ) - : null, - cert_ids: values.enable_https - ? values.cert_ids - .map((value) => Number(value)) - .filter((value) => Number.isFinite(value) && value > 0) - : [], - redirect_http: values.enable_https ? values.redirect_http : false, - }), - { message: 'HTTPS 设置已保存。' }, - ); - })} - > - { - form.setValue('enable_https', checked, { shouldDirty: true }); - if (!checked) { - form.setValue('cert_ids', [], { shouldDirty: true }); - form.setValue('redirect_http', false, { shouldDirty: true }); - } - }} - /> - - - - - {certificates.map((certificate) => ( - - ))} - - {watchedEnableHTTPS && watchedCertIDs.length > 0 ? ( -

- 已选择 {watchedCertIDs.length} 张证书,发布时会校验证书集合是否覆盖全部域名。 -

- ) : null} -
- - - form.setValue('redirect_http', checked, { shouldDirty: true }) - } - /> - -
- ); -} - function CacheSection({ route, saving, @@ -751,7 +681,7 @@ function CacheSection({ ? '/assets\n/static' : watchedPolicy === 'path_exact' ? '/robots.txt\n/manifest.json' - : '按 URL 缓存无需额外规则' + : '按 URL 缓存时无需额外规则' } {...form.register('cache_rules_text')} /> @@ -820,18 +750,19 @@ export function ProxyRouteConfigPage({ () => certificatesQuery.data ?? [], [certificatesQuery.data], ); - const domainSuggestionSources = useMemo(() => { - return [ + const domainSuggestionSources = useMemo( + () => [ ...(route?.domains ?? []), ...(managedDomainsQuery.data?.map((item) => item.domain) ?? []), - ]; - }, [managedDomainsQuery.data, route?.domains]); + ], + [managedDomainsQuery.data, route?.domains], + ); if (!Number.isFinite(numericRouteID) || numericRouteID <= 0) { return ( ); } @@ -843,7 +774,7 @@ export function ProxyRouteConfigPage({ if (routeQuery.isError) { return ( ); @@ -861,8 +792,8 @@ export function ProxyRouteConfigPage({ if (!route) { return ( ); } @@ -900,7 +831,7 @@ export function ProxyRouteConfigPage({