mirror of
https://github.com/Rain-kl/OpenFlare.git
synced 2026-10-07 16:16:37 +08:00
[功能] 更新域名列表输入组件,支持为每个域名选择证书并优化相关逻辑
This commit is contained in:
@@ -1,15 +1,23 @@
|
|||||||
'use client';
|
'use client';
|
||||||
|
|
||||||
import { useId, useMemo } from 'react';
|
import { useId, useMemo } from 'react';
|
||||||
|
import { Minus, Plus } from 'lucide-react';
|
||||||
|
|
||||||
|
import type { TlsCertificateItem } from '@/features/tls-certificates/types';
|
||||||
import {
|
import {
|
||||||
ResourceInput,
|
ResourceInput,
|
||||||
|
ResourceSelect,
|
||||||
SecondaryButton,
|
SecondaryButton,
|
||||||
} from '@/features/shared/components/resource-primitives';
|
} from '@/features/shared/components/resource-primitives';
|
||||||
|
export type DomainListRow = {
|
||||||
|
domain: string;
|
||||||
|
certificateId: string;
|
||||||
|
};
|
||||||
|
|
||||||
function splitDomainRows(value: string) {
|
const actionButtonClassName = 'h-12 w-[72px] shrink-0 rounded-2xl px-0';
|
||||||
const rows = value.split(/\r?\n/);
|
|
||||||
return rows.length > 0 ? rows : [''];
|
function ensureRows(rows: DomainListRow[]) {
|
||||||
|
return rows.length > 0 ? rows : [{ domain: '', certificateId: '' }];
|
||||||
}
|
}
|
||||||
|
|
||||||
function buildSuggestionSources(domains: string[]) {
|
function buildSuggestionSources(domains: string[]) {
|
||||||
@@ -32,7 +40,11 @@ function buildSuggestionSources(domains: string[]) {
|
|||||||
return Array.from(values);
|
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();
|
const normalizedInput = input.trim().toLowerCase();
|
||||||
if (!normalizedInput) {
|
if (!normalizedInput) {
|
||||||
return [];
|
return [];
|
||||||
@@ -40,7 +52,7 @@ function buildDomainSuggestions(input: string, sources: string[], rows: string[]
|
|||||||
|
|
||||||
const existingDomains = new Set(
|
const existingDomains = new Set(
|
||||||
rows
|
rows
|
||||||
.map((row) => row.trim().toLowerCase())
|
.map((row) => row.domain.trim().toLowerCase())
|
||||||
.filter((row) => row && row !== normalizedInput),
|
.filter((row) => row && row !== normalizedInput),
|
||||||
);
|
);
|
||||||
const suggestions: string[] = [];
|
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({
|
export function DomainListInput({
|
||||||
value,
|
rows,
|
||||||
onChange,
|
onChange,
|
||||||
onBlur,
|
onBlur,
|
||||||
suggestionSources = [],
|
suggestionSources = [],
|
||||||
placeholder = 'app.example.com',
|
certificates = [],
|
||||||
|
domainPlaceholder = 'app.example.com',
|
||||||
}: {
|
}: {
|
||||||
value: string;
|
rows: DomainListRow[];
|
||||||
onChange: (value: string) => void;
|
onChange: (rows: DomainListRow[]) => void;
|
||||||
onBlur?: () => void;
|
onBlur?: () => void;
|
||||||
suggestionSources?: string[];
|
suggestionSources?: string[];
|
||||||
placeholder?: string;
|
certificates?: TlsCertificateItem[];
|
||||||
|
domainPlaceholder?: string;
|
||||||
}) {
|
}) {
|
||||||
const listId = useId();
|
const listId = useId();
|
||||||
const rows = splitDomainRows(value);
|
const safeRows = ensureRows(rows);
|
||||||
const normalizedSources = useMemo(
|
const normalizedSources = useMemo(
|
||||||
() => buildSuggestionSources(suggestionSources),
|
() => buildSuggestionSources(suggestionSources),
|
||||||
[suggestionSources],
|
[suggestionSources],
|
||||||
);
|
);
|
||||||
|
|
||||||
const updateRows = (nextRows: string[]) => {
|
const updateRows = (nextRows: DomainListRow[]) => {
|
||||||
onChange(nextRows.join('\n'));
|
onChange(ensureRows(nextRows));
|
||||||
};
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="space-y-3">
|
<div className="space-y-4">
|
||||||
{rows.map((row, index) => {
|
{safeRows.map((row, index) => {
|
||||||
const suggestions = buildDomainSuggestions(row, normalizedSources, rows).slice(
|
const suggestions = buildDomainSuggestions(
|
||||||
0,
|
row.domain,
|
||||||
4,
|
normalizedSources,
|
||||||
);
|
safeRows,
|
||||||
|
).slice(0, 4);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div key={`${index}-${rows.length}`} className="space-y-2">
|
<div key={`${index}-${safeRows.length}`} className="space-y-2">
|
||||||
<div className="flex items-start gap-2">
|
<div className="grid gap-3 md:grid-cols-[72px_minmax(0,1fr)_280px] md:items-start">
|
||||||
<div className="min-w-0 flex-1">
|
<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
|
<ResourceInput
|
||||||
value={row}
|
value={row.domain}
|
||||||
list={`${listId}-${index}`}
|
list={`${listId}-${index}`}
|
||||||
aria-label={`域名 ${index + 1}`}
|
aria-label={`域名 ${index + 1}`}
|
||||||
placeholder={index === 0 ? placeholder : 'www.example.com'}
|
placeholder={index === 0 ? domainPlaceholder : 'www.example.com'}
|
||||||
onBlur={onBlur}
|
onBlur={onBlur}
|
||||||
onChange={(event) => {
|
onChange={(event) => {
|
||||||
const nextRows = rows.slice();
|
const nextRows = safeRows.slice();
|
||||||
nextRows[index] = event.target.value;
|
nextRows[index] = {
|
||||||
|
...nextRows[index],
|
||||||
|
domain: event.target.value,
|
||||||
|
};
|
||||||
updateRows(nextRows);
|
updateRows(nextRows);
|
||||||
}}
|
}}
|
||||||
|
className="h-12"
|
||||||
/>
|
/>
|
||||||
<datalist id={`${listId}-${index}`}>
|
<datalist id={`${listId}-${index}`}>
|
||||||
{suggestions.map((suggestion) => (
|
{suggestions.map((suggestion) => (
|
||||||
<option key={suggestion} value={suggestion} />
|
<option key={suggestion} value={suggestion} />
|
||||||
))}
|
))}
|
||||||
</datalist>
|
</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>
|
</div>
|
||||||
|
|
||||||
<SecondaryButton
|
<ResourceSelect
|
||||||
type="button"
|
aria-label={`证书 ${index + 1}`}
|
||||||
aria-label={`新增域名输入框 ${index + 1}`}
|
value={row.certificateId}
|
||||||
className="h-[46px] min-w-11 px-0"
|
onChange={(event) => {
|
||||||
onClick={() => {
|
const nextRows = safeRows.slice();
|
||||||
const nextRows = rows.slice();
|
nextRows[index] = {
|
||||||
nextRows.splice(index + 1, 0, '');
|
...nextRows[index],
|
||||||
|
certificateId: event.target.value,
|
||||||
|
};
|
||||||
updateRows(nextRows);
|
updateRows(nextRows);
|
||||||
}}
|
}}
|
||||||
|
className="h-12"
|
||||||
>
|
>
|
||||||
+
|
<option value="">
|
||||||
</SecondaryButton>
|
{certificates.length === 0 ? '暂无可选证书' : '选择证书'}
|
||||||
|
</option>
|
||||||
<SecondaryButton
|
{certificates.map((certificate) => (
|
||||||
type="button"
|
<option key={certificate.id} value={certificate.id}>
|
||||||
aria-label={`删除域名输入框 ${index + 1}`}
|
{certificate.name}
|
||||||
className="h-[46px] min-w-11 px-0"
|
</option>
|
||||||
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>
|
</ResourceSelect>
|
||||||
) : null}
|
</div>
|
||||||
|
|
||||||
</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>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
+124
-203
@@ -15,13 +15,15 @@ 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 { getManagedDomains } from '@/features/managed-domains/api/managed-domains';
|
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 {
|
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 {
|
||||||
|
buildDomainRowsFromRoute,
|
||||||
|
DomainListInput,
|
||||||
|
type DomainListRow,
|
||||||
|
} from '@/features/proxy-routes/components/domain-list-input';
|
||||||
import {
|
import {
|
||||||
buildPayloadFromRoute,
|
buildPayloadFromRoute,
|
||||||
customHeadersToText,
|
customHeadersToText,
|
||||||
@@ -42,6 +44,8 @@ import type {
|
|||||||
ProxyRouteItem,
|
ProxyRouteItem,
|
||||||
ProxyRouteMutationPayload,
|
ProxyRouteMutationPayload,
|
||||||
} from '@/features/proxy-routes/types';
|
} from '@/features/proxy-routes/types';
|
||||||
|
import { getTlsCertificates } from '@/features/tls-certificates/api/tls-certificates';
|
||||||
|
import type { TlsCertificateItem } from '@/features/tls-certificates/types';
|
||||||
import {
|
import {
|
||||||
PrimaryButton,
|
PrimaryButton,
|
||||||
ResourceField,
|
ResourceField,
|
||||||
@@ -69,22 +73,47 @@ type SaveHandler = (
|
|||||||
|
|
||||||
const domainSettingsSchema = z
|
const domainSettingsSchema = z
|
||||||
.object({
|
.object({
|
||||||
site_name: z.string().trim().min(1, '请输入站点标识').max(255, '站点标识不能超过 255 个字符'),
|
site_name: z
|
||||||
domains_text: z.string().trim().min(1, '请至少填写一个域名'),
|
.string()
|
||||||
|
.trim()
|
||||||
|
.min(1, '请输入站点标识')
|
||||||
|
.max(255, '站点标识不能超过 255 个字符'),
|
||||||
|
domain_rows: z
|
||||||
|
.array(
|
||||||
|
z.object({
|
||||||
|
domain: z.string(),
|
||||||
|
certificateId: z.string(),
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
.min(1),
|
||||||
enabled: z.boolean(),
|
enabled: z.boolean(),
|
||||||
|
redirect_http: z.boolean(),
|
||||||
})
|
})
|
||||||
.superRefine((value, context) => {
|
.superRefine((value, context) => {
|
||||||
const domains = linesFromTextarea(value.domains_text).map((item) =>
|
const domains = value.domain_rows
|
||||||
item.toLowerCase(),
|
.map((item) => item.domain.trim().toLowerCase())
|
||||||
);
|
.filter(Boolean);
|
||||||
const error = validateDomains(domains);
|
const error = validateDomains(domains);
|
||||||
if (error) {
|
if (error) {
|
||||||
context.addIssue({
|
context.addIssue({
|
||||||
code: z.ZodIssueCode.custom,
|
code: z.ZodIssueCode.custom,
|
||||||
path: ['domains_text'],
|
path: ['domain_rows'],
|
||||||
message: error,
|
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
|
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
|
const cacheSchema = z
|
||||||
.object({
|
.object({
|
||||||
cache_enabled: z.boolean(),
|
cache_enabled: z.boolean(),
|
||||||
@@ -197,9 +210,29 @@ const cacheSchema = z
|
|||||||
type DomainSettingsValues = z.infer<typeof domainSettingsSchema>;
|
type DomainSettingsValues = z.infer<typeof domainSettingsSchema>;
|
||||||
type RateLimitValues = z.infer<typeof rateLimitSchema>;
|
type RateLimitValues = z.infer<typeof rateLimitSchema>;
|
||||||
type ReverseProxyValues = z.infer<typeof reverseProxySchema>;
|
type ReverseProxyValues = z.infer<typeof reverseProxySchema>;
|
||||||
type HTTPSValues = z.infer<typeof httpsSchema>;
|
|
||||||
type CacheValues = z.infer<typeof cacheSchema>;
|
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({
|
function ConfigSectionShell({
|
||||||
title,
|
title,
|
||||||
description,
|
description,
|
||||||
@@ -230,11 +263,13 @@ function ConfigSectionShell({
|
|||||||
|
|
||||||
function DomainSettingsSection({
|
function DomainSettingsSection({
|
||||||
route,
|
route,
|
||||||
|
certificates,
|
||||||
saving,
|
saving,
|
||||||
onSave,
|
onSave,
|
||||||
suggestionSources,
|
suggestionSources,
|
||||||
}: {
|
}: {
|
||||||
route: ProxyRouteItem;
|
route: ProxyRouteItem;
|
||||||
|
certificates: TlsCertificateItem[];
|
||||||
saving: boolean;
|
saving: boolean;
|
||||||
onSave: SaveHandler;
|
onSave: SaveHandler;
|
||||||
suggestionSources: string[];
|
suggestionSources: string[];
|
||||||
@@ -243,23 +278,29 @@ function DomainSettingsSection({
|
|||||||
resolver: zodResolver(domainSettingsSchema),
|
resolver: zodResolver(domainSettingsSchema),
|
||||||
defaultValues: {
|
defaultValues: {
|
||||||
site_name: route.site_name,
|
site_name: route.site_name,
|
||||||
domains_text: route.domains.join('\n'),
|
domain_rows: buildDomainRows(route),
|
||||||
enabled: route.enabled,
|
enabled: route.enabled,
|
||||||
|
redirect_http: route.redirect_http,
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
form.reset({
|
form.reset({
|
||||||
site_name: route.site_name,
|
site_name: route.site_name,
|
||||||
domains_text: route.domains.join('\n'),
|
domain_rows: buildDomainRows(route),
|
||||||
enabled: route.enabled,
|
enabled: route.enabled,
|
||||||
|
redirect_http: route.redirect_http,
|
||||||
});
|
});
|
||||||
}, [form, route]);
|
}, [form, route]);
|
||||||
|
|
||||||
|
const selectedCertificateIDs = normalizeSelectedCertificateIDs(
|
||||||
|
form.watch('domain_rows'),
|
||||||
|
);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<ConfigSectionShell
|
<ConfigSectionShell
|
||||||
title="域名设置"
|
title="域名设置"
|
||||||
description="配置站点。"
|
description="在一个列表里同时维护域名、证书和 HTTPS 跳转。保存时会自动汇总站点证书集合。"
|
||||||
formId="proxy-route-domains-form"
|
formId="proxy-route-domains-form"
|
||||||
saving={saving}
|
saving={saving}
|
||||||
>
|
>
|
||||||
@@ -267,9 +308,10 @@ function DomainSettingsSection({
|
|||||||
id="proxy-route-domains-form"
|
id="proxy-route-domains-form"
|
||||||
className="space-y-5"
|
className="space-y-5"
|
||||||
onSubmit={form.handleSubmit((values) => {
|
onSubmit={form.handleSubmit((values) => {
|
||||||
const domains = linesFromTextarea(values.domains_text).map((item) =>
|
const domains = values.domain_rows
|
||||||
item.toLowerCase(),
|
.map((item) => item.domain.trim().toLowerCase())
|
||||||
);
|
.filter(Boolean);
|
||||||
|
const certIDs = normalizeSelectedCertificateIDs(values.domain_rows);
|
||||||
|
|
||||||
onSave(
|
onSave(
|
||||||
buildPayloadFromRoute(route, {
|
buildPayloadFromRoute(route, {
|
||||||
@@ -277,19 +319,24 @@ function DomainSettingsSection({
|
|||||||
domain: domains[0],
|
domain: domains[0],
|
||||||
domains,
|
domains,
|
||||||
enabled: values.enabled,
|
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: '域名设置已保存。' },
|
{ message: '域名设置已保存。' },
|
||||||
);
|
);
|
||||||
})}
|
})}
|
||||||
>
|
>
|
||||||
<ToggleField
|
<ToggleField
|
||||||
label="启用站点"
|
label="启用站点"
|
||||||
description="关闭后站点会保留配置,但不会被纳入发布渲染。"
|
description="关闭后会保留配置,但不会参与发布。"
|
||||||
checked={form.watch('enabled')}
|
checked={form.watch('enabled')}
|
||||||
onChange={(checked) =>
|
onChange={(checked) =>
|
||||||
form.setValue('enabled', checked, { shouldDirty: true })
|
form.setValue('enabled', checked, { shouldDirty: true })
|
||||||
}
|
}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<ResourceField
|
<ResourceField
|
||||||
label="站点标识"
|
label="站点标识"
|
||||||
hint="建议使用稳定、可读的业务标识,不必与域名完全一致。"
|
hint="建议使用稳定、可读的业务标识,不必与域名完全一致。"
|
||||||
@@ -303,23 +350,37 @@ function DomainSettingsSection({
|
|||||||
|
|
||||||
<ResourceField
|
<ResourceField
|
||||||
label="域名列表"
|
label="域名列表"
|
||||||
hint="一个输入框填写一个域名,点击右侧 + 可以继续追加。输入时会优先提示已有域名后缀。"
|
hint="每行配置一个域名。可为不同域名选择不同证书,相同证书也可以重复选择。"
|
||||||
error={form.formState.errors.domains_text?.message}
|
error={form.formState.errors.domain_rows?.message as string | undefined}
|
||||||
>
|
>
|
||||||
<Controller
|
<Controller
|
||||||
control={form.control}
|
control={form.control}
|
||||||
name="domains_text"
|
name="domain_rows"
|
||||||
render={({ field }) => (
|
render={({ field }) => (
|
||||||
<DomainListInput
|
<DomainListInput
|
||||||
value={field.value}
|
rows={field.value}
|
||||||
onChange={field.onChange}
|
onChange={field.onChange}
|
||||||
onBlur={field.onBlur}
|
onBlur={field.onBlur}
|
||||||
suggestionSources={suggestionSources}
|
suggestionSources={suggestionSources}
|
||||||
|
certificates={certificates}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
/>
|
/>
|
||||||
</ResourceField>
|
</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>
|
</form>
|
||||||
</ConfigSectionShell>
|
</ConfigSectionShell>
|
||||||
);
|
);
|
||||||
@@ -362,7 +423,7 @@ function RateLimitSection({
|
|||||||
return (
|
return (
|
||||||
<ConfigSectionShell
|
<ConfigSectionShell
|
||||||
title="流量限制"
|
title="流量限制"
|
||||||
description="网站限流, 空值或 0 表示关闭。"
|
description="站点限流,空值或 0 表示关闭。"
|
||||||
formId="proxy-route-limits-form"
|
formId="proxy-route-limits-form"
|
||||||
saving={saving}
|
saving={saving}
|
||||||
>
|
>
|
||||||
@@ -384,7 +445,7 @@ function RateLimitSection({
|
|||||||
>
|
>
|
||||||
<ResourceField
|
<ResourceField
|
||||||
label="并发限制"
|
label="并发限制"
|
||||||
hint="限制当前站点最大并发数"
|
hint="限制当前站点最大并发连接数。"
|
||||||
error={form.formState.errors.limit_conn_per_server?.message}
|
error={form.formState.errors.limit_conn_per_server?.message}
|
||||||
>
|
>
|
||||||
<ResourceInput
|
<ResourceInput
|
||||||
@@ -394,8 +455,8 @@ function RateLimitSection({
|
|||||||
</ResourceField>
|
</ResourceField>
|
||||||
|
|
||||||
<ResourceField
|
<ResourceField
|
||||||
label="单IP限制"
|
label="单 IP 限制"
|
||||||
hint="限制单个IP访问最大并发数"
|
hint="限制单个 IP 的最大并发数。"
|
||||||
error={form.formState.errors.limit_conn_per_ip?.message}
|
error={form.formState.errors.limit_conn_per_ip?.message}
|
||||||
>
|
>
|
||||||
<ResourceInput
|
<ResourceInput
|
||||||
@@ -405,8 +466,8 @@ function RateLimitSection({
|
|||||||
</ResourceField>
|
</ResourceField>
|
||||||
|
|
||||||
<ResourceField
|
<ResourceField
|
||||||
label="流量限制"
|
label="限速"
|
||||||
hint="限制每个请求的流量上限。"
|
hint="限制单请求带宽,例如 512k 或 1m。"
|
||||||
error={form.formState.errors.limit_rate?.message}
|
error={form.formState.errors.limit_rate?.message}
|
||||||
className="md:col-span-2"
|
className="md:col-span-2"
|
||||||
>
|
>
|
||||||
@@ -448,7 +509,7 @@ function ReverseProxySection({
|
|||||||
return (
|
return (
|
||||||
<ConfigSectionShell
|
<ConfigSectionShell
|
||||||
title="反向代理"
|
title="反向代理"
|
||||||
description="第一行作为主回源;如果填写多行,会自动进入多上游负载均衡模式。"
|
description="第一行作为主回源;填写多行时会自动进入多上游负载均衡模式。"
|
||||||
formId="proxy-route-proxy-form"
|
formId="proxy-route-proxy-form"
|
||||||
saving={saving}
|
saving={saving}
|
||||||
>
|
>
|
||||||
@@ -513,10 +574,7 @@ function ReverseProxySection({
|
|||||||
/>
|
/>
|
||||||
</ResourceField>
|
</ResourceField>
|
||||||
|
|
||||||
<ResourceField
|
<ResourceField label="备注" error={form.formState.errors.remark?.message}>
|
||||||
label="备注"
|
|
||||||
error={form.formState.errors.remark?.message}
|
|
||||||
>
|
|
||||||
<ResourceTextarea
|
<ResourceTextarea
|
||||||
placeholder="例如:多活回源,优先使用上海入口"
|
placeholder="例如:多活回源,优先使用上海入口"
|
||||||
{...form.register('remark')}
|
{...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({
|
function CacheSection({
|
||||||
route,
|
route,
|
||||||
saving,
|
saving,
|
||||||
@@ -751,7 +681,7 @@ function CacheSection({
|
|||||||
? '/assets\n/static'
|
? '/assets\n/static'
|
||||||
: watchedPolicy === 'path_exact'
|
: watchedPolicy === 'path_exact'
|
||||||
? '/robots.txt\n/manifest.json'
|
? '/robots.txt\n/manifest.json'
|
||||||
: '按 URL 缓存无需额外规则'
|
: '按 URL 缓存时无需额外规则'
|
||||||
}
|
}
|
||||||
{...form.register('cache_rules_text')}
|
{...form.register('cache_rules_text')}
|
||||||
/>
|
/>
|
||||||
@@ -820,18 +750,19 @@ export function ProxyRouteConfigPage({
|
|||||||
() => certificatesQuery.data ?? [],
|
() => certificatesQuery.data ?? [],
|
||||||
[certificatesQuery.data],
|
[certificatesQuery.data],
|
||||||
);
|
);
|
||||||
const domainSuggestionSources = useMemo(() => {
|
const domainSuggestionSources = useMemo(
|
||||||
return [
|
() => [
|
||||||
...(route?.domains ?? []),
|
...(route?.domains ?? []),
|
||||||
...(managedDomainsQuery.data?.map((item) => item.domain) ?? []),
|
...(managedDomainsQuery.data?.map((item) => item.domain) ?? []),
|
||||||
];
|
],
|
||||||
}, [managedDomainsQuery.data, route?.domains]);
|
[managedDomainsQuery.data, route?.domains],
|
||||||
|
);
|
||||||
|
|
||||||
if (!Number.isFinite(numericRouteID) || numericRouteID <= 0) {
|
if (!Number.isFinite(numericRouteID) || numericRouteID <= 0) {
|
||||||
return (
|
return (
|
||||||
<EmptyState
|
<EmptyState
|
||||||
title="缺少网站 ID"
|
title="缺少站点 ID"
|
||||||
description="请从网站列表进入配置子页面。"
|
description="请从站点列表进入配置页面。"
|
||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -843,7 +774,7 @@ export function ProxyRouteConfigPage({
|
|||||||
if (routeQuery.isError) {
|
if (routeQuery.isError) {
|
||||||
return (
|
return (
|
||||||
<ErrorState
|
<ErrorState
|
||||||
title="网站详情加载失败"
|
title="站点详情加载失败"
|
||||||
description={getErrorMessage(routeQuery.error)}
|
description={getErrorMessage(routeQuery.error)}
|
||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
@@ -861,8 +792,8 @@ export function ProxyRouteConfigPage({
|
|||||||
if (!route) {
|
if (!route) {
|
||||||
return (
|
return (
|
||||||
<EmptyState
|
<EmptyState
|
||||||
title="网站不存在"
|
title="站点不存在"
|
||||||
description="该网站可能已被删除,或当前 ID 无法匹配到记录。"
|
description="该站点可能已被删除,或当前 ID 无法匹配到记录。"
|
||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -900,7 +831,7 @@ export function ProxyRouteConfigPage({
|
|||||||
|
|
||||||
<div className="grid gap-6 xl:grid-cols-[280px_minmax(0,1fr)]">
|
<div className="grid gap-6 xl:grid-cols-[280px_minmax(0,1fr)]">
|
||||||
<aside className="space-y-4">
|
<aside className="space-y-4">
|
||||||
<AppCard title="配置分区" >
|
<AppCard title="配置分区">
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
{websiteConfigSections.map((section) => {
|
{websiteConfigSections.map((section) => {
|
||||||
const active = section.key === currentSection;
|
const active = section.key === currentSection;
|
||||||
@@ -932,6 +863,7 @@ export function ProxyRouteConfigPage({
|
|||||||
{currentSection === 'domains' ? (
|
{currentSection === 'domains' ? (
|
||||||
<DomainSettingsSection
|
<DomainSettingsSection
|
||||||
route={route}
|
route={route}
|
||||||
|
certificates={certificates}
|
||||||
saving={saveMutation.isPending}
|
saving={saveMutation.isPending}
|
||||||
suggestionSources={domainSuggestionSources}
|
suggestionSources={domainSuggestionSources}
|
||||||
onSave={(payload, context) =>
|
onSave={(payload, context) =>
|
||||||
@@ -960,17 +892,6 @@ export function ProxyRouteConfigPage({
|
|||||||
/>
|
/>
|
||||||
) : null}
|
) : null}
|
||||||
|
|
||||||
{currentSection === 'https' ? (
|
|
||||||
<HTTPSSection
|
|
||||||
route={route}
|
|
||||||
certificates={certificates}
|
|
||||||
saving={saveMutation.isPending}
|
|
||||||
onSave={(payload, context) =>
|
|
||||||
saveMutation.mutate({ payload, context })
|
|
||||||
}
|
|
||||||
/>
|
|
||||||
) : null}
|
|
||||||
|
|
||||||
{currentSection === 'cache' ? (
|
{currentSection === 'cache' ? (
|
||||||
<CacheSection
|
<CacheSection
|
||||||
route={route}
|
route={route}
|
||||||
|
|||||||
+90
-28
@@ -2,23 +2,26 @@
|
|||||||
|
|
||||||
import { zodResolver } from '@hookform/resolvers/zod';
|
import { zodResolver } from '@hookform/resolvers/zod';
|
||||||
import { useMutation, useQuery } from '@tanstack/react-query';
|
import { useMutation, useQuery } from '@tanstack/react-query';
|
||||||
import { useEffect } from 'react';
|
import { useEffect, useMemo } from 'react';
|
||||||
import { Controller, 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 { 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 {
|
||||||
|
DomainListInput,
|
||||||
|
type DomainListRow,
|
||||||
|
} from '@/features/proxy-routes/components/domain-list-input';
|
||||||
import {
|
import {
|
||||||
buildOriginUrl,
|
buildOriginUrl,
|
||||||
getErrorMessage,
|
getErrorMessage,
|
||||||
linesFromTextarea,
|
|
||||||
parseOriginUrl,
|
parseOriginUrl,
|
||||||
parseOriginUrls,
|
parseOriginUrls,
|
||||||
validateDomains,
|
validateDomains,
|
||||||
} from '@/features/proxy-routes/helpers';buyao
|
} from '@/features/proxy-routes/helpers';
|
||||||
import type { ProxyRouteItem } from '@/features/proxy-routes/types';
|
import type { ProxyRouteItem } from '@/features/proxy-routes/types';
|
||||||
|
import { getTlsCertificates } from '@/features/tls-certificates/api/tls-certificates';
|
||||||
import {
|
import {
|
||||||
PrimaryButton,
|
PrimaryButton,
|
||||||
ResourceField,
|
ResourceField,
|
||||||
@@ -27,23 +30,29 @@ import {
|
|||||||
ToggleField,
|
ToggleField,
|
||||||
} from '@/features/shared/components/resource-primitives';
|
} from '@/features/shared/components/resource-primitives';
|
||||||
|
|
||||||
|
const domainRowSchema = z.object({
|
||||||
|
domain: z.string(),
|
||||||
|
certificateId: z.string(),
|
||||||
|
});
|
||||||
|
|
||||||
const createWebsiteSchema = z
|
const createWebsiteSchema = z
|
||||||
.object({
|
.object({
|
||||||
site_name: z.string().trim().max(255, '站点标识不能超过 255 个字符'),
|
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, '请至少填写一个上游地址'),
|
origin_urls_text: z.string().trim().min(1, '请至少填写一个上游地址'),
|
||||||
enabled: z.boolean(),
|
enabled: z.boolean(),
|
||||||
|
redirect_http: z.boolean(),
|
||||||
remark: z.string().max(255, '备注不能超过 255 个字符'),
|
remark: z.string().max(255, '备注不能超过 255 个字符'),
|
||||||
})
|
})
|
||||||
.superRefine((value, context) => {
|
.superRefine((value, context) => {
|
||||||
const domains = linesFromTextarea(value.domains_text).map((item) =>
|
const domains = value.domain_rows
|
||||||
item.toLowerCase(),
|
.map((item) => item.domain.trim().toLowerCase())
|
||||||
);
|
.filter(Boolean);
|
||||||
const domainError = validateDomains(domains);
|
const domainError = validateDomains(domains);
|
||||||
if (domainError) {
|
if (domainError) {
|
||||||
context.addIssue({
|
context.addIssue({
|
||||||
code: z.ZodIssueCode.custom,
|
code: z.ZodIssueCode.custom,
|
||||||
path: ['domains_text'],
|
path: ['domain_rows'],
|
||||||
message: domainError,
|
message: domainError,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
@@ -56,18 +65,42 @@ const createWebsiteSchema = z
|
|||||||
message: error,
|
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>;
|
type CreateWebsiteFormValues = z.infer<typeof createWebsiteSchema>;
|
||||||
|
|
||||||
const defaultValues: CreateWebsiteFormValues = {
|
const defaultValues: CreateWebsiteFormValues = {
|
||||||
site_name: '',
|
site_name: '',
|
||||||
domains_text: '',
|
domain_rows: [{ domain: '', certificateId: '' }],
|
||||||
origin_urls_text: '',
|
origin_urls_text: '',
|
||||||
enabled: true,
|
enabled: true,
|
||||||
|
redirect_http: false,
|
||||||
remark: '',
|
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({
|
export function ProxyRouteCreateDrawer({
|
||||||
open,
|
open,
|
||||||
onOpenChange,
|
onOpenChange,
|
||||||
@@ -88,16 +121,29 @@ export function ProxyRouteCreateDrawer({
|
|||||||
queryFn: getManagedDomains,
|
queryFn: getManagedDomains,
|
||||||
enabled: open,
|
enabled: open,
|
||||||
});
|
});
|
||||||
const combinedDomainSuggestions = [
|
const certificatesQuery = useQuery({
|
||||||
...domainSuggestionSources,
|
queryKey: ['tls-certificates', 'list'],
|
||||||
...(managedDomainsQuery.data?.map((item) => item.domain) ?? []),
|
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({
|
const createMutation = useMutation({
|
||||||
mutationFn: async (values: CreateWebsiteFormValues) => {
|
mutationFn: async (values: CreateWebsiteFormValues) => {
|
||||||
const domains = linesFromTextarea(values.domains_text).map((item) =>
|
const domains = values.domain_rows
|
||||||
item.toLowerCase(),
|
.map((item) => item.domain.trim().toLowerCase())
|
||||||
);
|
.filter(Boolean);
|
||||||
|
const selectedCertIDs = normalizeSelectedCertificateIDs(values.domain_rows);
|
||||||
const { urls } = parseOriginUrls(values.origin_urls_text);
|
const { urls } = parseOriginUrls(values.origin_urls_text);
|
||||||
const primaryOrigin = parseOriginUrl(urls[0]);
|
const primaryOrigin = parseOriginUrl(urls[0]);
|
||||||
|
|
||||||
@@ -119,9 +165,10 @@ export function ProxyRouteCreateDrawer({
|
|||||||
origin_host: '',
|
origin_host: '',
|
||||||
upstreams: urls.slice(1),
|
upstreams: urls.slice(1),
|
||||||
enabled: values.enabled,
|
enabled: values.enabled,
|
||||||
enable_https: false,
|
enable_https: selectedCertIDs.length > 0,
|
||||||
cert_id: null,
|
cert_id: selectedCertIDs[0] ?? null,
|
||||||
redirect_http: false,
|
cert_ids: selectedCertIDs,
|
||||||
|
redirect_http: selectedCertIDs.length > 0 ? values.redirect_http : false,
|
||||||
limit_conn_per_server: 0,
|
limit_conn_per_server: 0,
|
||||||
limit_conn_per_ip: 0,
|
limit_conn_per_ip: 0,
|
||||||
limit_rate: '',
|
limit_rate: '',
|
||||||
@@ -170,7 +217,7 @@ export function ProxyRouteCreateDrawer({
|
|||||||
>
|
>
|
||||||
<ResourceField
|
<ResourceField
|
||||||
label="站点标识"
|
label="站点标识"
|
||||||
hint="可选。留空时会自动使用第一个域名。"
|
hint="可选,留空时会自动使用第一个域名。"
|
||||||
error={form.formState.errors.site_name?.message}
|
error={form.formState.errors.site_name?.message}
|
||||||
>
|
>
|
||||||
<ResourceInput
|
<ResourceInput
|
||||||
@@ -181,26 +228,41 @@ export function ProxyRouteCreateDrawer({
|
|||||||
|
|
||||||
<ResourceField
|
<ResourceField
|
||||||
label="域名列表"
|
label="域名列表"
|
||||||
hint="一个输入框填写一个域名,点击右侧 + 可以继续追加。输入时会优先提示已有域名后缀。"
|
hint="每行配置一个域名,可按需为该行选择证书。保存时会自动汇总站点证书集合。"
|
||||||
error={form.formState.errors.domains_text?.message}
|
error={form.formState.errors.domain_rows?.message as string | undefined}
|
||||||
>
|
>
|
||||||
<Controller
|
<Controller
|
||||||
control={form.control}
|
control={form.control}
|
||||||
name="domains_text"
|
name="domain_rows"
|
||||||
render={({ field }) => (
|
render={({ field }) => (
|
||||||
<DomainListInput
|
<DomainListInput
|
||||||
value={field.value}
|
rows={field.value}
|
||||||
onChange={field.onChange}
|
onChange={field.onChange}
|
||||||
onBlur={field.onBlur}
|
onBlur={field.onBlur}
|
||||||
suggestionSources={combinedDomainSuggestions}
|
suggestionSources={combinedDomainSuggestions}
|
||||||
|
certificates={certificatesQuery.data ?? []}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
/>
|
/>
|
||||||
</ResourceField>
|
</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
|
<ResourceField
|
||||||
label="上游地址"
|
label="上游地址"
|
||||||
hint="每行一个完整 URL。第一行作为主回源,多上游模式下请保持相同协议且不要带 path/query。"
|
hint="每行一个完整 URL。第一行作为主回源,多上游模式请保持相同协议且不要包含 path 或 query。"
|
||||||
error={form.formState.errors.origin_urls_text?.message}
|
error={form.formState.errors.origin_urls_text?.message}
|
||||||
>
|
>
|
||||||
<ResourceTextarea
|
<ResourceTextarea
|
||||||
@@ -212,7 +274,7 @@ export function ProxyRouteCreateDrawer({
|
|||||||
|
|
||||||
<ToggleField
|
<ToggleField
|
||||||
label="创建后立即启用"
|
label="创建后立即启用"
|
||||||
description="关闭后网站会先以草稿形式保存,发布配置前仍可继续编辑。"
|
description="关闭后站点会以草稿保存,后续仍可继续编辑。"
|
||||||
checked={form.watch('enabled')}
|
checked={form.watch('enabled')}
|
||||||
onChange={(checked) =>
|
onChange={(checked) =>
|
||||||
form.setValue('enabled', checked, { shouldDirty: true })
|
form.setValue('enabled', checked, { shouldDirty: true })
|
||||||
@@ -223,7 +285,7 @@ export function ProxyRouteCreateDrawer({
|
|||||||
label="备注"
|
label="备注"
|
||||||
error={form.formState.errors.remark?.message}
|
error={form.formState.errors.remark?.message}
|
||||||
>
|
>
|
||||||
<ResourceTextarea placeholder="" {...form.register('remark')} />
|
<ResourceTextarea {...form.register('remark')} />
|
||||||
</ResourceField>
|
</ResourceField>
|
||||||
|
|
||||||
{createMutation.isError ? (
|
{createMutation.isError ? (
|
||||||
|
|||||||
@@ -8,7 +8,7 @@ export const websiteConfigSections = [
|
|||||||
{
|
{
|
||||||
key: 'domains',
|
key: 'domains',
|
||||||
label: '域名设置',
|
label: '域名设置',
|
||||||
description: '维护站点标识。',
|
description: '维护站点标识、域名列表和证书绑定。',
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
key: 'limits',
|
key: 'limits',
|
||||||
@@ -18,21 +18,17 @@ export const websiteConfigSections = [
|
|||||||
{
|
{
|
||||||
key: 'proxy',
|
key: 'proxy',
|
||||||
label: '反向代理',
|
label: '反向代理',
|
||||||
description: '配置主回源、上游。',
|
description: '配置主回源和上游地址。',
|
||||||
},
|
|
||||||
{
|
|
||||||
key: 'https',
|
|
||||||
label: 'HTTPS',
|
|
||||||
description: '证书设置。',
|
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
key: 'cache',
|
key: 'cache',
|
||||||
label: '缓存',
|
label: '缓存',
|
||||||
description: '页面缓存。',
|
description: '配置站点缓存策略。',
|
||||||
},
|
},
|
||||||
] as const;
|
] as const;
|
||||||
|
|
||||||
export type WebsiteConfigSectionKey = (typeof websiteConfigSections)[number]['key'];
|
export type WebsiteConfigSectionKey =
|
||||||
|
(typeof websiteConfigSections)[number]['key'];
|
||||||
|
|
||||||
const domainPattern =
|
const domainPattern =
|
||||||
/^(?=.{1,253}$)(?:[a-z0-9](?:[a-z0-9-]{0,61}[a-z0-9])?\.)+[a-z]{2,63}$/i;
|
/^(?=.{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) {
|
export function parseOriginUrl(originUrl: string) {
|
||||||
const parsed = new URL(originUrl);
|
const parsed = new URL(originUrl);
|
||||||
const port =
|
const port = parsed.port || (parsed.protocol === 'http:' ? '80' : '443');
|
||||||
parsed.port || (parsed.protocol === 'http:' ? '80' : '443');
|
|
||||||
const path = parsed.pathname === '/' ? '' : parsed.pathname;
|
const path = parsed.pathname === '/' ? '' : parsed.pathname;
|
||||||
|
|
||||||
return {
|
return {
|
||||||
@@ -173,7 +168,7 @@ export function validateOriginHost(value: string) {
|
|||||||
/[\/\\\s]/.test(normalized) ||
|
/[\/\\\s]/.test(normalized) ||
|
||||||
!originHostPattern.test(normalized)
|
!originHostPattern.test(normalized)
|
||||||
) {
|
) {
|
||||||
return '回源主机名格式不合法';
|
return '回源 Host 格式不合法';
|
||||||
}
|
}
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
@@ -250,11 +245,7 @@ export function validateCacheRules(
|
|||||||
}
|
}
|
||||||
|
|
||||||
for (const rule of rules) {
|
for (const rule of rules) {
|
||||||
if (
|
if (!rule.startsWith('/') || rule.includes('://') || /[\s]/.test(rule)) {
|
||||||
!rule.startsWith('/') ||
|
|
||||||
rule.includes('://') ||
|
|
||||||
/[\s]/.test(rule)
|
|
||||||
) {
|
|
||||||
return `缓存路径规则格式不合法:${rule}`;
|
return `缓存路径规则格式不合法:${rule}`;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -49,6 +49,7 @@ function buildRoute(overrides: Record<string, unknown> = {}) {
|
|||||||
enabled: true,
|
enabled: true,
|
||||||
enable_https: true,
|
enable_https: true,
|
||||||
cert_id: 1,
|
cert_id: 1,
|
||||||
|
cert_ids: [1],
|
||||||
redirect_http: true,
|
redirect_http: true,
|
||||||
limit_conn_per_server: 120,
|
limit_conn_per_server: 120,
|
||||||
limit_conn_per_ip: 12,
|
limit_conn_per_ip: 12,
|
||||||
@@ -181,9 +182,10 @@ describe('Proxy route website pages', () => {
|
|||||||
upstreams: JSON.stringify([payload.origin_url, ...payload.upstreams]),
|
upstreams: JSON.stringify([payload.origin_url, ...payload.upstreams]),
|
||||||
upstream_list: [payload.origin_url, ...payload.upstreams],
|
upstream_list: [payload.origin_url, ...payload.upstreams],
|
||||||
enabled: payload.enabled,
|
enabled: payload.enabled,
|
||||||
enable_https: false,
|
enable_https: payload.enable_https,
|
||||||
cert_id: null,
|
cert_id: payload.cert_id,
|
||||||
redirect_http: false,
|
cert_ids: payload.cert_ids ?? [],
|
||||||
|
redirect_http: payload.redirect_http,
|
||||||
limit_conn_per_server: 0,
|
limit_conn_per_server: 0,
|
||||||
limit_conn_per_ip: 0,
|
limit_conn_per_ip: 0,
|
||||||
limit_rate: '',
|
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}`));
|
return Promise.reject(new Error(`Unhandled fetch: ${url}`));
|
||||||
}),
|
}),
|
||||||
);
|
);
|
||||||
@@ -257,13 +271,18 @@ describe('Proxy route website pages', () => {
|
|||||||
const dialog = await screen.findByRole('dialog');
|
const dialog = await screen.findByRole('dialog');
|
||||||
expect(dialog).toBeInTheDocument();
|
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');
|
const primaryDomainInput = within(dialog).getByLabelText('域名 1');
|
||||||
await user.type(primaryDomainInput, 'app.exam');
|
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(within(dialog).getByLabelText('域名 2'), 'www.example.com');
|
||||||
|
|
||||||
await user.type(
|
await user.type(
|
||||||
@@ -315,6 +334,10 @@ describe('Proxy route website pages', () => {
|
|||||||
primary_domain: (payload.domains as string[])[0],
|
primary_domain: (payload.domains as string[])[0],
|
||||||
domain_count: (payload.domains as string[]).length,
|
domain_count: (payload.domains as string[]).length,
|
||||||
enabled: payload.enabled,
|
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.clear(secondaryDomainInput);
|
||||||
await user.type(secondaryDomainInput, 'www.brand.example.com');
|
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(
|
const saveButton = document.querySelector(
|
||||||
'button[form="proxy-route-domains-form"]',
|
'button[form="proxy-route-domains-form"]',
|
||||||
) as HTMLButtonElement | null;
|
) as HTMLButtonElement | null;
|
||||||
@@ -398,6 +424,10 @@ describe('Proxy route website pages', () => {
|
|||||||
domain: 'brand.example.com',
|
domain: 'brand.example.com',
|
||||||
domains: ['brand.example.com', 'www.brand.example.com'],
|
domains: ['brand.example.com', 'www.brand.example.com'],
|
||||||
enabled: true,
|
enabled: true,
|
||||||
|
enable_https: true,
|
||||||
|
cert_id: 1,
|
||||||
|
cert_ids: [1],
|
||||||
|
redirect_http: true,
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
Reference in New Issue
Block a user