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 new file mode 100644 index 00000000..dc1ec49e --- /dev/null +++ b/openflare_server/web/features/proxy-routes/components/domain-list-input.tsx @@ -0,0 +1,185 @@ +'use client'; + +import { useId, useMemo } from 'react'; + +import { + ResourceInput, + SecondaryButton, +} from '@/features/shared/components/resource-primitives'; + +function splitDomainRows(value: string) { + const rows = value.split(/\r?\n/); + return rows.length > 0 ? rows : ['']; +} + +function buildSuggestionSources(domains: string[]) { + const values = new Set(); + + for (const domain of domains) { + const normalized = domain.trim().toLowerCase().replace(/^\*\./, ''); + if (!normalized) { + continue; + } + + values.add(normalized); + + const segments = normalized.split('.'); + for (let index = 1; index < segments.length - 1; index += 1) { + values.add(segments.slice(index).join('.')); + } + } + + return Array.from(values); +} + +function buildDomainSuggestions(input: string, sources: string[], rows: string[]) { + const normalizedInput = input.trim().toLowerCase(); + if (!normalizedInput) { + return []; + } + + const existingDomains = new Set( + rows + .map((row) => row.trim().toLowerCase()) + .filter((row) => row && row !== normalizedInput), + ); + const suggestions: string[] = []; + + for (const source of sources) { + if (source.startsWith(normalizedInput) && source !== normalizedInput) { + suggestions.push(source); + } + + const separatorIndex = normalizedInput.lastIndexOf('.'); + if (separatorIndex <= 0) { + continue; + } + + const prefix = normalizedInput.slice(0, separatorIndex); + const suffixInput = normalizedInput.slice(separatorIndex + 1); + + if (!suffixInput || source.startsWith(suffixInput)) { + suggestions.push(`${prefix}.${source}`); + } + } + + return suggestions.filter((suggestion, index) => { + return ( + suggestion !== normalizedInput && + !existingDomains.has(suggestion) && + suggestions.indexOf(suggestion) === index + ); + }); +} + +export function DomainListInput({ + value, + onChange, + onBlur, + suggestionSources = [], + placeholder = 'app.example.com', +}: { + value: string; + onChange: (value: string) => void; + onBlur?: () => void; + suggestionSources?: string[]; + placeholder?: string; +}) { + const listId = useId(); + const rows = splitDomainRows(value); + const normalizedSources = useMemo( + () => buildSuggestionSources(suggestionSources), + [suggestionSources], + ); + + const updateRows = (nextRows: string[]) => { + onChange(nextRows.join('\n')); + }; + + return ( +
+ {rows.map((row, index) => { + const suggestions = buildDomainSuggestions(row, normalizedSources, rows).slice( + 0, + 4, + ); + + return ( +
+
+
+ { + const nextRows = rows.slice(); + nextRows[index] = event.target.value; + updateRows(nextRows); + }} + /> + + {suggestions.map((suggestion) => ( + +
+ + { + const nextRows = rows.slice(); + nextRows.splice(index + 1, 0, ''); + updateRows(nextRows); + }} + > + + + + + { + if (rows.length === 1) { + updateRows(['']); + return; + } + + updateRows(rows.filter((_, rowIndex) => rowIndex !== index)); + }} + > + - + +
+ + {suggestions.length > 0 ? ( +
+ {suggestions.map((suggestion) => ( + + ))} +
+ ) : null} + +
+ ); + })} +
+ ); +} 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 286d1663..cb95e832 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 @@ -5,7 +5,7 @@ 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 { Controller, useForm } from 'react-hook-form'; import { z } from 'zod'; import { EmptyState } from '@/components/feedback/empty-state'; @@ -14,19 +14,19 @@ 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 { 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 { buildPayloadFromRoute, customHeadersToText, getErrorMessage, getWebsiteConfigSection, - getWebsiteStatusBadges, linesFromTextarea, normalizeLimitRate, parseCustomHeadersText, @@ -37,7 +37,6 @@ import { validateLimitRate, validateOriginHost, websiteConfigSections, - type WebsiteConfigSectionKey, } from '@/features/proxy-routes/helpers'; import type { ProxyRouteItem, @@ -53,7 +52,6 @@ import { ToggleField, } from '@/features/shared/components/resource-primitives'; import { cn } from '@/lib/utils/cn'; -import { formatDateTime } from '@/lib/utils/date'; type FeedbackState = { tone: 'success' | 'danger'; @@ -234,10 +232,12 @@ function DomainSettingsSection({ route, saving, onSave, + suggestionSources, }: { route: ProxyRouteItem; saving: boolean; onSave: SaveHandler; + suggestionSources: string[]; }) { const form = useForm({ resolver: zodResolver(domainSettingsSchema), @@ -303,13 +303,20 @@ function DomainSettingsSection({ - ( + + )} /> @@ -476,6 +483,7 @@ function ReverseProxySection({ error={form.formState.errors.origin_urls_text?.message} > certificatesQuery.data ?? [], [certificatesQuery.data], ); + const domainSuggestionSources = useMemo(() => { + return [ + ...(route?.domains ?? []), + ...(managedDomainsQuery.data?.map((item) => item.domain) ?? []), + ]; + }, [managedDomainsQuery.data, route?.domains]); if (!Number.isFinite(numericRouteID) || numericRouteID <= 0) { return ( @@ -817,10 +835,6 @@ export function ProxyRouteConfigPage({ ); } - const currentSectionMeta = - websiteConfigSections.find((section) => section.key === currentSection) ?? - websiteConfigSections[0]; - return (
saveMutation.mutate({ payload, context }) } 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 index e67fdd43..e10ec782 100644 --- 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 @@ -1,13 +1,15 @@ 'use client'; import { zodResolver } from '@hookform/resolvers/zod'; -import { useMutation } from '@tanstack/react-query'; +import { useMutation, useQuery } from '@tanstack/react-query'; import { useEffect } from 'react'; -import { useForm } from 'react-hook-form'; +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 } from '@/features/proxy-routes/components/domain-list-input'; import { buildOriginUrl, getErrorMessage, @@ -20,6 +22,7 @@ import type { ProxyRouteItem } from '@/features/proxy-routes/types'; import { PrimaryButton, ResourceField, + ResourceInput, ResourceTextarea, ToggleField, } from '@/features/shared/components/resource-primitives'; @@ -69,15 +72,26 @@ 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 combinedDomainSuggestions = [ + ...domainSuggestionSources, + ...(managedDomainsQuery.data?.map((item) => item.domain) ?? []), + ]; const createMutation = useMutation({ mutationFn: async (values: CreateWebsiteFormValues) => { @@ -156,24 +170,31 @@ export function ProxyRouteCreateDrawer({ > - - ( + + )} /> @@ -183,6 +204,7 @@ export function ProxyRouteCreateDrawer({ error={form.formState.errors.origin_urls_text?.message} > @@ -201,10 +223,7 @@ export function ProxyRouteCreateDrawer({ label="备注" error={form.formState.errors.remark?.message} > - + {createMutation.isError ? ( 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 8f19b504..5b9539a3 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 @@ -33,7 +33,6 @@ import { ResourceInput, SecondaryButton, } from '@/features/shared/components/resource-primitives'; -import { formatDateTime } from '@/lib/utils/date'; type FeedbackState = { tone: 'info' | 'success' | 'danger'; @@ -388,6 +387,7 @@ export function ProxyRoutesPage() { route.domains)} onCreated={async (route) => { await Promise.all([ queryClient.invalidateQueries({ queryKey: ['proxy-routes'] }), diff --git a/openflare_server/web/tests/unit/proxy-routes-page.test.tsx b/openflare_server/web/tests/unit/proxy-routes-page.test.tsx index f1d28d03..6b3d7651 100644 --- a/openflare_server/web/tests/unit/proxy-routes-page.test.tsx +++ b/openflare_server/web/tests/unit/proxy-routes-page.test.tsx @@ -232,6 +232,18 @@ describe('Proxy route website pages', () => { ); } + if (url.includes('/managed-domains/')) { + return Promise.resolve( + new Response( + JSON.stringify({ + success: true, + message: '', + data: [{ id: 1, domain: '*.example.com', cert_id: 1, enabled: true }], + }), + ), + ); + } + return Promise.reject(new Error(`Unhandled fetch: ${url}`)); }), ); @@ -245,22 +257,28 @@ describe('Proxy route website pages', () => { const dialog = await screen.findByRole('dialog'); expect(dialog).toBeInTheDocument(); - const dialogTextboxes = within(dialog).getAllByRole('textbox'); - await user.type(dialogTextboxes[0], 'launch-site'); + await user.type(within(dialog).getByPlaceholderText('marketing-site'), 'launch-site'); + + const primaryDomainInput = within(dialog).getByLabelText('域名 1'); + await user.type(primaryDomainInput, 'app.exam'); + await user.click(await within(dialog).findByRole('button', { name: 'app.example.com' })); + + await user.click(within(dialog).getByLabelText('新增域名输入框 1')); + await user.type(within(dialog).getByLabelText('域名 2'), 'www.example.com'); + await user.type( - dialogTextboxes[1], - 'app.example.com{enter}www.example.com', - ); - await user.type( - dialogTextboxes[2], + within(dialog).getByLabelText('上游地址'), 'https://origin-a.internal:443{enter}https://origin-b.internal:443', ); const submitButton = document.querySelector( 'button[form="create-website-form"]', ) as HTMLButtonElement | null; - expect(submitButton).not.toBeNull(); - await user.click(submitButton!); + expect(submitButton).toBeInstanceOf(HTMLButtonElement); + if (!submitButton) { + throw new Error('missing create submit button'); + } + await user.click(submitButton); await waitFor(() => { expect(pushMock).toHaveBeenCalledWith( @@ -327,6 +345,18 @@ describe('Proxy route website pages', () => { ); } + if (url.includes('/managed-domains/')) { + return Promise.resolve( + new Response( + JSON.stringify({ + success: true, + message: '', + data: [{ id: 1, domain: '*.example.com', cert_id: 1, enabled: true }], + }), + ), + ); + } + return Promise.reject(new Error(`Unhandled fetch: ${url}`)); }), ); @@ -338,21 +368,26 @@ describe('Proxy route website pages', () => { const user = userEvent.setup(); expect(await screen.findByText('marketing-site')).toBeInTheDocument(); - const [siteNameInput, domainsText] = screen.getAllByRole('textbox'); + const siteNameInput = screen.getByPlaceholderText('marketing-site'); await user.clear(siteNameInput); await user.type(siteNameInput, 'brand-site'); - await user.clear(domainsText); - await user.type( - domainsText, - 'brand.example.com{enter}www.brand.example.com', - ); + const primaryDomainInput = screen.getByLabelText('域名 1'); + await user.clear(primaryDomainInput); + await user.type(primaryDomainInput, 'brand.example.com'); + + const secondaryDomainInput = screen.getByLabelText('域名 2'); + await user.clear(secondaryDomainInput); + await user.type(secondaryDomainInput, 'www.brand.example.com'); const saveButton = document.querySelector( 'button[form="proxy-route-domains-form"]', ) as HTMLButtonElement | null; - expect(saveButton).not.toBeNull(); - await user.click(saveButton!); + expect(saveButton).toBeInstanceOf(HTMLButtonElement); + if (!saveButton) { + throw new Error('missing domain save button'); + } + await user.click(saveButton); await waitFor(() => { expect(updateRequests).toHaveLength(1);