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

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 { 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<DomainSettingsValues>({
resolver: zodResolver(domainSettingsSchema),
@@ -303,13 +303,20 @@ function DomainSettingsSection({
<ResourceField
label="域名列表"
hint="每行一个域名,第一行会作为主域名,并用于列表摘要和兼容镜像。"
hint="一个输入框填写一个域名,点击右侧 + 可以继续追加。输入时会优先提示已有域名后缀。"
error={form.formState.errors.domains_text?.message}
>
<ResourceTextarea
className="min-h-36"
placeholder={'app.example.com\nwww.example.com'}
{...form.register('domains_text')}
<Controller
control={form.control}
name="domains_text"
render={({ field }) => (
<DomainListInput
value={field.value}
onChange={field.onChange}
onBlur={field.onBlur}
suggestionSources={suggestionSources}
/>
)}
/>
</ResourceField>
@@ -476,6 +483,7 @@ function ReverseProxySection({
error={form.formState.errors.origin_urls_text?.message}
>
<ResourceTextarea
aria-label="上游地址"
className="min-h-40"
placeholder={'https://origin-a.internal:443\nhttps://origin-b.internal:443'}
{...form.register('origin_urls_text')}
@@ -743,6 +751,10 @@ export function ProxyRouteConfigPage({
queryKey: ['tls-certificates', 'list'],
queryFn: getTlsCertificates,
});
const managedDomainsQuery = useQuery({
queryKey: ['managed-domains'],
queryFn: getManagedDomains,
});
const saveMutation = useMutation({
mutationFn: async ({
@@ -776,6 +788,12 @@ export function ProxyRouteConfigPage({
() => 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 (
<div className="space-y-6">
<PageHeader
@@ -887,6 +901,7 @@ export function ProxyRouteConfigPage({
<DomainSettingsSection
route={route}
saving={saveMutation.isPending}
suggestionSources={domainSuggestionSources}
onSave={(payload, context) =>
saveMutation.mutate({ payload, context })
}
@@ -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<CreateWebsiteFormValues>({
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({
>
<ResourceField
label="站点标识"
hint="可选。留空时会自动使用第一行域名。"
hint="可选。留空时会自动使用第一个域名。"
error={form.formState.errors.site_name?.message}
>
<input
<ResourceInput
{...form.register('site_name')}
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
label="域名列表"
hint="每行一个域名,第一行会作为主域名。"
hint="一个输入框填写一个域名,点击右侧 + 可以继续追加。输入时会优先提示已有域名后缀。"
error={form.formState.errors.domains_text?.message}
>
<ResourceTextarea
placeholder={'app.example.com\nwww.example.com'}
{...form.register('domains_text')}
<Controller
control={form.control}
name="domains_text"
render={({ field }) => (
<DomainListInput
value={field.value}
onChange={field.onChange}
onBlur={field.onBlur}
suggestionSources={combinedDomainSuggestions}
/>
)}
/>
</ResourceField>
@@ -183,6 +204,7 @@ export function ProxyRouteCreateDrawer({
error={form.formState.errors.origin_urls_text?.message}
>
<ResourceTextarea
aria-label="上游地址"
placeholder={'https://origin-a.internal:443\nhttps://origin-b.internal:443'}
{...form.register('origin_urls_text')}
/>
@@ -201,10 +223,7 @@ export function ProxyRouteCreateDrawer({
label="备注"
error={form.formState.errors.remark?.message}
>
<ResourceTextarea
placeholder=""
{...form.register('remark')}
/>
<ResourceTextarea placeholder="" {...form.register('remark')} />
</ResourceField>
{createMutation.isError ? (
@@ -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() {
<ProxyRouteCreateDrawer
open={isCreateOpen}
onOpenChange={setIsCreateOpen}
domainSuggestionSources={routes.flatMap((route) => route.domains)}
onCreated={async (route) => {
await Promise.all([
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}`));
}),
);
@@ -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);