[功能] 更新域名列表输入组件,支持为每个域名选择证书并优化相关逻辑

This commit is contained in:
ryan
2026-04-01 09:27:41 +08:00
parent cff815bd47
commit 77457250cf
5 changed files with 399 additions and 326 deletions
@@ -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 (
<div className="space-y-3">
{rows.map((row, index) => {
const suggestions = buildDomainSuggestions(row, normalizedSources, rows).slice(
0,
4,
);
<div className="space-y-4">
{safeRows.map((row, index) => {
const suggestions = buildDomainSuggestions(
row.domain,
normalizedSources,
safeRows,
).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">
<div key={`${index}-${safeRows.length}`} className="space-y-2">
<div className="grid gap-3 md:grid-cols-[72px_minmax(0,1fr)_280px] md:items-start">
<SecondaryButton
type="button"
aria-label={`删除域名输入框 ${index + 1}`}
className={actionButtonClassName}
disabled={safeRows.length === 1}
onClick={() => {
if (safeRows.length === 1) {
updateRows([{ domain: '', certificateId: '' }]);
return;
}
updateRows(
safeRows.filter((_, rowIndex) => rowIndex !== index),
);
}}
>
<Minus aria-hidden="true" className="h-[14px] w-[14px]" />
</SecondaryButton>
<div className="min-w-0 space-y-2">
<ResourceInput
value={row}
value={row.domain}
list={`${listId}-${index}`}
aria-label={`域名 ${index + 1}`}
placeholder={index === 0 ? placeholder : 'www.example.com'}
placeholder={index === 0 ? domainPlaceholder : 'www.example.com'}
onBlur={onBlur}
onChange={(event) => {
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"
/>
<datalist id={`${listId}-${index}`}>
{suggestions.map((suggestion) => (
<option key={suggestion} value={suggestion} />
))}
</datalist>
{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 = safeRows.slice();
nextRows[index] = {
...nextRows[index],
domain: suggestion,
};
updateRows(nextRows);
}}
>
{suggestion}
</button>
))}
</div>
) : null}
</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, '');
<ResourceSelect
aria-label={`证书 ${index + 1}`}
value={row.certificateId}
onChange={(event) => {
const nextRows = safeRows.slice();
nextRows[index] = {
...nextRows[index],
certificateId: event.target.value,
};
updateRows(nextRows);
}}
className="h-12"
>
+
</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>
<option value="">
{certificates.length === 0 ? '暂无可选证书' : '选择证书'}
</option>
{certificates.map((certificate) => (
<option key={certificate.id} value={certificate.id}>
{certificate.name}
</option>
))}
</div>
) : null}
</ResourceSelect>
</div>
</div>
);
})}
<SecondaryButton
type="button"
aria-label="新增域名输入框"
className={actionButtonClassName}
onClick={() => {
updateRows([...safeRows, { domain: '', certificateId: '' }]);
}}
>
<Plus aria-hidden="true" className="h-[14px] w-[14px]" />
</SecondaryButton>
</div>
);
}
@@ -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<typeof domainSettingsSchema>;
type RateLimitValues = z.infer<typeof rateLimitSchema>;
type ReverseProxyValues = z.infer<typeof reverseProxySchema>;
type HTTPSValues = z.infer<typeof httpsSchema>;
type CacheValues = z.infer<typeof cacheSchema>;
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 (
<ConfigSectionShell
title="域名设置"
description="配置站点。"
description="在一个列表里同时维护域名、证书和 HTTPS 跳转。保存时会自动汇总站点证书集合。"
formId="proxy-route-domains-form"
saving={saving}
>
@@ -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: '域名设置已保存。' },
);
})}
>
<ToggleField
label="启用站点"
description="关闭后站点会保留配置,但不会被纳入发布渲染。"
checked={form.watch('enabled')}
onChange={(checked) =>
form.setValue('enabled', checked, { shouldDirty: true })
}
label="启用站点"
description="关闭后会保留配置,但不会参与发布。"
checked={form.watch('enabled')}
onChange={(checked) =>
form.setValue('enabled', checked, { shouldDirty: true })
}
/>
<ResourceField
label="站点标识"
hint="建议使用稳定、可读的业务标识,不必与域名完全一致。"
@@ -303,23 +350,37 @@ function DomainSettingsSection({
<ResourceField
label="域名列表"
hint="一个输入框填写一个域名,点击右侧 + 可以继续追加。输入时会优先提示已有域名后缀。"
error={form.formState.errors.domains_text?.message}
hint="每行配置一个域名。可为不同域名选择不同证书,相同证书也可以重复选择。"
error={form.formState.errors.domain_rows?.message as string | undefined}
>
<Controller
control={form.control}
name="domains_text"
name="domain_rows"
render={({ field }) => (
<DomainListInput
value={field.value}
rows={field.value}
onChange={field.onChange}
onBlur={field.onBlur}
suggestionSources={suggestionSources}
certificates={certificates}
/>
)}
/>
</ResourceField>
<ToggleField
label="HTTP 自动跳转到 HTTPS"
description={
selectedCertificateIDs.length > 0
? '开启后会额外生成 80 端口重定向规则。'
: '至少为一个域名选择证书后才能启用。'
}
checked={form.watch('redirect_http')}
disabled={selectedCertificateIDs.length === 0}
onChange={(checked) =>
form.setValue('redirect_http', checked, { shouldDirty: true })
}
/>
</form>
</ConfigSectionShell>
);
@@ -362,7 +423,7 @@ function RateLimitSection({
return (
<ConfigSectionShell
title="流量限制"
description="网站限流, 空值或 0 表示关闭。"
description="站点限流,空值或 0 表示关闭。"
formId="proxy-route-limits-form"
saving={saving}
>
@@ -384,7 +445,7 @@ function RateLimitSection({
>
<ResourceField
label="并发限制"
hint="限制当前站点最大并发数"
hint="限制当前站点最大并发连接数。"
error={form.formState.errors.limit_conn_per_server?.message}
>
<ResourceInput
@@ -394,8 +455,8 @@ function RateLimitSection({
</ResourceField>
<ResourceField
label="单IP限制"
hint="限制单个IP访问最大并发数"
label="单 IP 限制"
hint="限制单个 IP 的最大并发数。"
error={form.formState.errors.limit_conn_per_ip?.message}
>
<ResourceInput
@@ -405,8 +466,8 @@ function RateLimitSection({
</ResourceField>
<ResourceField
label="流量限制"
hint="限制每个请求的流量上限。"
label="限速"
hint="限制单请求带宽,例如 512k 或 1m。"
error={form.formState.errors.limit_rate?.message}
className="md:col-span-2"
>
@@ -448,7 +509,7 @@ function ReverseProxySection({
return (
<ConfigSectionShell
title="反向代理"
description="第一行作为主回源;如果填写多行,会自动进入多上游负载均衡模式。"
description="第一行作为主回源;填写多行时会自动进入多上游负载均衡模式。"
formId="proxy-route-proxy-form"
saving={saving}
>
@@ -513,10 +574,7 @@ function ReverseProxySection({
/>
</ResourceField>
<ResourceField
label="备注"
error={form.formState.errors.remark?.message}
>
<ResourceField label="备注" error={form.formState.errors.remark?.message}>
<ResourceTextarea
placeholder="例如:多活回源,优先使用上海入口"
{...form.register('remark')}
@@ -527,134 +585,6 @@ function ReverseProxySection({
);
}
function HTTPSSection({
route,
certificates,
saving,
onSave,
}: {
route: ProxyRouteItem;
certificates: TlsCertificateItem[];
saving: boolean;
onSave: SaveHandler;
}) {
const form = useForm<HTTPSValues>({
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 (
<ConfigSectionShell
title="HTTPS"
description="启用后必须选择覆盖当前全部域名的证书。发布时服务端会再次验证证书覆盖范围。"
formId="proxy-route-https-form"
saving={saving}
>
<form
id="proxy-route-https-form"
className="space-y-5"
onSubmit={form.handleSubmit((values) => {
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 设置已保存。' },
);
})}
>
<ToggleField
label="启用 HTTPS"
description="关闭后站点只会渲染 HTTP server。"
checked={watchedEnableHTTPS}
onChange={(checked) => {
form.setValue('enable_https', checked, { shouldDirty: true });
if (!checked) {
form.setValue('cert_ids', [], { shouldDirty: true });
form.setValue('redirect_http', false, { shouldDirty: true });
}
}}
/>
<ResourceField
label="证书"
error={form.formState.errors.cert_ids?.message}
hint="请确保该证书能覆盖当前站点的全部域名。"
>
<ResourceSelect
multiple
size={Math.min(Math.max(certificates.length, 4), 8)}
className="min-h-44"
disabled={!watchedEnableHTTPS}
{...form.register('cert_ids')}
>
<option value="">请选择证书</option>
{certificates.map((certificate) => (
<option key={certificate.id} value={certificate.id}>
{certificate.not_after
? `${certificate.name} · ${certificate.not_after}`
: certificate.name}
</option>
))}
</ResourceSelect>
{watchedEnableHTTPS && watchedCertIDs.length > 0 ? (
<p className="text-xs leading-5 text-[var(--foreground-secondary)]">
已选择 {watchedCertIDs.length} 张证书,发布时会校验证书集合是否覆盖全部域名。
</p>
) : null}
</ResourceField>
<ToggleField
label="HTTP 自动跳转到 HTTPS"
description="开启后会生成额外的 80 端口重定向 server。"
checked={form.watch('redirect_http')}
disabled={!watchedEnableHTTPS}
onChange={(checked) =>
form.setValue('redirect_http', checked, { shouldDirty: true })
}
/>
</form>
</ConfigSectionShell>
);
}
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 (
<EmptyState
title="缺少网站 ID"
description="请从网站列表进入配置子页面。"
title="缺少站点 ID"
description="请从站点列表进入配置页面。"
/>
);
}
@@ -843,7 +774,7 @@ export function ProxyRouteConfigPage({
if (routeQuery.isError) {
return (
<ErrorState
title="网站详情加载失败"
title="站点详情加载失败"
description={getErrorMessage(routeQuery.error)}
/>
);
@@ -861,8 +792,8 @@ export function ProxyRouteConfigPage({
if (!route) {
return (
<EmptyState
title="网站不存在"
description="该网站可能已被删除,或当前 ID 无法匹配到记录。"
title="站点不存在"
description="该站点可能已被删除,或当前 ID 无法匹配到记录。"
/>
);
}
@@ -900,7 +831,7 @@ export function ProxyRouteConfigPage({
<div className="grid gap-6 xl:grid-cols-[280px_minmax(0,1fr)]">
<aside className="space-y-4">
<AppCard title="配置分区" >
<AppCard title="配置分区">
<div className="space-y-2">
{websiteConfigSections.map((section) => {
const active = section.key === currentSection;
@@ -932,6 +863,7 @@ export function ProxyRouteConfigPage({
{currentSection === 'domains' ? (
<DomainSettingsSection
route={route}
certificates={certificates}
saving={saveMutation.isPending}
suggestionSources={domainSuggestionSources}
onSave={(payload, context) =>
@@ -960,17 +892,6 @@ export function ProxyRouteConfigPage({
/>
) : null}
{currentSection === 'https' ? (
<HTTPSSection
route={route}
certificates={certificates}
saving={saveMutation.isPending}
onSave={(payload, context) =>
saveMutation.mutate({ payload, context })
}
/>
) : null}
{currentSection === 'cache' ? (
<CacheSection
route={route}
@@ -2,23 +2,26 @@
import { zodResolver } from '@hookform/resolvers/zod';
import { useMutation, useQuery } from '@tanstack/react-query';
import { useEffect } from 'react';
import { useEffect, useMemo } from 'react';
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 {
DomainListInput,
type DomainListRow,
} from '@/features/proxy-routes/components/domain-list-input';
import {
buildOriginUrl,
getErrorMessage,
linesFromTextarea,
parseOriginUrl,
parseOriginUrls,
validateDomains,
} from '@/features/proxy-routes/helpers';buyao
} from '@/features/proxy-routes/helpers';
import type { ProxyRouteItem } from '@/features/proxy-routes/types';
import { getTlsCertificates } from '@/features/tls-certificates/api/tls-certificates';
import {
PrimaryButton,
ResourceField,
@@ -27,23 +30,29 @@ import {
ToggleField,
} from '@/features/shared/components/resource-primitives';
const domainRowSchema = z.object({
domain: z.string(),
certificateId: z.string(),
});
const createWebsiteSchema = z
.object({
site_name: z.string().trim().max(255, '站点标识不能超过 255 个字符'),
domains_text: z.string().trim().min(1, '请至少填写一个域名'),
domain_rows: z.array(domainRowSchema).min(1),
origin_urls_text: z.string().trim().min(1, '请至少填写一个上游地址'),
enabled: z.boolean(),
redirect_http: z.boolean(),
remark: z.string().max(255, '备注不能超过 255 个字符'),
})
.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 domainError = validateDomains(domains);
if (domainError) {
context.addIssue({
code: z.ZodIssueCode.custom,
path: ['domains_text'],
path: ['domain_rows'],
message: domainError,
});
}
@@ -56,18 +65,42 @@ const createWebsiteSchema = z
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 跳转前,请先为域名选择证书',
});
}
});
type CreateWebsiteFormValues = z.infer<typeof createWebsiteSchema>;
const defaultValues: CreateWebsiteFormValues = {
site_name: '',
domains_text: '',
domain_rows: [{ domain: '', certificateId: '' }],
origin_urls_text: '',
enabled: true,
redirect_http: false,
remark: '',
};
function normalizeSelectedCertificateIDs(rows: DomainListRow[]) {
return Array.from(
new Set(
rows
.map((item) => Number(item.certificateId))
.filter((item) => Number.isFinite(item) && item > 0),
),
);
}
export function ProxyRouteCreateDrawer({
open,
onOpenChange,
@@ -88,16 +121,29 @@ export function ProxyRouteCreateDrawer({
queryFn: getManagedDomains,
enabled: open,
});
const combinedDomainSuggestions = [
...domainSuggestionSources,
...(managedDomainsQuery.data?.map((item) => item.domain) ?? []),
];
const certificatesQuery = useQuery({
queryKey: ['tls-certificates', 'list'],
queryFn: getTlsCertificates,
enabled: open,
});
const combinedDomainSuggestions = useMemo(
() => [
...domainSuggestionSources,
...(managedDomainsQuery.data?.map((item) => item.domain) ?? []),
],
[domainSuggestionSources, managedDomainsQuery.data],
);
const selectedCertificateIDs = normalizeSelectedCertificateIDs(
form.watch('domain_rows'),
);
const createMutation = useMutation({
mutationFn: async (values: CreateWebsiteFormValues) => {
const domains = linesFromTextarea(values.domains_text).map((item) =>
item.toLowerCase(),
);
const domains = values.domain_rows
.map((item) => item.domain.trim().toLowerCase())
.filter(Boolean);
const selectedCertIDs = normalizeSelectedCertificateIDs(values.domain_rows);
const { urls } = parseOriginUrls(values.origin_urls_text);
const primaryOrigin = parseOriginUrl(urls[0]);
@@ -119,9 +165,10 @@ export function ProxyRouteCreateDrawer({
origin_host: '',
upstreams: urls.slice(1),
enabled: values.enabled,
enable_https: false,
cert_id: null,
redirect_http: false,
enable_https: selectedCertIDs.length > 0,
cert_id: selectedCertIDs[0] ?? null,
cert_ids: selectedCertIDs,
redirect_http: selectedCertIDs.length > 0 ? values.redirect_http : false,
limit_conn_per_server: 0,
limit_conn_per_ip: 0,
limit_rate: '',
@@ -170,7 +217,7 @@ export function ProxyRouteCreateDrawer({
>
<ResourceField
label="站点标识"
hint="可选。留空时会自动使用第一个域名。"
hint="可选,留空时会自动使用第一个域名。"
error={form.formState.errors.site_name?.message}
>
<ResourceInput
@@ -181,26 +228,41 @@ export function ProxyRouteCreateDrawer({
<ResourceField
label="域名列表"
hint="一个输入框填写一个域名,点击右侧 + 可以继续追加。输入时会优先提示已有域名后缀。"
error={form.formState.errors.domains_text?.message}
hint="每行配置一个域名,可按需为该行选择证书。保存时会自动汇总站点证书集合。"
error={form.formState.errors.domain_rows?.message as string | undefined}
>
<Controller
control={form.control}
name="domains_text"
name="domain_rows"
render={({ field }) => (
<DomainListInput
value={field.value}
rows={field.value}
onChange={field.onChange}
onBlur={field.onBlur}
suggestionSources={combinedDomainSuggestions}
certificates={certificatesQuery.data ?? []}
/>
)}
/>
</ResourceField>
<ToggleField
label="HTTP 自动跳转到 HTTPS"
description={
selectedCertificateIDs.length > 0
? '勾选后会额外生成 80 端口重定向规则。'
: '至少为一个域名选择证书后才能启用。'
}
checked={form.watch('redirect_http')}
disabled={selectedCertificateIDs.length === 0}
onChange={(checked) =>
form.setValue('redirect_http', checked, { shouldDirty: true })
}
/>
<ResourceField
label="上游地址"
hint="每行一个完整 URL。第一行作为主回源,多上游模式下请保持相同协议且不要带 path/query。"
hint="每行一个完整 URL。第一行作为主回源,多上游模式请保持相同协议且不要包含 path 或 query。"
error={form.formState.errors.origin_urls_text?.message}
>
<ResourceTextarea
@@ -212,7 +274,7 @@ export function ProxyRouteCreateDrawer({
<ToggleField
label="创建后立即启用"
description="关闭后网站会先以草稿形式保存,发布配置前仍可继续编辑。"
description="关闭后站点会以草稿保存,后续仍可继续编辑。"
checked={form.watch('enabled')}
onChange={(checked) =>
form.setValue('enabled', checked, { shouldDirty: true })
@@ -223,7 +285,7 @@ export function ProxyRouteCreateDrawer({
label="备注"
error={form.formState.errors.remark?.message}
>
<ResourceTextarea placeholder="" {...form.register('remark')} />
<ResourceTextarea {...form.register('remark')} />
</ResourceField>
{createMutation.isError ? (
@@ -8,7 +8,7 @@ export const websiteConfigSections = [
{
key: 'domains',
label: '域名设置',
description: '维护站点标识。',
description: '维护站点标识、域名列表和证书绑定。',
},
{
key: 'limits',
@@ -18,21 +18,17 @@ export const websiteConfigSections = [
{
key: 'proxy',
label: '反向代理',
description: '配置主回源、上游。',
},
{
key: 'https',
label: 'HTTPS',
description: '证书设置。',
description: '配置主回源和上游地址。',
},
{
key: 'cache',
label: '缓存',
description: '页面缓存。',
description: '配置站点缓存策略。',
},
] as const;
export type WebsiteConfigSectionKey = (typeof websiteConfigSections)[number]['key'];
export type WebsiteConfigSectionKey =
(typeof websiteConfigSections)[number]['key'];
const domainPattern =
/^(?=.{1,253}$)(?:[a-z0-9](?:[a-z0-9-]{0,61}[a-z0-9])?\.)+[a-z]{2,63}$/i;
@@ -130,8 +126,7 @@ export function parseOriginUrls(value: string) {
export function parseOriginUrl(originUrl: string) {
const parsed = new URL(originUrl);
const port =
parsed.port || (parsed.protocol === 'http:' ? '80' : '443');
const port = parsed.port || (parsed.protocol === 'http:' ? '80' : '443');
const path = parsed.pathname === '/' ? '' : parsed.pathname;
return {
@@ -173,7 +168,7 @@ export function validateOriginHost(value: string) {
/[\/\\\s]/.test(normalized) ||
!originHostPattern.test(normalized)
) {
return '回源主机名格式不合法';
return '回源 Host 格式不合法';
}
return null;
}
@@ -250,11 +245,7 @@ export function validateCacheRules(
}
for (const rule of rules) {
if (
!rule.startsWith('/') ||
rule.includes('://') ||
/[\s]/.test(rule)
) {
if (!rule.startsWith('/') || rule.includes('://') || /[\s]/.test(rule)) {
return `缓存路径规则格式不合法:${rule}`;
}
}
@@ -49,6 +49,7 @@ function buildRoute(overrides: Record<string, unknown> = {}) {
enabled: true,
enable_https: true,
cert_id: 1,
cert_ids: [1],
redirect_http: true,
limit_conn_per_server: 120,
limit_conn_per_ip: 12,
@@ -181,9 +182,10 @@ describe('Proxy route website pages', () => {
upstreams: JSON.stringify([payload.origin_url, ...payload.upstreams]),
upstream_list: [payload.origin_url, ...payload.upstreams],
enabled: payload.enabled,
enable_https: false,
cert_id: null,
redirect_http: false,
enable_https: payload.enable_https,
cert_id: payload.cert_id,
cert_ids: payload.cert_ids ?? [],
redirect_http: payload.redirect_http,
limit_conn_per_server: 0,
limit_conn_per_ip: 0,
limit_rate: '',
@@ -244,6 +246,18 @@ describe('Proxy route website pages', () => {
);
}
if (url.includes('/tls-certificates/')) {
return Promise.resolve(
new Response(
JSON.stringify({
success: true,
message: '',
data: [{ id: 1, name: 'example-cert', not_after: null }],
}),
),
);
}
return Promise.reject(new Error(`Unhandled fetch: ${url}`));
}),
);
@@ -257,13 +271,18 @@ describe('Proxy route website pages', () => {
const dialog = await screen.findByRole('dialog');
expect(dialog).toBeInTheDocument();
await user.type(within(dialog).getByPlaceholderText('marketing-site'), '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(
await within(dialog).findByRole('button', { name: 'app.example.com' }),
);
await user.click(within(dialog).getByLabelText('新增域名输入框 1'));
await user.click(within(dialog).getByLabelText('新增域名输入框'));
await user.type(within(dialog).getByLabelText('域名 2'), 'www.example.com');
await user.type(
@@ -315,6 +334,10 @@ describe('Proxy route website pages', () => {
primary_domain: (payload.domains as string[])[0],
domain_count: (payload.domains as string[]).length,
enabled: payload.enabled,
enable_https: payload.enable_https,
cert_id: payload.cert_id,
cert_ids: payload.cert_ids,
redirect_http: payload.redirect_http,
}),
}),
),
@@ -380,6 +403,9 @@ describe('Proxy route website pages', () => {
await user.clear(secondaryDomainInput);
await user.type(secondaryDomainInput, 'www.brand.example.com');
await user.selectOptions(screen.getByLabelText('证书 1'), '1');
await user.selectOptions(screen.getByLabelText('证书 2'), '1');
const saveButton = document.querySelector(
'button[form="proxy-route-domains-form"]',
) as HTMLButtonElement | null;
@@ -398,6 +424,10 @@ describe('Proxy route website pages', () => {
domain: 'brand.example.com',
domains: ['brand.example.com', 'www.brand.example.com'],
enabled: true,
enable_https: true,
cert_id: 1,
cert_ids: [1],
redirect_http: true,
});
});
});