mirror of
https://github.com/Rain-kl/OpenFlare.git
synced 2026-09-29 05:56:38 +08:00
[功能] 更新域名列表输入组件,支持为每个域名选择证书并优化相关逻辑
This commit is contained in:
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
+124
-203
@@ -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}
|
||||
|
||||
+90
-28
@@ -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,
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user