[功能] 添加域名列表输入组件,支持动态建议和批量输入

This commit is contained in:
ryan
2026-03-31 13:29:30 +08:00
parent 355791f2e4
commit 97fa56b1af
5 changed files with 299 additions and 45 deletions
@@ -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<string>();
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 (
<div className="space-y-3">
{rows.map((row, index) => {
const suggestions = buildDomainSuggestions(row, normalizedSources, rows).slice(
0,
4,
);
return (
<div key={`${index}-${rows.length}`} className="space-y-2">
<div className="flex items-start gap-2">
<div className="min-w-0 flex-1">
<ResourceInput
value={row}
list={`${listId}-${index}`}
aria-label={`域名 ${index + 1}`}
placeholder={index === 0 ? placeholder : 'www.example.com'}
onBlur={onBlur}
onChange={(event) => {
const nextRows = rows.slice();
nextRows[index] = event.target.value;
updateRows(nextRows);
}}
/>
<datalist id={`${listId}-${index}`}>
{suggestions.map((suggestion) => (
<option key={suggestion} value={suggestion} />
))}
</datalist>
</div>
<SecondaryButton
type="button"
aria-label={`新增域名输入框 ${index + 1}`}
className="h-[46px] min-w-11 px-0"
onClick={() => {
const nextRows = rows.slice();
nextRows.splice(index + 1, 0, '');
updateRows(nextRows);
}}
>
+
</SecondaryButton>
<SecondaryButton
type="button"
aria-label={`删除域名输入框 ${index + 1}`}
className="h-[46px] min-w-11 px-0"
disabled={rows.length === 1}
onClick={() => {
if (rows.length === 1) {
updateRows(['']);
return;
}
updateRows(rows.filter((_, rowIndex) => rowIndex !== index));
}}
>
-
</SecondaryButton>
</div>
{suggestions.length > 0 ? (
<div className="flex flex-wrap gap-2">
{suggestions.map((suggestion) => (
<button
key={suggestion}
type="button"
className="inline-flex items-center rounded-full border border-[var(--border-default)] bg-[var(--surface-panel)] px-3 py-1 text-xs text-[var(--foreground-secondary)] transition hover:border-[var(--border-strong)] hover:text-[var(--foreground-primary)]"
onClick={() => {
const nextRows = rows.slice();
nextRows[index] = suggestion;
updateRows(nextRows);
}}
>
{suggestion}
</button>
))}
</div>
) : null}
</div>
);
})}
</div>
);
}
@@ -5,7 +5,7 @@ import { zodResolver } from '@hookform/resolvers/zod';
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'; import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
import { useEffect, useMemo, useState } from 'react'; import { useEffect, useMemo, useState } from 'react';
import type { ReactNode } 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 { z } from 'zod';
import { EmptyState } from '@/components/feedback/empty-state'; 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 { LoadingState } from '@/components/feedback/loading-state';
import { PageHeader } from '@/components/layout/page-header'; import { PageHeader } from '@/components/layout/page-header';
import { AppCard } from '@/components/ui/app-card'; 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 { getTlsCertificates } from '@/features/tls-certificates/api/tls-certificates';
import type { TlsCertificateItem } from '@/features/tls-certificates/types'; import type { TlsCertificateItem } from '@/features/tls-certificates/types';
import { import {
getProxyRoute, getProxyRoute,
updateProxyRoute, updateProxyRoute,
} from '@/features/proxy-routes/api/proxy-routes'; } from '@/features/proxy-routes/api/proxy-routes';
import { DomainListInput } from '@/features/proxy-routes/components/domain-list-input';
import { import {
buildPayloadFromRoute, buildPayloadFromRoute,
customHeadersToText, customHeadersToText,
getErrorMessage, getErrorMessage,
getWebsiteConfigSection, getWebsiteConfigSection,
getWebsiteStatusBadges,
linesFromTextarea, linesFromTextarea,
normalizeLimitRate, normalizeLimitRate,
parseCustomHeadersText, parseCustomHeadersText,
@@ -37,7 +37,6 @@ import {
validateLimitRate, validateLimitRate,
validateOriginHost, validateOriginHost,
websiteConfigSections, websiteConfigSections,
type WebsiteConfigSectionKey,
} from '@/features/proxy-routes/helpers'; } from '@/features/proxy-routes/helpers';
import type { import type {
ProxyRouteItem, ProxyRouteItem,
@@ -53,7 +52,6 @@ import {
ToggleField, ToggleField,
} from '@/features/shared/components/resource-primitives'; } from '@/features/shared/components/resource-primitives';
import { cn } from '@/lib/utils/cn'; import { cn } from '@/lib/utils/cn';
import { formatDateTime } from '@/lib/utils/date';
type FeedbackState = { type FeedbackState = {
tone: 'success' | 'danger'; tone: 'success' | 'danger';
@@ -234,10 +232,12 @@ function DomainSettingsSection({
route, route,
saving, saving,
onSave, onSave,
suggestionSources,
}: { }: {
route: ProxyRouteItem; route: ProxyRouteItem;
saving: boolean; saving: boolean;
onSave: SaveHandler; onSave: SaveHandler;
suggestionSources: string[];
}) { }) {
const form = useForm<DomainSettingsValues>({ const form = useForm<DomainSettingsValues>({
resolver: zodResolver(domainSettingsSchema), resolver: zodResolver(domainSettingsSchema),
@@ -303,13 +303,20 @@ function DomainSettingsSection({
<ResourceField <ResourceField
label="域名列表" label="域名列表"
hint="每行一个域名,第一行会作为主域名,并用于列表摘要和兼容镜像。" hint="一个输入框填写一个域名,点击右侧 + 可以继续追加。输入时会优先提示已有域名后缀。"
error={form.formState.errors.domains_text?.message} error={form.formState.errors.domains_text?.message}
> >
<ResourceTextarea <Controller
className="min-h-36" control={form.control}
placeholder={'app.example.com\nwww.example.com'} name="domains_text"
{...form.register('domains_text')} render={({ field }) => (
<DomainListInput
value={field.value}
onChange={field.onChange}
onBlur={field.onBlur}
suggestionSources={suggestionSources}
/>
)}
/> />
</ResourceField> </ResourceField>
@@ -476,6 +483,7 @@ function ReverseProxySection({
error={form.formState.errors.origin_urls_text?.message} error={form.formState.errors.origin_urls_text?.message}
> >
<ResourceTextarea <ResourceTextarea
aria-label="上游地址"
className="min-h-40" className="min-h-40"
placeholder={'https://origin-a.internal:443\nhttps://origin-b.internal:443'} placeholder={'https://origin-a.internal:443\nhttps://origin-b.internal:443'}
{...form.register('origin_urls_text')} {...form.register('origin_urls_text')}
@@ -743,6 +751,10 @@ export function ProxyRouteConfigPage({
queryKey: ['tls-certificates', 'list'], queryKey: ['tls-certificates', 'list'],
queryFn: getTlsCertificates, queryFn: getTlsCertificates,
}); });
const managedDomainsQuery = useQuery({
queryKey: ['managed-domains'],
queryFn: getManagedDomains,
});
const saveMutation = useMutation({ const saveMutation = useMutation({
mutationFn: async ({ mutationFn: async ({
@@ -776,6 +788,12 @@ export function ProxyRouteConfigPage({
() => certificatesQuery.data ?? [], () => certificatesQuery.data ?? [],
[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) { if (!Number.isFinite(numericRouteID) || numericRouteID <= 0) {
return ( return (
@@ -817,10 +835,6 @@ export function ProxyRouteConfigPage({
); );
} }
const currentSectionMeta =
websiteConfigSections.find((section) => section.key === currentSection) ??
websiteConfigSections[0];
return ( return (
<div className="space-y-6"> <div className="space-y-6">
<PageHeader <PageHeader
@@ -887,6 +901,7 @@ export function ProxyRouteConfigPage({
<DomainSettingsSection <DomainSettingsSection
route={route} route={route}
saving={saveMutation.isPending} saving={saveMutation.isPending}
suggestionSources={domainSuggestionSources}
onSave={(payload, context) => onSave={(payload, context) =>
saveMutation.mutate({ payload, context }) saveMutation.mutate({ payload, context })
} }
@@ -1,13 +1,15 @@
'use client'; 'use client';
import { zodResolver } from '@hookform/resolvers/zod'; import { zodResolver } from '@hookform/resolvers/zod';
import { useMutation } from '@tanstack/react-query'; import { useMutation, useQuery } from '@tanstack/react-query';
import { useEffect } from 'react'; import { useEffect } from 'react';
import { useForm } from 'react-hook-form'; import { Controller, useForm } from 'react-hook-form';
import { z } from 'zod'; import { z } from 'zod';
import { Drawer } from '@/components/ui/drawer'; 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 { createProxyRoute } from '@/features/proxy-routes/api/proxy-routes';
import { DomainListInput } from '@/features/proxy-routes/components/domain-list-input';
import { import {
buildOriginUrl, buildOriginUrl,
getErrorMessage, getErrorMessage,
@@ -20,6 +22,7 @@ import type { ProxyRouteItem } from '@/features/proxy-routes/types';
import { import {
PrimaryButton, PrimaryButton,
ResourceField, ResourceField,
ResourceInput,
ResourceTextarea, ResourceTextarea,
ToggleField, ToggleField,
} from '@/features/shared/components/resource-primitives'; } from '@/features/shared/components/resource-primitives';
@@ -69,15 +72,26 @@ export function ProxyRouteCreateDrawer({
open, open,
onOpenChange, onOpenChange,
onCreated, onCreated,
domainSuggestionSources = [],
}: { }: {
open: boolean; open: boolean;
onOpenChange: (open: boolean) => void; onOpenChange: (open: boolean) => void;
onCreated: (route: ProxyRouteItem) => void; onCreated: (route: ProxyRouteItem) => void;
domainSuggestionSources?: string[];
}) { }) {
const form = useForm<CreateWebsiteFormValues>({ const form = useForm<CreateWebsiteFormValues>({
resolver: zodResolver(createWebsiteSchema), resolver: zodResolver(createWebsiteSchema),
defaultValues, defaultValues,
}); });
const managedDomainsQuery = useQuery({
queryKey: ['managed-domains'],
queryFn: getManagedDomains,
enabled: open,
});
const combinedDomainSuggestions = [
...domainSuggestionSources,
...(managedDomainsQuery.data?.map((item) => item.domain) ?? []),
];
const createMutation = useMutation({ const createMutation = useMutation({
mutationFn: async (values: CreateWebsiteFormValues) => { mutationFn: async (values: CreateWebsiteFormValues) => {
@@ -156,24 +170,31 @@ export function ProxyRouteCreateDrawer({
> >
<ResourceField <ResourceField
label="站点标识" label="站点标识"
hint="可选。留空时会自动使用第一行域名。" hint="可选。留空时会自动使用第一个域名。"
error={form.formState.errors.site_name?.message} error={form.formState.errors.site_name?.message}
> >
<input <ResourceInput
{...form.register('site_name')} {...form.register('site_name')}
placeholder="marketing-site" placeholder="marketing-site"
className="w-full rounded-2xl border border-[var(--border-default)] bg-[var(--surface-elevated)] px-4 py-3 text-sm text-[var(--foreground-primary)] outline-none transition focus:border-[var(--border-strong)]"
/> />
</ResourceField> </ResourceField>
<ResourceField <ResourceField
label="域名列表" label="域名列表"
hint="每行一个域名,第一行会作为主域名。" hint="一个输入框填写一个域名,点击右侧 + 可以继续追加。输入时会优先提示已有域名后缀。"
error={form.formState.errors.domains_text?.message} error={form.formState.errors.domains_text?.message}
> >
<ResourceTextarea <Controller
placeholder={'app.example.com\nwww.example.com'} control={form.control}
{...form.register('domains_text')} name="domains_text"
render={({ field }) => (
<DomainListInput
value={field.value}
onChange={field.onChange}
onBlur={field.onBlur}
suggestionSources={combinedDomainSuggestions}
/>
)}
/> />
</ResourceField> </ResourceField>
@@ -183,6 +204,7 @@ export function ProxyRouteCreateDrawer({
error={form.formState.errors.origin_urls_text?.message} error={form.formState.errors.origin_urls_text?.message}
> >
<ResourceTextarea <ResourceTextarea
aria-label="上游地址"
placeholder={'https://origin-a.internal:443\nhttps://origin-b.internal:443'} placeholder={'https://origin-a.internal:443\nhttps://origin-b.internal:443'}
{...form.register('origin_urls_text')} {...form.register('origin_urls_text')}
/> />
@@ -201,10 +223,7 @@ export function ProxyRouteCreateDrawer({
label="备注" label="备注"
error={form.formState.errors.remark?.message} error={form.formState.errors.remark?.message}
> >
<ResourceTextarea <ResourceTextarea placeholder="" {...form.register('remark')} />
placeholder=""
{...form.register('remark')}
/>
</ResourceField> </ResourceField>
{createMutation.isError ? ( {createMutation.isError ? (
@@ -33,7 +33,6 @@ import {
ResourceInput, ResourceInput,
SecondaryButton, SecondaryButton,
} from '@/features/shared/components/resource-primitives'; } from '@/features/shared/components/resource-primitives';
import { formatDateTime } from '@/lib/utils/date';
type FeedbackState = { type FeedbackState = {
tone: 'info' | 'success' | 'danger'; tone: 'info' | 'success' | 'danger';
@@ -388,6 +387,7 @@ export function ProxyRoutesPage() {
<ProxyRouteCreateDrawer <ProxyRouteCreateDrawer
open={isCreateOpen} open={isCreateOpen}
onOpenChange={setIsCreateOpen} onOpenChange={setIsCreateOpen}
domainSuggestionSources={routes.flatMap((route) => route.domains)}
onCreated={async (route) => { onCreated={async (route) => {
await Promise.all([ await Promise.all([
queryClient.invalidateQueries({ queryKey: ['proxy-routes'] }), queryClient.invalidateQueries({ queryKey: ['proxy-routes'] }),
@@ -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}`)); return Promise.reject(new Error(`Unhandled fetch: ${url}`));
}), }),
); );
@@ -245,22 +257,28 @@ describe('Proxy route website pages', () => {
const dialog = await screen.findByRole('dialog'); const dialog = await screen.findByRole('dialog');
expect(dialog).toBeInTheDocument(); expect(dialog).toBeInTheDocument();
const dialogTextboxes = within(dialog).getAllByRole('textbox'); await user.type(within(dialog).getByPlaceholderText('marketing-site'), 'launch-site');
await user.type(dialogTextboxes[0], '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( await user.type(
dialogTextboxes[1], within(dialog).getByLabelText('上游地址'),
'app.example.com{enter}www.example.com',
);
await user.type(
dialogTextboxes[2],
'https://origin-a.internal:443{enter}https://origin-b.internal:443', 'https://origin-a.internal:443{enter}https://origin-b.internal:443',
); );
const submitButton = document.querySelector( const submitButton = document.querySelector(
'button[form="create-website-form"]', 'button[form="create-website-form"]',
) as HTMLButtonElement | null; ) as HTMLButtonElement | null;
expect(submitButton).not.toBeNull(); expect(submitButton).toBeInstanceOf(HTMLButtonElement);
await user.click(submitButton!); if (!submitButton) {
throw new Error('missing create submit button');
}
await user.click(submitButton);
await waitFor(() => { await waitFor(() => {
expect(pushMock).toHaveBeenCalledWith( 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}`)); return Promise.reject(new Error(`Unhandled fetch: ${url}`));
}), }),
); );
@@ -338,21 +368,26 @@ describe('Proxy route website pages', () => {
const user = userEvent.setup(); const user = userEvent.setup();
expect(await screen.findByText('marketing-site')).toBeInTheDocument(); 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.clear(siteNameInput);
await user.type(siteNameInput, 'brand-site'); await user.type(siteNameInput, 'brand-site');
await user.clear(domainsText); const primaryDomainInput = screen.getByLabelText('域名 1');
await user.type( await user.clear(primaryDomainInput);
domainsText, await user.type(primaryDomainInput, 'brand.example.com');
'brand.example.com{enter}www.brand.example.com',
); const secondaryDomainInput = screen.getByLabelText('域名 2');
await user.clear(secondaryDomainInput);
await user.type(secondaryDomainInput, 'www.brand.example.com');
const saveButton = document.querySelector( const saveButton = document.querySelector(
'button[form="proxy-route-domains-form"]', 'button[form="proxy-route-domains-form"]',
) as HTMLButtonElement | null; ) as HTMLButtonElement | null;
expect(saveButton).not.toBeNull(); expect(saveButton).toBeInstanceOf(HTMLButtonElement);
await user.click(saveButton!); if (!saveButton) {
throw new Error('missing domain save button');
}
await user.click(saveButton);
await waitFor(() => { await waitFor(() => {
expect(updateRequests).toHaveLength(1); expect(updateRequests).toHaveLength(1);