mirror of
https://github.com/Rain-kl/OpenFlare.git
synced 2026-09-28 05:46:36 +08:00
[功能] 添加网站创建抽屉组件,支持域名和上游地址输入,更新相关逻辑和测试
This commit is contained in:
@@ -24,6 +24,12 @@
|
||||
</a>
|
||||
</p>
|
||||
|
||||
> [!NOTE]
|
||||
> 当前项目处于快速迭代期,设计与实现均不稳定,请确保使用最新版本并关注更新日志。
|
||||
|
||||
> [!WARNING]
|
||||
> 使用 root 用户初次登录系统后,务必修改默认密码 `123456`,并且确保关闭新用户注册功能。
|
||||
|
||||
## 为什么存在
|
||||
|
||||
OpenFlare 解决的是一类朴素但高频的运维问题:
|
||||
|
||||
@@ -0,0 +1,16 @@
|
||||
'use client';
|
||||
|
||||
import { useSearchParams } from 'next/navigation';
|
||||
|
||||
import { ProxyRouteConfigPage } from '@/features/proxy-routes/components/proxy-route-config-page';
|
||||
|
||||
export default function ProxyRouteDetailRoute() {
|
||||
const searchParams = useSearchParams();
|
||||
|
||||
return (
|
||||
<ProxyRouteConfigPage
|
||||
routeId={searchParams.get('id') ?? ''}
|
||||
initialSection={searchParams.get('section') ?? 'domains'}
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,969 @@
|
||||
'use client';
|
||||
|
||||
import Link from 'next/link';
|
||||
import { zodResolver } from '@hookform/resolvers/zod';
|
||||
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
|
||||
import { useEffect, useMemo, useState } from 'react';
|
||||
import type { ReactNode } from 'react';
|
||||
import { useForm } from 'react-hook-form';
|
||||
import { z } from 'zod';
|
||||
|
||||
import { EmptyState } from '@/components/feedback/empty-state';
|
||||
import { ErrorState } from '@/components/feedback/error-state';
|
||||
import { InlineMessage } from '@/components/feedback/inline-message';
|
||||
import { LoadingState } from '@/components/feedback/loading-state';
|
||||
import { PageHeader } from '@/components/layout/page-header';
|
||||
import { AppCard } from '@/components/ui/app-card';
|
||||
import { StatusBadge } from '@/components/ui/status-badge';
|
||||
import { 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 {
|
||||
buildPayloadFromRoute,
|
||||
customHeadersToText,
|
||||
getErrorMessage,
|
||||
getWebsiteConfigSection,
|
||||
getWebsiteStatusBadges,
|
||||
linesFromTextarea,
|
||||
normalizeLimitRate,
|
||||
parseCustomHeadersText,
|
||||
parseOriginUrl,
|
||||
parseOriginUrls,
|
||||
validateCacheRules,
|
||||
validateDomains,
|
||||
validateLimitRate,
|
||||
validateOriginHost,
|
||||
websiteConfigSections,
|
||||
type WebsiteConfigSectionKey,
|
||||
} from '@/features/proxy-routes/helpers';
|
||||
import type {
|
||||
ProxyRouteItem,
|
||||
ProxyRouteMutationPayload,
|
||||
} from '@/features/proxy-routes/types';
|
||||
import {
|
||||
PrimaryButton,
|
||||
ResourceField,
|
||||
ResourceInput,
|
||||
ResourceSelect,
|
||||
ResourceTextarea,
|
||||
SecondaryButton,
|
||||
ToggleField,
|
||||
} from '@/features/shared/components/resource-primitives';
|
||||
import { cn } from '@/lib/utils/cn';
|
||||
import { formatDateTime } from '@/lib/utils/date';
|
||||
|
||||
type FeedbackState = {
|
||||
tone: 'success' | 'danger';
|
||||
message: string;
|
||||
};
|
||||
|
||||
type SaveContext = {
|
||||
message: string;
|
||||
};
|
||||
|
||||
type SaveHandler = (
|
||||
payload: ProxyRouteMutationPayload,
|
||||
context: SaveContext,
|
||||
) => void;
|
||||
|
||||
const domainSettingsSchema = z
|
||||
.object({
|
||||
site_name: z.string().trim().min(1, '请输入站点标识').max(255, '站点标识不能超过 255 个字符'),
|
||||
domains_text: z.string().trim().min(1, '请至少填写一个域名'),
|
||||
enabled: z.boolean(),
|
||||
})
|
||||
.superRefine((value, context) => {
|
||||
const domains = linesFromTextarea(value.domains_text).map((item) =>
|
||||
item.toLowerCase(),
|
||||
);
|
||||
const error = validateDomains(domains);
|
||||
if (error) {
|
||||
context.addIssue({
|
||||
code: z.ZodIssueCode.custom,
|
||||
path: ['domains_text'],
|
||||
message: error,
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
const rateLimitSchema = z
|
||||
.object({
|
||||
limit_conn_per_server: z.string(),
|
||||
limit_conn_per_ip: z.string(),
|
||||
limit_rate: z.string(),
|
||||
})
|
||||
.superRefine((value, context) => {
|
||||
for (const field of ['limit_conn_per_server', 'limit_conn_per_ip'] as const) {
|
||||
const rawValue = value[field].trim();
|
||||
if (!rawValue) {
|
||||
continue;
|
||||
}
|
||||
if (!/^\d+$/.test(rawValue)) {
|
||||
context.addIssue({
|
||||
code: z.ZodIssueCode.custom,
|
||||
path: [field],
|
||||
message: '请输入大于等于 0 的整数',
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
const limitRateError = validateLimitRate(value.limit_rate);
|
||||
if (limitRateError) {
|
||||
context.addIssue({
|
||||
code: z.ZodIssueCode.custom,
|
||||
path: ['limit_rate'],
|
||||
message: limitRateError,
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
const reverseProxySchema = z
|
||||
.object({
|
||||
origin_urls_text: z.string().trim().min(1, '请至少填写一个上游地址'),
|
||||
origin_host: z.string(),
|
||||
custom_headers_text: z.string(),
|
||||
remark: z.string().max(255, '备注不能超过 255 个字符'),
|
||||
})
|
||||
.superRefine((value, context) => {
|
||||
const { error } = parseOriginUrls(value.origin_urls_text);
|
||||
if (error) {
|
||||
context.addIssue({
|
||||
code: z.ZodIssueCode.custom,
|
||||
path: ['origin_urls_text'],
|
||||
message: error,
|
||||
});
|
||||
}
|
||||
|
||||
const originHostError = validateOriginHost(value.origin_host);
|
||||
if (originHostError) {
|
||||
context.addIssue({
|
||||
code: z.ZodIssueCode.custom,
|
||||
path: ['origin_host'],
|
||||
message: originHostError,
|
||||
});
|
||||
}
|
||||
|
||||
const { error: headerError } = parseCustomHeadersText(
|
||||
value.custom_headers_text,
|
||||
);
|
||||
if (headerError) {
|
||||
context.addIssue({
|
||||
code: z.ZodIssueCode.custom,
|
||||
path: ['custom_headers_text'],
|
||||
message: headerError,
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
const httpsSchema = z
|
||||
.object({
|
||||
enable_https: z.boolean(),
|
||||
cert_id: z.string(),
|
||||
redirect_http: z.boolean(),
|
||||
})
|
||||
.superRefine((value, context) => {
|
||||
if (value.enable_https && !value.cert_id.trim()) {
|
||||
context.addIssue({
|
||||
code: z.ZodIssueCode.custom,
|
||||
path: ['cert_id'],
|
||||
message: '启用 HTTPS 时必须选择证书',
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
const cacheSchema = z
|
||||
.object({
|
||||
cache_enabled: z.boolean(),
|
||||
cache_policy: z.enum(['url', 'suffix', 'path_prefix', 'path_exact']),
|
||||
cache_rules_text: z.string(),
|
||||
})
|
||||
.superRefine((value, context) => {
|
||||
if (!value.cache_enabled) {
|
||||
return;
|
||||
}
|
||||
|
||||
const rules = linesFromTextarea(value.cache_rules_text);
|
||||
const error = validateCacheRules(value.cache_policy, rules);
|
||||
if (error) {
|
||||
context.addIssue({
|
||||
code: z.ZodIssueCode.custom,
|
||||
path: ['cache_rules_text'],
|
||||
message: error,
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
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 ConfigSectionShell({
|
||||
title,
|
||||
description,
|
||||
formId,
|
||||
saving,
|
||||
children,
|
||||
}: {
|
||||
title: string;
|
||||
description: string;
|
||||
formId: string;
|
||||
saving: boolean;
|
||||
children: ReactNode;
|
||||
}) {
|
||||
return (
|
||||
<AppCard
|
||||
title={title}
|
||||
description={description}
|
||||
action={
|
||||
<PrimaryButton type="submit" form={formId} disabled={saving}>
|
||||
{saving ? '保存中...' : '保存当前分区'}
|
||||
</PrimaryButton>
|
||||
}
|
||||
>
|
||||
{children}
|
||||
</AppCard>
|
||||
);
|
||||
}
|
||||
|
||||
function DomainSettingsSection({
|
||||
route,
|
||||
saving,
|
||||
onSave,
|
||||
}: {
|
||||
route: ProxyRouteItem;
|
||||
saving: boolean;
|
||||
onSave: SaveHandler;
|
||||
}) {
|
||||
const form = useForm<DomainSettingsValues>({
|
||||
resolver: zodResolver(domainSettingsSchema),
|
||||
defaultValues: {
|
||||
site_name: route.site_name,
|
||||
domains_text: route.domains.join('\n'),
|
||||
enabled: route.enabled,
|
||||
},
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
form.reset({
|
||||
site_name: route.site_name,
|
||||
domains_text: route.domains.join('\n'),
|
||||
enabled: route.enabled,
|
||||
});
|
||||
}, [form, route]);
|
||||
|
||||
return (
|
||||
<ConfigSectionShell
|
||||
title="域名设置"
|
||||
description="站点标识在这里维护;域名列表第一行永远视为主域名。"
|
||||
formId="proxy-route-domains-form"
|
||||
saving={saving}
|
||||
>
|
||||
<form
|
||||
id="proxy-route-domains-form"
|
||||
className="space-y-5"
|
||||
onSubmit={form.handleSubmit((values) => {
|
||||
const domains = linesFromTextarea(values.domains_text).map((item) =>
|
||||
item.toLowerCase(),
|
||||
);
|
||||
|
||||
onSave(
|
||||
buildPayloadFromRoute(route, {
|
||||
site_name: values.site_name.trim(),
|
||||
domain: domains[0],
|
||||
domains,
|
||||
enabled: values.enabled,
|
||||
}),
|
||||
{ message: '域名设置已保存。' },
|
||||
);
|
||||
})}
|
||||
>
|
||||
<ResourceField
|
||||
label="站点标识"
|
||||
hint="建议使用稳定、可读的业务标识,不必与域名完全一致。"
|
||||
error={form.formState.errors.site_name?.message}
|
||||
>
|
||||
<ResourceInput
|
||||
placeholder="marketing-site"
|
||||
{...form.register('site_name')}
|
||||
/>
|
||||
</ResourceField>
|
||||
|
||||
<ResourceField
|
||||
label="域名列表"
|
||||
hint="每行一个域名,第一行会作为主域名,并用于列表摘要和兼容镜像。"
|
||||
error={form.formState.errors.domains_text?.message}
|
||||
>
|
||||
<ResourceTextarea
|
||||
className="min-h-36"
|
||||
placeholder={'app.example.com\nwww.example.com'}
|
||||
{...form.register('domains_text')}
|
||||
/>
|
||||
</ResourceField>
|
||||
|
||||
<ToggleField
|
||||
label="启用站点"
|
||||
description="关闭后站点会保留配置,但不会被纳入发布渲染。"
|
||||
checked={form.watch('enabled')}
|
||||
onChange={(checked) =>
|
||||
form.setValue('enabled', checked, { shouldDirty: true })
|
||||
}
|
||||
/>
|
||||
</form>
|
||||
</ConfigSectionShell>
|
||||
);
|
||||
}
|
||||
|
||||
function RateLimitSection({
|
||||
route,
|
||||
saving,
|
||||
onSave,
|
||||
}: {
|
||||
route: ProxyRouteItem;
|
||||
saving: boolean;
|
||||
onSave: SaveHandler;
|
||||
}) {
|
||||
const form = useForm<RateLimitValues>({
|
||||
resolver: zodResolver(rateLimitSchema),
|
||||
defaultValues: {
|
||||
limit_conn_per_server: route.limit_conn_per_server
|
||||
? String(route.limit_conn_per_server)
|
||||
: '',
|
||||
limit_conn_per_ip: route.limit_conn_per_ip
|
||||
? String(route.limit_conn_per_ip)
|
||||
: '',
|
||||
limit_rate: route.limit_rate || '',
|
||||
},
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
form.reset({
|
||||
limit_conn_per_server: route.limit_conn_per_server
|
||||
? String(route.limit_conn_per_server)
|
||||
: '',
|
||||
limit_conn_per_ip: route.limit_conn_per_ip
|
||||
? String(route.limit_conn_per_ip)
|
||||
: '',
|
||||
limit_rate: route.limit_rate || '',
|
||||
});
|
||||
}, [form, route]);
|
||||
|
||||
return (
|
||||
<ConfigSectionShell
|
||||
title="流量限制"
|
||||
description="使用结构化字段生成 OpenResty 限制指令,空值或 0 表示关闭。"
|
||||
formId="proxy-route-limits-form"
|
||||
saving={saving}
|
||||
>
|
||||
<form
|
||||
id="proxy-route-limits-form"
|
||||
className="grid gap-5 md:grid-cols-2"
|
||||
onSubmit={form.handleSubmit((values) => {
|
||||
onSave(
|
||||
buildPayloadFromRoute(route, {
|
||||
limit_conn_per_server: Number(
|
||||
values.limit_conn_per_server.trim() || '0',
|
||||
),
|
||||
limit_conn_per_ip: Number(values.limit_conn_per_ip.trim() || '0'),
|
||||
limit_rate: normalizeLimitRate(values.limit_rate),
|
||||
}),
|
||||
{ message: '流量限制已保存。' },
|
||||
);
|
||||
})}
|
||||
>
|
||||
<ResourceField
|
||||
label="每站点最大连接数"
|
||||
hint="例如 120。0 表示关闭。"
|
||||
error={form.formState.errors.limit_conn_per_server?.message}
|
||||
>
|
||||
<ResourceInput
|
||||
placeholder="120"
|
||||
{...form.register('limit_conn_per_server')}
|
||||
/>
|
||||
</ResourceField>
|
||||
|
||||
<ResourceField
|
||||
label="每 IP 最大连接数"
|
||||
hint="例如 12。0 表示关闭。"
|
||||
error={form.formState.errors.limit_conn_per_ip?.message}
|
||||
>
|
||||
<ResourceInput
|
||||
placeholder="12"
|
||||
{...form.register('limit_conn_per_ip')}
|
||||
/>
|
||||
</ResourceField>
|
||||
|
||||
<ResourceField
|
||||
label="下载限速"
|
||||
hint="支持 512k、1m 或纯数字字节速率。留空表示关闭。"
|
||||
error={form.formState.errors.limit_rate?.message}
|
||||
className="md:col-span-2"
|
||||
>
|
||||
<ResourceInput placeholder="512k" {...form.register('limit_rate')} />
|
||||
</ResourceField>
|
||||
</form>
|
||||
</ConfigSectionShell>
|
||||
);
|
||||
}
|
||||
|
||||
function ReverseProxySection({
|
||||
route,
|
||||
saving,
|
||||
onSave,
|
||||
}: {
|
||||
route: ProxyRouteItem;
|
||||
saving: boolean;
|
||||
onSave: SaveHandler;
|
||||
}) {
|
||||
const form = useForm<ReverseProxyValues>({
|
||||
resolver: zodResolver(reverseProxySchema),
|
||||
defaultValues: {
|
||||
origin_urls_text: route.upstream_list.join('\n'),
|
||||
origin_host: route.origin_host || '',
|
||||
custom_headers_text: customHeadersToText(route.custom_header_list),
|
||||
remark: route.remark || '',
|
||||
},
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
form.reset({
|
||||
origin_urls_text: route.upstream_list.join('\n'),
|
||||
origin_host: route.origin_host || '',
|
||||
custom_headers_text: customHeadersToText(route.custom_header_list),
|
||||
remark: route.remark || '',
|
||||
});
|
||||
}, [form, route]);
|
||||
|
||||
return (
|
||||
<ConfigSectionShell
|
||||
title="反向代理"
|
||||
description="第一行作为主回源;如果填写多行,会自动进入多上游负载均衡模式。"
|
||||
formId="proxy-route-proxy-form"
|
||||
saving={saving}
|
||||
>
|
||||
<form
|
||||
id="proxy-route-proxy-form"
|
||||
className="space-y-5"
|
||||
onSubmit={form.handleSubmit((values) => {
|
||||
const { urls } = parseOriginUrls(values.origin_urls_text);
|
||||
const primaryOrigin = parseOriginUrl(urls[0]);
|
||||
const { headers } = parseCustomHeadersText(values.custom_headers_text);
|
||||
|
||||
onSave(
|
||||
buildPayloadFromRoute(route, {
|
||||
origin_id: null,
|
||||
origin_url: urls[0],
|
||||
origin_scheme: primaryOrigin.scheme,
|
||||
origin_address: primaryOrigin.address,
|
||||
origin_port: primaryOrigin.port,
|
||||
origin_uri: primaryOrigin.uri,
|
||||
origin_host: values.origin_host.trim(),
|
||||
upstreams: urls.slice(1),
|
||||
custom_headers: headers,
|
||||
remark: values.remark.trim(),
|
||||
}),
|
||||
{ message: '反向代理设置已保存。' },
|
||||
);
|
||||
})}
|
||||
>
|
||||
<ResourceField
|
||||
label="上游地址"
|
||||
hint="每行一个完整 URL。多上游模式下不要带 path 或 query。"
|
||||
error={form.formState.errors.origin_urls_text?.message}
|
||||
>
|
||||
<ResourceTextarea
|
||||
className="min-h-40"
|
||||
placeholder={'https://origin-a.internal:443\nhttps://origin-b.internal:443'}
|
||||
{...form.register('origin_urls_text')}
|
||||
/>
|
||||
</ResourceField>
|
||||
|
||||
<ResourceField
|
||||
label="Origin Host Header"
|
||||
hint="留空时默认透传访问域名 $host。"
|
||||
error={form.formState.errors.origin_host?.message}
|
||||
>
|
||||
<ResourceInput
|
||||
placeholder="origin.example.internal"
|
||||
{...form.register('origin_host')}
|
||||
/>
|
||||
</ResourceField>
|
||||
|
||||
<ResourceField
|
||||
label="自定义请求头"
|
||||
hint="每行一条,格式为 Key: Value。"
|
||||
error={form.formState.errors.custom_headers_text?.message}
|
||||
>
|
||||
<ResourceTextarea
|
||||
className="min-h-32"
|
||||
placeholder={'X-Trace-Id: $request_id\nX-Site: marketing'}
|
||||
{...form.register('custom_headers_text')}
|
||||
/>
|
||||
</ResourceField>
|
||||
|
||||
<ResourceField
|
||||
label="备注"
|
||||
error={form.formState.errors.remark?.message}
|
||||
>
|
||||
<ResourceTextarea
|
||||
placeholder="例如:多活回源,优先使用上海入口"
|
||||
{...form.register('remark')}
|
||||
/>
|
||||
</ResourceField>
|
||||
</form>
|
||||
</ConfigSectionShell>
|
||||
);
|
||||
}
|
||||
|
||||
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_id: route.cert_id ? String(route.cert_id) : '',
|
||||
redirect_http: route.redirect_http,
|
||||
},
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
form.reset({
|
||||
enable_https: route.enable_https,
|
||||
cert_id: route.cert_id ? String(route.cert_id) : '',
|
||||
redirect_http: route.redirect_http,
|
||||
});
|
||||
}, [form, route]);
|
||||
|
||||
const watchedEnableHTTPS = form.watch('enable_https');
|
||||
|
||||
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_id ? Number(values.cert_id) : null,
|
||||
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_id', '', { shouldDirty: true });
|
||||
form.setValue('redirect_http', false, { shouldDirty: true });
|
||||
}
|
||||
}}
|
||||
/>
|
||||
|
||||
<ResourceField
|
||||
label="证书"
|
||||
error={form.formState.errors.cert_id?.message}
|
||||
hint="请确保该证书能覆盖当前站点的全部域名。"
|
||||
>
|
||||
<ResourceSelect
|
||||
disabled={!watchedEnableHTTPS}
|
||||
{...form.register('cert_id')}
|
||||
>
|
||||
<option value="">请选择证书</option>
|
||||
{certificates.map((certificate) => (
|
||||
<option key={certificate.id} value={certificate.id}>
|
||||
{certificate.name}
|
||||
</option>
|
||||
))}
|
||||
</ResourceSelect>
|
||||
</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,
|
||||
onSave,
|
||||
}: {
|
||||
route: ProxyRouteItem;
|
||||
saving: boolean;
|
||||
onSave: SaveHandler;
|
||||
}) {
|
||||
const form = useForm<CacheValues>({
|
||||
resolver: zodResolver(cacheSchema),
|
||||
defaultValues: {
|
||||
cache_enabled: route.cache_enabled,
|
||||
cache_policy: (route.cache_policy || 'url') as CacheValues['cache_policy'],
|
||||
cache_rules_text: route.cache_rule_list.join('\n'),
|
||||
},
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
form.reset({
|
||||
cache_enabled: route.cache_enabled,
|
||||
cache_policy: (route.cache_policy || 'url') as CacheValues['cache_policy'],
|
||||
cache_rules_text: route.cache_rule_list.join('\n'),
|
||||
});
|
||||
}, [form, route]);
|
||||
|
||||
const watchedEnabled = form.watch('cache_enabled');
|
||||
const watchedPolicy = form.watch('cache_policy');
|
||||
|
||||
return (
|
||||
<ConfigSectionShell
|
||||
title="缓存"
|
||||
description="保留现有安全绕过逻辑,只对当前站点生效。"
|
||||
formId="proxy-route-cache-form"
|
||||
saving={saving}
|
||||
>
|
||||
<form
|
||||
id="proxy-route-cache-form"
|
||||
className="space-y-5"
|
||||
onSubmit={form.handleSubmit((values) => {
|
||||
const rules = linesFromTextarea(values.cache_rules_text);
|
||||
onSave(
|
||||
buildPayloadFromRoute(route, {
|
||||
cache_enabled: values.cache_enabled,
|
||||
cache_policy: values.cache_enabled ? values.cache_policy : 'url',
|
||||
cache_rules:
|
||||
values.cache_enabled && values.cache_policy !== 'url' ? rules : [],
|
||||
}),
|
||||
{ message: '缓存设置已保存。' },
|
||||
);
|
||||
})}
|
||||
>
|
||||
<ToggleField
|
||||
label="启用站点缓存"
|
||||
description="系统仍会自动绕过非 GET、带 Authorization 或常见登录态 Cookie 的请求。"
|
||||
checked={watchedEnabled}
|
||||
onChange={(checked) =>
|
||||
form.setValue('cache_enabled', checked, { shouldDirty: true })
|
||||
}
|
||||
/>
|
||||
|
||||
<ResourceField label="缓存策略">
|
||||
<ResourceSelect
|
||||
disabled={!watchedEnabled}
|
||||
{...form.register('cache_policy')}
|
||||
>
|
||||
<option value="url">按 URL 缓存</option>
|
||||
<option value="suffix">按后缀缓存</option>
|
||||
<option value="path_prefix">按路径前缀缓存</option>
|
||||
<option value="path_exact">按精确路径缓存</option>
|
||||
</ResourceSelect>
|
||||
</ResourceField>
|
||||
|
||||
<ResourceField
|
||||
label="缓存规则"
|
||||
error={form.formState.errors.cache_rules_text?.message}
|
||||
hint={
|
||||
watchedPolicy === 'suffix'
|
||||
? '每行一个后缀,例如 jpg、css、js。'
|
||||
: watchedPolicy === 'path_prefix'
|
||||
? '每行一个路径前缀,例如 /assets、/static。'
|
||||
: watchedPolicy === 'path_exact'
|
||||
? '每行一个精确路径,例如 /robots.txt。'
|
||||
: '按 URL 缓存时无需额外规则。'
|
||||
}
|
||||
>
|
||||
<ResourceTextarea
|
||||
disabled={!watchedEnabled || watchedPolicy === 'url'}
|
||||
className="min-h-32"
|
||||
placeholder={
|
||||
watchedPolicy === 'suffix'
|
||||
? 'jpg\ncss\njs'
|
||||
: watchedPolicy === 'path_prefix'
|
||||
? '/assets\n/static'
|
||||
: watchedPolicy === 'path_exact'
|
||||
? '/robots.txt\n/manifest.json'
|
||||
: '按 URL 缓存无需额外规则'
|
||||
}
|
||||
{...form.register('cache_rules_text')}
|
||||
/>
|
||||
</ResourceField>
|
||||
</form>
|
||||
</ConfigSectionShell>
|
||||
);
|
||||
}
|
||||
|
||||
export function ProxyRouteConfigPage({
|
||||
routeId,
|
||||
initialSection,
|
||||
}: {
|
||||
routeId: string;
|
||||
initialSection?: string;
|
||||
}) {
|
||||
const queryClient = useQueryClient();
|
||||
const [feedback, setFeedback] = useState<FeedbackState | null>(null);
|
||||
|
||||
const numericRouteID = Number(routeId);
|
||||
const currentSection = getWebsiteConfigSection(initialSection);
|
||||
|
||||
const routeQuery = useQuery({
|
||||
queryKey: ['proxy-routes', 'detail', numericRouteID],
|
||||
queryFn: () => getProxyRoute(numericRouteID),
|
||||
enabled: Number.isFinite(numericRouteID) && numericRouteID > 0,
|
||||
});
|
||||
const certificatesQuery = useQuery({
|
||||
queryKey: ['tls-certificates', 'list'],
|
||||
queryFn: getTlsCertificates,
|
||||
});
|
||||
|
||||
const saveMutation = useMutation({
|
||||
mutationFn: async ({
|
||||
payload,
|
||||
context,
|
||||
}: {
|
||||
payload: Parameters<typeof updateProxyRoute>[1];
|
||||
context: SaveContext;
|
||||
}) => {
|
||||
const updatedRoute = await updateProxyRoute(numericRouteID, payload);
|
||||
return { updatedRoute, context };
|
||||
},
|
||||
onSuccess: async ({ updatedRoute, context }) => {
|
||||
queryClient.setQueryData(
|
||||
['proxy-routes', 'detail', numericRouteID],
|
||||
updatedRoute,
|
||||
);
|
||||
setFeedback({ tone: 'success', message: context.message });
|
||||
await Promise.all([
|
||||
queryClient.invalidateQueries({ queryKey: ['proxy-routes'] }),
|
||||
queryClient.invalidateQueries({ queryKey: ['config-versions', 'diff'] }),
|
||||
]);
|
||||
},
|
||||
onError: (error) => {
|
||||
setFeedback({ tone: 'danger', message: getErrorMessage(error) });
|
||||
},
|
||||
});
|
||||
|
||||
const route = routeQuery.data;
|
||||
const certificates = useMemo(
|
||||
() => certificatesQuery.data ?? [],
|
||||
[certificatesQuery.data],
|
||||
);
|
||||
|
||||
if (!Number.isFinite(numericRouteID) || numericRouteID <= 0) {
|
||||
return (
|
||||
<EmptyState
|
||||
title="缺少网站 ID"
|
||||
description="请从网站列表进入配置子页面。"
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
if (routeQuery.isLoading || certificatesQuery.isLoading) {
|
||||
return <LoadingState />;
|
||||
}
|
||||
|
||||
if (routeQuery.isError) {
|
||||
return (
|
||||
<ErrorState
|
||||
title="网站详情加载失败"
|
||||
description={getErrorMessage(routeQuery.error)}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
if (certificatesQuery.isError) {
|
||||
return (
|
||||
<ErrorState
|
||||
title="证书列表加载失败"
|
||||
description={getErrorMessage(certificatesQuery.error)}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
if (!route) {
|
||||
return (
|
||||
<EmptyState
|
||||
title="网站不存在"
|
||||
description="该网站可能已被删除,或当前 ID 无法匹配到记录。"
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
const currentSectionMeta =
|
||||
websiteConfigSections.find((section) => section.key === currentSection) ??
|
||||
websiteConfigSections[0];
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<PageHeader
|
||||
title={route.site_name}
|
||||
description={`主域名 ${route.primary_domain},共 ${route.domain_count} 个域名`}
|
||||
action={
|
||||
<div className="flex flex-wrap gap-3">
|
||||
<Link
|
||||
href="/proxy-route"
|
||||
className="inline-flex items-center justify-center rounded-2xl border border-[var(--border-default)] bg-[var(--control-background)] px-4 py-3 text-sm font-medium text-[var(--foreground-primary)] transition hover:bg-[var(--control-background-hover)]"
|
||||
>
|
||||
返回列表
|
||||
</Link>
|
||||
<SecondaryButton
|
||||
type="button"
|
||||
onClick={() =>
|
||||
queryClient.invalidateQueries({
|
||||
queryKey: ['proxy-routes', 'detail', numericRouteID],
|
||||
})
|
||||
}
|
||||
>
|
||||
刷新详情
|
||||
</SecondaryButton>
|
||||
</div>
|
||||
}
|
||||
/>
|
||||
|
||||
{feedback ? (
|
||||
<InlineMessage tone={feedback.tone} message={feedback.message} />
|
||||
) : null}
|
||||
|
||||
<div className="grid gap-6 xl:grid-cols-[280px_minmax(0,1fr)]">
|
||||
<aside className="space-y-4">
|
||||
<AppCard title="配置分区" description="每个分区独立保存,切换时不会自动提交。">
|
||||
<div className="space-y-2">
|
||||
{websiteConfigSections.map((section) => {
|
||||
const active = section.key === currentSection;
|
||||
return (
|
||||
<Link
|
||||
key={section.key}
|
||||
href={`/proxy-route/detail?id=${route.id}§ion=${section.key}`}
|
||||
className={cn(
|
||||
'block rounded-2xl border px-4 py-3 transition',
|
||||
active
|
||||
? 'border-[var(--border-strong)] bg-[var(--accent-soft)]'
|
||||
: 'border-[var(--border-default)] bg-[var(--surface-elevated)] hover:border-[var(--border-strong)]',
|
||||
)}
|
||||
>
|
||||
<p className="text-sm font-medium text-[var(--foreground-primary)]">
|
||||
{section.label}
|
||||
</p>
|
||||
<p className="mt-1 text-xs leading-5 text-[var(--foreground-secondary)]">
|
||||
{section.description}
|
||||
</p>
|
||||
</Link>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</AppCard>
|
||||
|
||||
<AppCard title="站点摘要">
|
||||
<div className="space-y-3">
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{getWebsiteStatusBadges(route).map((badge) => (
|
||||
<StatusBadge
|
||||
key={badge.label}
|
||||
label={badge.label}
|
||||
variant={badge.variant}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
<div className="space-y-2 text-sm text-[var(--foreground-secondary)]">
|
||||
<p>站点标识:{route.site_name}</p>
|
||||
<p>主域名:{route.primary_domain}</p>
|
||||
<p>上游数量:{route.upstream_list.length}</p>
|
||||
<p>最近更新:{formatDateTime(route.updated_at)}</p>
|
||||
</div>
|
||||
</div>
|
||||
</AppCard>
|
||||
|
||||
<AppCard title="当前分区">
|
||||
<p className="text-sm font-medium text-[var(--foreground-primary)]">
|
||||
{currentSectionMeta.label}
|
||||
</p>
|
||||
<p className="mt-2 text-sm leading-6 text-[var(--foreground-secondary)]">
|
||||
{currentSectionMeta.description}
|
||||
</p>
|
||||
</AppCard>
|
||||
</aside>
|
||||
|
||||
<div className="min-w-0 space-y-6">
|
||||
{currentSection === 'domains' ? (
|
||||
<DomainSettingsSection
|
||||
route={route}
|
||||
saving={saveMutation.isPending}
|
||||
onSave={(payload, context) =>
|
||||
saveMutation.mutate({ payload, context })
|
||||
}
|
||||
/>
|
||||
) : null}
|
||||
|
||||
{currentSection === 'limits' ? (
|
||||
<RateLimitSection
|
||||
route={route}
|
||||
saving={saveMutation.isPending}
|
||||
onSave={(payload, context) =>
|
||||
saveMutation.mutate({ payload, context })
|
||||
}
|
||||
/>
|
||||
) : null}
|
||||
|
||||
{currentSection === 'proxy' ? (
|
||||
<ReverseProxySection
|
||||
route={route}
|
||||
saving={saveMutation.isPending}
|
||||
onSave={(payload, context) =>
|
||||
saveMutation.mutate({ payload, context })
|
||||
}
|
||||
/>
|
||||
) : null}
|
||||
|
||||
{currentSection === 'https' ? (
|
||||
<HTTPSSection
|
||||
route={route}
|
||||
certificates={certificates}
|
||||
saving={saveMutation.isPending}
|
||||
onSave={(payload, context) =>
|
||||
saveMutation.mutate({ payload, context })
|
||||
}
|
||||
/>
|
||||
) : null}
|
||||
|
||||
{currentSection === 'cache' ? (
|
||||
<CacheSection
|
||||
route={route}
|
||||
saving={saveMutation.isPending}
|
||||
onSave={(payload, context) =>
|
||||
saveMutation.mutate({ payload, context })
|
||||
}
|
||||
/>
|
||||
) : null}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,219 @@
|
||||
'use client';
|
||||
|
||||
import { zodResolver } from '@hookform/resolvers/zod';
|
||||
import { useMutation } from '@tanstack/react-query';
|
||||
import { useEffect } from 'react';
|
||||
import { useForm } from 'react-hook-form';
|
||||
import { z } from 'zod';
|
||||
|
||||
import { Drawer } from '@/components/ui/drawer';
|
||||
import { createProxyRoute } from '@/features/proxy-routes/api/proxy-routes';
|
||||
import {
|
||||
buildOriginUrl,
|
||||
getErrorMessage,
|
||||
linesFromTextarea,
|
||||
parseOriginUrl,
|
||||
parseOriginUrls,
|
||||
validateDomains,
|
||||
} from '@/features/proxy-routes/helpers';
|
||||
import type { ProxyRouteItem } from '@/features/proxy-routes/types';
|
||||
import {
|
||||
PrimaryButton,
|
||||
ResourceField,
|
||||
ResourceTextarea,
|
||||
ToggleField,
|
||||
} from '@/features/shared/components/resource-primitives';
|
||||
|
||||
const createWebsiteSchema = z
|
||||
.object({
|
||||
site_name: z.string().trim().max(255, '站点标识不能超过 255 个字符'),
|
||||
domains_text: z.string().trim().min(1, '请至少填写一个域名'),
|
||||
origin_urls_text: z.string().trim().min(1, '请至少填写一个上游地址'),
|
||||
enabled: z.boolean(),
|
||||
remark: z.string().max(255, '备注不能超过 255 个字符'),
|
||||
})
|
||||
.superRefine((value, context) => {
|
||||
const domains = linesFromTextarea(value.domains_text).map((item) =>
|
||||
item.toLowerCase(),
|
||||
);
|
||||
const domainError = validateDomains(domains);
|
||||
if (domainError) {
|
||||
context.addIssue({
|
||||
code: z.ZodIssueCode.custom,
|
||||
path: ['domains_text'],
|
||||
message: domainError,
|
||||
});
|
||||
}
|
||||
|
||||
const { error } = parseOriginUrls(value.origin_urls_text);
|
||||
if (error) {
|
||||
context.addIssue({
|
||||
code: z.ZodIssueCode.custom,
|
||||
path: ['origin_urls_text'],
|
||||
message: error,
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
type CreateWebsiteFormValues = z.infer<typeof createWebsiteSchema>;
|
||||
|
||||
const defaultValues: CreateWebsiteFormValues = {
|
||||
site_name: '',
|
||||
domains_text: '',
|
||||
origin_urls_text: '',
|
||||
enabled: true,
|
||||
remark: '',
|
||||
};
|
||||
|
||||
export function ProxyRouteCreateDrawer({
|
||||
open,
|
||||
onOpenChange,
|
||||
onCreated,
|
||||
}: {
|
||||
open: boolean;
|
||||
onOpenChange: (open: boolean) => void;
|
||||
onCreated: (route: ProxyRouteItem) => void;
|
||||
}) {
|
||||
const form = useForm<CreateWebsiteFormValues>({
|
||||
resolver: zodResolver(createWebsiteSchema),
|
||||
defaultValues,
|
||||
});
|
||||
|
||||
const createMutation = useMutation({
|
||||
mutationFn: async (values: CreateWebsiteFormValues) => {
|
||||
const domains = linesFromTextarea(values.domains_text).map((item) =>
|
||||
item.toLowerCase(),
|
||||
);
|
||||
const { urls } = parseOriginUrls(values.origin_urls_text);
|
||||
const primaryOrigin = parseOriginUrl(urls[0]);
|
||||
|
||||
return createProxyRoute({
|
||||
site_name: values.site_name.trim() || domains[0],
|
||||
domain: domains[0],
|
||||
domains,
|
||||
origin_id: null,
|
||||
origin_url: buildOriginUrl(
|
||||
primaryOrigin.scheme,
|
||||
primaryOrigin.address,
|
||||
primaryOrigin.port,
|
||||
primaryOrigin.uri,
|
||||
),
|
||||
origin_scheme: primaryOrigin.scheme,
|
||||
origin_address: primaryOrigin.address,
|
||||
origin_port: primaryOrigin.port,
|
||||
origin_uri: primaryOrigin.uri,
|
||||
origin_host: '',
|
||||
upstreams: urls.slice(1),
|
||||
enabled: values.enabled,
|
||||
enable_https: false,
|
||||
cert_id: null,
|
||||
redirect_http: false,
|
||||
limit_conn_per_server: 0,
|
||||
limit_conn_per_ip: 0,
|
||||
limit_rate: '',
|
||||
cache_enabled: false,
|
||||
cache_policy: 'url',
|
||||
cache_rules: [],
|
||||
custom_headers: [],
|
||||
remark: values.remark.trim(),
|
||||
});
|
||||
},
|
||||
onSuccess: (route) => {
|
||||
form.reset(defaultValues);
|
||||
onOpenChange(false);
|
||||
onCreated(route);
|
||||
},
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
if (!open) {
|
||||
form.reset(defaultValues);
|
||||
}
|
||||
}, [form, open]);
|
||||
|
||||
return (
|
||||
<Drawer
|
||||
open={open}
|
||||
onOpenChange={onOpenChange}
|
||||
direction="right"
|
||||
title="新建网站"
|
||||
description="先完成最小可用的站点录入,创建后再进入配置页继续补齐 HTTPS、缓存和限流。"
|
||||
footer={
|
||||
<div className="flex items-center justify-end gap-3">
|
||||
<PrimaryButton
|
||||
type="submit"
|
||||
form="create-website-form"
|
||||
disabled={createMutation.isPending}
|
||||
>
|
||||
{createMutation.isPending ? '创建中...' : '创建并进入配置'}
|
||||
</PrimaryButton>
|
||||
</div>
|
||||
}
|
||||
>
|
||||
<form
|
||||
id="create-website-form"
|
||||
className="space-y-5"
|
||||
onSubmit={form.handleSubmit((values) => createMutation.mutate(values))}
|
||||
>
|
||||
<ResourceField
|
||||
label="站点标识"
|
||||
hint="可选。留空时会自动使用第一行域名。"
|
||||
error={form.formState.errors.site_name?.message}
|
||||
>
|
||||
<input
|
||||
{...form.register('site_name')}
|
||||
placeholder="marketing-site"
|
||||
className="w-full rounded-2xl border border-[var(--border-default)] bg-[var(--surface-elevated)] px-4 py-3 text-sm text-[var(--foreground-primary)] outline-none transition focus:border-[var(--border-strong)]"
|
||||
/>
|
||||
</ResourceField>
|
||||
|
||||
<ResourceField
|
||||
label="域名列表"
|
||||
hint="每行一个域名,第一行会作为主域名。"
|
||||
error={form.formState.errors.domains_text?.message}
|
||||
>
|
||||
<ResourceTextarea
|
||||
placeholder={'app.example.com\nwww.example.com'}
|
||||
{...form.register('domains_text')}
|
||||
/>
|
||||
</ResourceField>
|
||||
|
||||
<ResourceField
|
||||
label="上游地址"
|
||||
hint="每行一个完整 URL。第一行作为主回源,多上游模式下请保持相同协议且不要带 path/query。"
|
||||
error={form.formState.errors.origin_urls_text?.message}
|
||||
>
|
||||
<ResourceTextarea
|
||||
placeholder={'https://origin-a.internal:443\nhttps://origin-b.internal:443'}
|
||||
{...form.register('origin_urls_text')}
|
||||
/>
|
||||
</ResourceField>
|
||||
|
||||
<ToggleField
|
||||
label="创建后立即启用"
|
||||
description="关闭后网站会先以草稿形式保存,发布配置前仍可继续编辑。"
|
||||
checked={form.watch('enabled')}
|
||||
onChange={(checked) =>
|
||||
form.setValue('enabled', checked, { shouldDirty: true })
|
||||
}
|
||||
/>
|
||||
|
||||
<ResourceField
|
||||
label="备注"
|
||||
error={form.formState.errors.remark?.message}
|
||||
>
|
||||
<ResourceTextarea
|
||||
placeholder="例如:营销站点,优先指向上海入口"
|
||||
{...form.register('remark')}
|
||||
/>
|
||||
</ResourceField>
|
||||
|
||||
{createMutation.isError ? (
|
||||
<p className="text-sm text-[var(--status-danger-foreground)]">
|
||||
{getErrorMessage(createMutation.error)}
|
||||
</p>
|
||||
) : null}
|
||||
</form>
|
||||
</Drawer>
|
||||
);
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,319 @@
|
||||
import type {
|
||||
ProxyRouteCustomHeader,
|
||||
ProxyRouteItem,
|
||||
ProxyRouteMutationPayload,
|
||||
} from '@/features/proxy-routes/types';
|
||||
|
||||
export const websiteConfigSections = [
|
||||
{
|
||||
key: 'domains',
|
||||
label: '域名设置',
|
||||
description: '维护站点标识、主域名顺序和启用状态。',
|
||||
},
|
||||
{
|
||||
key: 'limits',
|
||||
label: '流量限制',
|
||||
description: '设置连接数和限速,0 或空值表示关闭。',
|
||||
},
|
||||
{
|
||||
key: 'proxy',
|
||||
label: '反向代理',
|
||||
description: '配置主回源、多上游、Host 头和自定义请求头。',
|
||||
},
|
||||
{
|
||||
key: 'https',
|
||||
label: 'HTTPS',
|
||||
description: '选择证书并控制 HTTP 到 HTTPS 的跳转策略。',
|
||||
},
|
||||
{
|
||||
key: 'cache',
|
||||
label: '缓存',
|
||||
description: '按 URL、后缀或路径规则启用站点级缓存。',
|
||||
},
|
||||
] as const;
|
||||
|
||||
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;
|
||||
const originHostPattern =
|
||||
/^(?:(?:[a-z0-9-]+\.)*[a-z0-9-]+|\[[0-9a-f:.]+\]|[0-9.]+)(?::\d{1,5})?$/i;
|
||||
const headerKeyPattern = /^[A-Za-z0-9_-]+$/;
|
||||
const limitRatePattern = /^\d+(?:[kKmM])?$/;
|
||||
|
||||
export function getErrorMessage(error: unknown) {
|
||||
return error instanceof Error ? error.message : '请求失败,请稍后重试。';
|
||||
}
|
||||
|
||||
export function getWebsiteConfigSection(
|
||||
value: string | null | undefined,
|
||||
): WebsiteConfigSectionKey {
|
||||
return websiteConfigSections.some((section) => section.key === value)
|
||||
? (value as WebsiteConfigSectionKey)
|
||||
: 'domains';
|
||||
}
|
||||
|
||||
export function linesFromTextarea(value: string) {
|
||||
return value
|
||||
.split(/\r?\n/)
|
||||
.map((item) => item.trim())
|
||||
.filter(Boolean);
|
||||
}
|
||||
|
||||
export function validateDomains(domains: string[]) {
|
||||
if (domains.length === 0) {
|
||||
return '请至少填写一个域名';
|
||||
}
|
||||
|
||||
const seen = new Set<string>();
|
||||
for (const domain of domains) {
|
||||
const normalized = domain.trim().toLowerCase();
|
||||
if (!domainPattern.test(normalized)) {
|
||||
return `域名格式不合法:${domain}`;
|
||||
}
|
||||
if (seen.has(normalized)) {
|
||||
return `域名重复:${domain}`;
|
||||
}
|
||||
seen.add(normalized);
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
export function parseOriginUrls(value: string) {
|
||||
const urls = linesFromTextarea(value);
|
||||
if (urls.length === 0) {
|
||||
return { urls: [], error: '请至少填写一个上游地址' };
|
||||
}
|
||||
|
||||
let sharedScheme = '';
|
||||
for (const originUrl of urls) {
|
||||
let parsed: URL;
|
||||
try {
|
||||
parsed = new URL(originUrl);
|
||||
} catch {
|
||||
return { urls: [], error: `上游地址格式不合法:${originUrl}` };
|
||||
}
|
||||
|
||||
if (parsed.protocol !== 'http:' && parsed.protocol !== 'https:') {
|
||||
return {
|
||||
urls: [],
|
||||
error: `上游地址必须以 http:// 或 https:// 开头:${originUrl}`,
|
||||
};
|
||||
}
|
||||
|
||||
if (!parsed.hostname) {
|
||||
return { urls: [], error: `上游地址缺少主机名:${originUrl}` };
|
||||
}
|
||||
|
||||
if (urls.length > 1) {
|
||||
if ((parsed.pathname && parsed.pathname !== '/') || parsed.search) {
|
||||
return {
|
||||
urls: [],
|
||||
error: '多上游模式暂不支持带路径或查询参数的地址',
|
||||
};
|
||||
}
|
||||
|
||||
if (!sharedScheme) {
|
||||
sharedScheme = parsed.protocol;
|
||||
} else if (sharedScheme !== parsed.protocol) {
|
||||
return {
|
||||
urls: [],
|
||||
error: '同一站点的多个上游必须使用相同协议',
|
||||
};
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return { urls, error: null };
|
||||
}
|
||||
|
||||
export function parseOriginUrl(originUrl: string) {
|
||||
const parsed = new URL(originUrl);
|
||||
const port =
|
||||
parsed.port || (parsed.protocol === 'http:' ? '80' : '443');
|
||||
const path = parsed.pathname === '/' ? '' : parsed.pathname;
|
||||
|
||||
return {
|
||||
scheme: parsed.protocol.replace(':', '') as 'http' | 'https',
|
||||
address: parsed.hostname,
|
||||
port,
|
||||
uri: parsed.search ? `${path}${parsed.search}` || parsed.search : path,
|
||||
};
|
||||
}
|
||||
|
||||
export function buildOriginUrl(
|
||||
scheme: 'http' | 'https',
|
||||
address: string,
|
||||
port: string,
|
||||
uri: string,
|
||||
) {
|
||||
const normalizedAddress = address.trim();
|
||||
const normalizedPort = port.trim();
|
||||
const normalizedURI = uri.trim();
|
||||
if (!normalizedAddress || !normalizedPort) {
|
||||
return '';
|
||||
}
|
||||
|
||||
const host =
|
||||
normalizedAddress.includes(':') && !normalizedAddress.startsWith('[')
|
||||
? `[${normalizedAddress}]`
|
||||
: normalizedAddress;
|
||||
|
||||
return `${scheme}://${host}:${normalizedPort}${normalizedURI}`;
|
||||
}
|
||||
|
||||
export function validateOriginHost(value: string) {
|
||||
const normalized = value.trim();
|
||||
if (!normalized) {
|
||||
return null;
|
||||
}
|
||||
if (
|
||||
normalized.includes('://') ||
|
||||
/[\/\\\s]/.test(normalized) ||
|
||||
!originHostPattern.test(normalized)
|
||||
) {
|
||||
return '回源主机名格式不合法';
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
export function parseCustomHeadersText(value: string) {
|
||||
const lines = linesFromTextarea(value);
|
||||
const headers: ProxyRouteCustomHeader[] = [];
|
||||
|
||||
for (const line of lines) {
|
||||
const separatorIndex = line.indexOf(':');
|
||||
if (separatorIndex <= 0) {
|
||||
return {
|
||||
headers: [],
|
||||
error: `自定义请求头格式不合法:${line}`,
|
||||
};
|
||||
}
|
||||
|
||||
const key = line.slice(0, separatorIndex).trim();
|
||||
const headerValue = line.slice(separatorIndex + 1).trim();
|
||||
|
||||
if (!headerKeyPattern.test(key)) {
|
||||
return {
|
||||
headers: [],
|
||||
error: `自定义请求头名称不合法:${key}`,
|
||||
};
|
||||
}
|
||||
|
||||
headers.push({ key, value: headerValue });
|
||||
}
|
||||
|
||||
return { headers, error: null };
|
||||
}
|
||||
|
||||
export function customHeadersToText(headers: ProxyRouteCustomHeader[]) {
|
||||
return headers.map((header) => `${header.key}: ${header.value}`).join('\n');
|
||||
}
|
||||
|
||||
export function validateLimitRate(value: string) {
|
||||
const normalized = value.trim();
|
||||
if (!normalized || normalized === '0') {
|
||||
return null;
|
||||
}
|
||||
if (!limitRatePattern.test(normalized)) {
|
||||
return '限速格式不合法,请使用 512k、1m 或纯数字';
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
export function normalizeLimitRate(value: string) {
|
||||
const normalized = value.trim().toLowerCase();
|
||||
return normalized === '0' ? '' : normalized;
|
||||
}
|
||||
|
||||
export function validateCacheRules(
|
||||
policy: 'url' | 'suffix' | 'path_prefix' | 'path_exact',
|
||||
rules: string[],
|
||||
) {
|
||||
if (policy === 'url') {
|
||||
return null;
|
||||
}
|
||||
|
||||
if (rules.length === 0) {
|
||||
return '当前缓存策略至少需要一条规则';
|
||||
}
|
||||
|
||||
if (policy === 'suffix') {
|
||||
for (const rule of rules) {
|
||||
const normalized = rule.replace(/^\./, '');
|
||||
if (!normalized || /[\/\\\s]/.test(normalized)) {
|
||||
return `缓存后缀格式不合法:${rule}`;
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
for (const rule of rules) {
|
||||
if (
|
||||
!rule.startsWith('/') ||
|
||||
rule.includes('://') ||
|
||||
/[\s]/.test(rule)
|
||||
) {
|
||||
return `缓存路径规则格式不合法:${rule}`;
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
export function buildPayloadFromRoute(
|
||||
route: ProxyRouteItem,
|
||||
overrides: Partial<ProxyRouteMutationPayload>,
|
||||
): ProxyRouteMutationPayload {
|
||||
const primaryOrigin = parseOriginUrl(route.origin_url);
|
||||
|
||||
return {
|
||||
site_name: route.site_name,
|
||||
domain: route.primary_domain,
|
||||
domains: route.domains,
|
||||
origin_id: null,
|
||||
origin_url: route.origin_url,
|
||||
origin_scheme: primaryOrigin.scheme,
|
||||
origin_address: primaryOrigin.address,
|
||||
origin_port: primaryOrigin.port,
|
||||
origin_uri: primaryOrigin.uri,
|
||||
origin_host: route.origin_host || '',
|
||||
upstreams: route.upstream_list.slice(1),
|
||||
enabled: route.enabled,
|
||||
enable_https: route.enable_https,
|
||||
cert_id: route.cert_id,
|
||||
redirect_http: route.redirect_http,
|
||||
limit_conn_per_server: route.limit_conn_per_server,
|
||||
limit_conn_per_ip: route.limit_conn_per_ip,
|
||||
limit_rate: route.limit_rate,
|
||||
cache_enabled: route.cache_enabled,
|
||||
cache_policy: route.cache_policy || 'url',
|
||||
cache_rules: route.cache_rule_list,
|
||||
custom_headers: route.custom_header_list,
|
||||
remark: route.remark || '',
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
export function getUpstreamSummary(route: ProxyRouteItem) {
|
||||
if (route.upstream_list.length <= 1) {
|
||||
return route.origin_url;
|
||||
}
|
||||
return `${route.upstream_list.length} 个上游,主上游 ${route.origin_url}`;
|
||||
}
|
||||
|
||||
export function getWebsiteStatusBadges(route: ProxyRouteItem) {
|
||||
return [
|
||||
route.enabled
|
||||
? { label: '已启用', variant: 'success' as const }
|
||||
: { label: '已停用', variant: 'warning' as const },
|
||||
route.enable_https
|
||||
? { label: 'HTTPS', variant: 'info' as const }
|
||||
: { label: 'HTTP', variant: 'warning' as const },
|
||||
route.cache_enabled
|
||||
? { label: '缓存开启', variant: 'success' as const }
|
||||
: { label: '缓存关闭', variant: 'warning' as const },
|
||||
];
|
||||
}
|
||||
|
||||
@@ -1,17 +1,22 @@
|
||||
import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
|
||||
import { render, screen, within } from '@testing-library/react';
|
||||
import { render, screen, waitFor, within } from '@testing-library/react';
|
||||
import userEvent from '@testing-library/user-event';
|
||||
import { afterEach, describe, expect, it, vi } from 'vitest';
|
||||
import type { ReactNode } from 'react';
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
|
||||
import { ThemeProvider } from '@/components/providers/theme-provider';
|
||||
import { ProxyRouteConfigPage } from '@/features/proxy-routes/components/proxy-route-config-page';
|
||||
import { ProxyRoutesPage } from '@/features/proxy-routes/components/proxy-routes-page';
|
||||
|
||||
describe('ProxyRoutesPage', () => {
|
||||
afterEach(() => {
|
||||
vi.unstubAllGlobals();
|
||||
});
|
||||
const pushMock = vi.fn();
|
||||
|
||||
function stubMatchMedia() {
|
||||
vi.mock('next/navigation', () => ({
|
||||
useRouter: () => ({
|
||||
push: pushMock,
|
||||
}),
|
||||
}));
|
||||
|
||||
function stubMatchMedia() {
|
||||
vi.stubGlobal(
|
||||
'matchMedia',
|
||||
vi.fn().mockImplementation(() => ({
|
||||
@@ -20,40 +25,98 @@ describe('ProxyRoutesPage', () => {
|
||||
removeEventListener: vi.fn(),
|
||||
})),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
function renderProxyRoutesPage() {
|
||||
function buildRoute(overrides: Record<string, unknown> = {}) {
|
||||
return {
|
||||
id: 9,
|
||||
site_name: 'marketing-site',
|
||||
domain: 'app.example.com',
|
||||
domains: ['app.example.com', 'www.example.com'],
|
||||
primary_domain: 'app.example.com',
|
||||
domain_count: 2,
|
||||
origin_id: null,
|
||||
origin_url: 'https://origin-a.internal:443',
|
||||
origin_host: '',
|
||||
upstreams: JSON.stringify([
|
||||
'https://origin-a.internal:443',
|
||||
'https://origin-b.internal:443',
|
||||
]),
|
||||
upstream_list: [
|
||||
'https://origin-a.internal:443',
|
||||
'https://origin-b.internal:443',
|
||||
],
|
||||
enabled: true,
|
||||
enable_https: true,
|
||||
cert_id: 1,
|
||||
redirect_http: true,
|
||||
limit_conn_per_server: 120,
|
||||
limit_conn_per_ip: 12,
|
||||
limit_rate: '512k',
|
||||
cache_enabled: true,
|
||||
cache_policy: 'path_prefix',
|
||||
cache_rules: JSON.stringify(['/assets']),
|
||||
cache_rule_list: ['/assets'],
|
||||
custom_headers: JSON.stringify([{ key: 'X-Site', value: 'marketing' }]),
|
||||
custom_header_list: [{ key: 'X-Site', value: 'marketing' }],
|
||||
remark: 'Marketing website',
|
||||
created_at: '2026-03-20T08:00:00Z',
|
||||
updated_at: '2026-03-21T08:00:00Z',
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
function buildDiff(overrides: Record<string, unknown> = {}) {
|
||||
return {
|
||||
active_version: '20260330-001',
|
||||
added_sites: [],
|
||||
removed_sites: [],
|
||||
modified_sites: [],
|
||||
added_domains: [],
|
||||
removed_domains: [],
|
||||
modified_domains: [],
|
||||
main_config_changed: false,
|
||||
changed_option_keys: [],
|
||||
changed_option_details: [],
|
||||
current_website_count: 1,
|
||||
active_website_count: 1,
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
function renderWithProviders(ui: ReactNode) {
|
||||
const queryClient = new QueryClient({
|
||||
defaultOptions: {
|
||||
queries: {
|
||||
retry: false,
|
||||
},
|
||||
mutations: {
|
||||
retry: false,
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
render(
|
||||
<QueryClientProvider client={queryClient}>
|
||||
<ThemeProvider>
|
||||
<ProxyRoutesPage />
|
||||
</ThemeProvider>
|
||||
<ThemeProvider>{ui}</ThemeProvider>
|
||||
</QueryClientProvider>,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
function buildBaseFetchStub({
|
||||
proxyRoutes = [],
|
||||
managedDomains,
|
||||
origins = [],
|
||||
certificates = [{ id: 1, name: 'example-cert', not_after: null }],
|
||||
matchResult,
|
||||
}: {
|
||||
proxyRoutes?: Array<Record<string, unknown>>;
|
||||
managedDomains: Array<Record<string, unknown>>;
|
||||
origins?: Array<Record<string, unknown>>;
|
||||
certificates?: Array<Record<string, unknown>>;
|
||||
matchResult?: Record<string, unknown>;
|
||||
}) {
|
||||
return vi.fn((input: RequestInfo | URL) => {
|
||||
describe('Proxy route website pages', () => {
|
||||
beforeEach(() => {
|
||||
pushMock.mockReset();
|
||||
stubMatchMedia();
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vi.unstubAllGlobals();
|
||||
});
|
||||
|
||||
it('renders website list summary with config entry', async () => {
|
||||
vi.stubGlobal(
|
||||
'fetch',
|
||||
vi.fn((input: RequestInfo | URL) => {
|
||||
const url = String(input);
|
||||
|
||||
if (url.includes('/proxy-routes/')) {
|
||||
@@ -62,56 +125,191 @@ describe('ProxyRoutesPage', () => {
|
||||
JSON.stringify({
|
||||
success: true,
|
||||
message: '',
|
||||
data: proxyRoutes,
|
||||
data: [buildRoute()],
|
||||
}),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
if (url.includes('/managed-domains/match?')) {
|
||||
if (url.includes('/config-versions/diff')) {
|
||||
return Promise.resolve(
|
||||
new Response(
|
||||
JSON.stringify({
|
||||
success: true,
|
||||
message: '',
|
||||
data:
|
||||
matchResult ??
|
||||
{
|
||||
domain: 'a.example.com',
|
||||
matched: true,
|
||||
candidate: {
|
||||
managed_domain_id: 2,
|
||||
domain: 'a.example.com',
|
||||
match_type: 'exact',
|
||||
certificate_id: 1,
|
||||
certificate_name: 'example-cert',
|
||||
},
|
||||
candidates: [],
|
||||
},
|
||||
data: buildDiff({
|
||||
modified_sites: ['marketing-site'],
|
||||
}),
|
||||
}),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
if (url.includes('/managed-domains/')) {
|
||||
return Promise.reject(new Error(`Unhandled fetch: ${url}`));
|
||||
}),
|
||||
);
|
||||
|
||||
renderWithProviders(<ProxyRoutesPage />);
|
||||
|
||||
expect(await screen.findByText('marketing-site')).toBeInTheDocument();
|
||||
expect(screen.getAllByText(/app\.example\.com/).length).toBeGreaterThan(0);
|
||||
expect(screen.getByRole('link')).toHaveAttribute(
|
||||
'href',
|
||||
'/proxy-route/detail?id=9§ion=domains',
|
||||
);
|
||||
});
|
||||
|
||||
it('creates a website and navigates to config page', async () => {
|
||||
const routes: Array<Record<string, unknown>> = [];
|
||||
|
||||
vi.stubGlobal(
|
||||
'fetch',
|
||||
vi.fn((input: RequestInfo | URL, init?: RequestInit) => {
|
||||
const url = String(input);
|
||||
const method = init?.method?.toUpperCase() ?? 'GET';
|
||||
|
||||
if (url.includes('/proxy-routes/') && method === 'POST') {
|
||||
const payload = JSON.parse(String(init?.body));
|
||||
const created = buildRoute({
|
||||
id: 21,
|
||||
site_name: payload.site_name,
|
||||
domain: payload.domain,
|
||||
domains: payload.domains,
|
||||
primary_domain: payload.domain,
|
||||
domain_count: payload.domains.length,
|
||||
origin_url: payload.origin_url,
|
||||
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,
|
||||
limit_conn_per_server: 0,
|
||||
limit_conn_per_ip: 0,
|
||||
limit_rate: '',
|
||||
cache_enabled: false,
|
||||
cache_policy: 'url',
|
||||
cache_rules: '[]',
|
||||
cache_rule_list: [],
|
||||
custom_headers: '[]',
|
||||
custom_header_list: [],
|
||||
remark: payload.remark,
|
||||
});
|
||||
routes.splice(0, routes.length, created);
|
||||
|
||||
return Promise.resolve(
|
||||
new Response(
|
||||
JSON.stringify({
|
||||
success: true,
|
||||
message: '',
|
||||
data: managedDomains,
|
||||
data: created,
|
||||
}),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
if (url.includes('/origins/')) {
|
||||
if (url.includes('/proxy-routes/')) {
|
||||
return Promise.resolve(
|
||||
new Response(
|
||||
JSON.stringify({
|
||||
success: true,
|
||||
message: '',
|
||||
data: origins,
|
||||
data: routes,
|
||||
}),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
if (url.includes('/config-versions/diff')) {
|
||||
return Promise.resolve(
|
||||
new Response(
|
||||
JSON.stringify({
|
||||
success: true,
|
||||
message: '',
|
||||
data: buildDiff(),
|
||||
}),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
return Promise.reject(new Error(`Unhandled fetch: ${url}`));
|
||||
}),
|
||||
);
|
||||
|
||||
renderWithProviders(<ProxyRoutesPage />);
|
||||
|
||||
const user = userEvent.setup();
|
||||
const pageButtons = await screen.findAllByRole('button');
|
||||
await user.click(pageButtons[1]);
|
||||
|
||||
const dialog = await screen.findByRole('dialog');
|
||||
expect(dialog).toBeInTheDocument();
|
||||
|
||||
const dialogTextboxes = within(dialog).getAllByRole('textbox');
|
||||
await user.type(dialogTextboxes[0], 'launch-site');
|
||||
await user.type(
|
||||
dialogTextboxes[1],
|
||||
'app.example.com{enter}www.example.com',
|
||||
);
|
||||
await user.type(
|
||||
dialogTextboxes[2],
|
||||
'https://origin-a.internal:443{enter}https://origin-b.internal:443',
|
||||
);
|
||||
|
||||
const submitButton = document.querySelector(
|
||||
'button[form="create-website-form"]',
|
||||
) as HTMLButtonElement | null;
|
||||
expect(submitButton).not.toBeNull();
|
||||
await user.click(submitButton!);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(pushMock).toHaveBeenCalledWith(
|
||||
'/proxy-route/detail?id=21§ion=domains',
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
it('saves domain settings from config page by section', async () => {
|
||||
const updateRequests: Array<Record<string, unknown>> = [];
|
||||
|
||||
vi.stubGlobal(
|
||||
'fetch',
|
||||
vi.fn((input: RequestInfo | URL, init?: RequestInit) => {
|
||||
const url = String(input);
|
||||
const method = init?.method?.toUpperCase() ?? 'GET';
|
||||
|
||||
if (url.includes('/proxy-routes/9/update') && method === 'POST') {
|
||||
const payload = JSON.parse(String(init?.body)) as Record<
|
||||
string,
|
||||
unknown
|
||||
>;
|
||||
updateRequests.push(payload);
|
||||
|
||||
return Promise.resolve(
|
||||
new Response(
|
||||
JSON.stringify({
|
||||
success: true,
|
||||
message: '',
|
||||
data: buildRoute({
|
||||
site_name: payload.site_name,
|
||||
domain: (payload.domains as string[])[0],
|
||||
domains: payload.domains,
|
||||
primary_domain: (payload.domains as string[])[0],
|
||||
domain_count: (payload.domains as string[]).length,
|
||||
enabled: payload.enabled,
|
||||
}),
|
||||
}),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
if (url.includes('/proxy-routes/9')) {
|
||||
return Promise.resolve(
|
||||
new Response(
|
||||
JSON.stringify({
|
||||
success: true,
|
||||
message: '',
|
||||
data: buildRoute(),
|
||||
}),
|
||||
),
|
||||
);
|
||||
@@ -123,319 +321,48 @@ describe('ProxyRoutesPage', () => {
|
||||
JSON.stringify({
|
||||
success: true,
|
||||
message: '',
|
||||
data: certificates,
|
||||
data: [{ id: 1, name: 'example-cert', not_after: null }],
|
||||
}),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
return Promise.reject(new Error(`Unhandled fetch: ${url}`));
|
||||
});
|
||||
}
|
||||
|
||||
it('shows wildcard subdomain input after selecting a wildcard website', async () => {
|
||||
stubMatchMedia();
|
||||
|
||||
vi.stubGlobal(
|
||||
'fetch',
|
||||
buildBaseFetchStub({
|
||||
managedDomains: [
|
||||
{
|
||||
id: 1,
|
||||
domain: '*.example.com',
|
||||
cert_id: 1,
|
||||
enabled: true,
|
||||
remark: '',
|
||||
created_at: '2026-03-20T08:00:00Z',
|
||||
updated_at: '2026-03-20T08:00:00Z',
|
||||
},
|
||||
{
|
||||
id: 2,
|
||||
domain: 'a.example.com',
|
||||
cert_id: 1,
|
||||
enabled: true,
|
||||
remark: '',
|
||||
created_at: '2026-03-20T08:00:00Z',
|
||||
updated_at: '2026-03-20T08:00:00Z',
|
||||
},
|
||||
],
|
||||
}),
|
||||
);
|
||||
|
||||
renderProxyRoutesPage();
|
||||
renderWithProviders(
|
||||
<ProxyRouteConfigPage routeId="9" initialSection="domains" />,
|
||||
);
|
||||
|
||||
const user = userEvent.setup();
|
||||
await user.click(await screen.findByRole('button', { name: '新增规则' }));
|
||||
const dialog = await screen.findByRole('dialog', { name: '新增规则' });
|
||||
expect(await screen.findByText('marketing-site')).toBeInTheDocument();
|
||||
|
||||
const domainInput = within(dialog).getByPlaceholderText('输入并搜索目标域名');
|
||||
await user.type(domainInput, 'example');
|
||||
await user.keyboard('{Enter}');
|
||||
const [siteNameInput, domainsText] = screen.getAllByRole('textbox');
|
||||
await user.clear(siteNameInput);
|
||||
await user.type(siteNameInput, 'brand-site');
|
||||
|
||||
expect(await screen.findByPlaceholderText('e.g. ai')).toBeInTheDocument();
|
||||
expect(domainInput).toHaveValue('*.example.com');
|
||||
await user.clear(domainsText);
|
||||
await user.type(
|
||||
domainsText,
|
||||
'brand.example.com{enter}www.brand.example.com',
|
||||
);
|
||||
|
||||
await user.type(screen.getByPlaceholderText('e.g. ai'), 'ai');
|
||||
expect(screen.getByPlaceholderText('e.g. ai')).toHaveValue('ai');
|
||||
const saveButton = document.querySelector(
|
||||
'button[form="proxy-route-domains-form"]',
|
||||
) as HTMLButtonElement | null;
|
||||
expect(saveButton).not.toBeNull();
|
||||
await user.click(saveButton!);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(updateRequests).toHaveLength(1);
|
||||
});
|
||||
|
||||
it('uses exact website directly without showing subdomain input', async () => {
|
||||
stubMatchMedia();
|
||||
|
||||
vi.stubGlobal(
|
||||
'fetch',
|
||||
buildBaseFetchStub({
|
||||
managedDomains: [
|
||||
{
|
||||
id: 2,
|
||||
domain: 'a.example.com',
|
||||
cert_id: 1,
|
||||
expect(updateRequests[0]).toMatchObject({
|
||||
site_name: 'brand-site',
|
||||
domain: 'brand.example.com',
|
||||
domains: ['brand.example.com', 'www.brand.example.com'],
|
||||
enabled: true,
|
||||
remark: '',
|
||||
created_at: '2026-03-20T08:00:00Z',
|
||||
updated_at: '2026-03-20T08:00:00Z',
|
||||
},
|
||||
],
|
||||
}),
|
||||
);
|
||||
|
||||
renderProxyRoutesPage();
|
||||
|
||||
const user = userEvent.setup();
|
||||
await user.click(await screen.findByRole('button', { name: '新增规则' }));
|
||||
const dialog = await screen.findByRole('dialog', { name: '新增规则' });
|
||||
|
||||
const domainInput = within(dialog).getByPlaceholderText('输入并搜索目标域名');
|
||||
await user.type(domainInput, 'a.example');
|
||||
await user.keyboard('{Enter}');
|
||||
|
||||
expect(screen.queryByPlaceholderText('e.g. ai')).not.toBeInTheDocument();
|
||||
expect(domainInput).toHaveValue('a.example.com');
|
||||
expect(await screen.findByText('Select Certificate')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('shows origin autocomplete suggestions and empty state', async () => {
|
||||
stubMatchMedia();
|
||||
|
||||
vi.stubGlobal(
|
||||
'fetch',
|
||||
buildBaseFetchStub({
|
||||
managedDomains: [
|
||||
{
|
||||
id: 1,
|
||||
domain: '*.example.com',
|
||||
cert_id: 1,
|
||||
enabled: true,
|
||||
remark: '',
|
||||
created_at: '2026-03-20T08:00:00Z',
|
||||
updated_at: '2026-03-20T08:00:00Z',
|
||||
},
|
||||
],
|
||||
origins: [
|
||||
{
|
||||
id: 1,
|
||||
name: 'Main Backend',
|
||||
address: '192.168.1.45',
|
||||
remark: '',
|
||||
route_count: 1,
|
||||
created_at: '2026-03-20T08:00:00Z',
|
||||
updated_at: '2026-03-20T08:00:00Z',
|
||||
},
|
||||
],
|
||||
}),
|
||||
);
|
||||
|
||||
renderProxyRoutesPage();
|
||||
|
||||
const user = userEvent.setup();
|
||||
await user.click(await screen.findByRole('button', { name: '新增规则' }));
|
||||
|
||||
const domainInput = await screen.findByPlaceholderText('输入并搜索目标域名');
|
||||
await user.type(domainInput, 'example');
|
||||
await user.keyboard('{Enter}');
|
||||
|
||||
const addressInput = screen.getByPlaceholderText('192.168.1.45');
|
||||
await user.click(addressInput);
|
||||
await user.type(addressInput, '192.');
|
||||
|
||||
expect(await screen.findByText('192.168.1.45')).toBeInTheDocument();
|
||||
expect(await screen.findByText('(Main Backend)')).toBeInTheDocument();
|
||||
expect(await screen.findByText('Local')).toBeInTheDocument();
|
||||
|
||||
await user.clear(addressInput);
|
||||
await user.type(addressInput, '10.10.');
|
||||
|
||||
expect(
|
||||
await screen.findByText('未发现匹配资产,请手动输入'),
|
||||
).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('opens create drawer with advanced section expanded and http selected by default', async () => {
|
||||
stubMatchMedia();
|
||||
|
||||
vi.stubGlobal(
|
||||
'fetch',
|
||||
buildBaseFetchStub({
|
||||
managedDomains: [
|
||||
{
|
||||
id: 1,
|
||||
domain: '*.example.com',
|
||||
cert_id: 1,
|
||||
enabled: true,
|
||||
remark: '',
|
||||
created_at: '2026-03-20T08:00:00Z',
|
||||
updated_at: '2026-03-20T08:00:00Z',
|
||||
},
|
||||
],
|
||||
}),
|
||||
);
|
||||
|
||||
renderProxyRoutesPage();
|
||||
|
||||
const user = userEvent.setup();
|
||||
await user.click(await screen.findByRole('button', { name: '新增规则' }));
|
||||
const dialog = await screen.findByRole('dialog', { name: '新增规则' });
|
||||
|
||||
expect(within(dialog).getByRole('heading', { name: 'Advanced' })).toBeInTheDocument();
|
||||
expect(within(dialog).getByLabelText('协议 1')).toHaveTextContent('HTTP');
|
||||
});
|
||||
|
||||
it('closes managed domain suggestions when clicking outside', async () => {
|
||||
stubMatchMedia();
|
||||
|
||||
vi.stubGlobal(
|
||||
'fetch',
|
||||
buildBaseFetchStub({
|
||||
managedDomains: [
|
||||
{
|
||||
id: 1,
|
||||
domain: '*.example.com',
|
||||
cert_id: 1,
|
||||
enabled: true,
|
||||
remark: '',
|
||||
created_at: '2026-03-20T08:00:00Z',
|
||||
updated_at: '2026-03-20T08:00:00Z',
|
||||
},
|
||||
],
|
||||
}),
|
||||
);
|
||||
|
||||
renderProxyRoutesPage();
|
||||
|
||||
const user = userEvent.setup();
|
||||
await user.click(await screen.findByRole('button', { name: '新增规则' }));
|
||||
const domainInput = await screen.findByPlaceholderText('输入并搜索目标域名');
|
||||
|
||||
await user.click(domainInput);
|
||||
await user.type(domainInput, 'exam');
|
||||
expect(await screen.findByText('*.example.com')).toBeInTheDocument();
|
||||
|
||||
await user.click(
|
||||
within(await screen.findByRole('dialog', { name: '新增规则' })).getByRole(
|
||||
'heading',
|
||||
{ name: 'Protocol' },
|
||||
),
|
||||
);
|
||||
expect(screen.queryByText('*.example.com')).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('supports arrow key navigation for managed domain suggestions', async () => {
|
||||
stubMatchMedia();
|
||||
|
||||
vi.stubGlobal(
|
||||
'fetch',
|
||||
buildBaseFetchStub({
|
||||
managedDomains: [
|
||||
{
|
||||
id: 1,
|
||||
domain: '*.example.com',
|
||||
cert_id: 1,
|
||||
enabled: true,
|
||||
remark: '',
|
||||
created_at: '2026-03-20T08:00:00Z',
|
||||
updated_at: '2026-03-20T08:00:00Z',
|
||||
},
|
||||
{
|
||||
id: 2,
|
||||
domain: 'app.example.com',
|
||||
cert_id: 1,
|
||||
enabled: true,
|
||||
remark: '',
|
||||
created_at: '2026-03-20T08:00:00Z',
|
||||
updated_at: '2026-03-20T08:00:00Z',
|
||||
},
|
||||
],
|
||||
}),
|
||||
);
|
||||
|
||||
renderProxyRoutesPage();
|
||||
|
||||
const user = userEvent.setup();
|
||||
await user.click(await screen.findByRole('button', { name: '新增规则' }));
|
||||
const dialog = await screen.findByRole('dialog', { name: '新增规则' });
|
||||
const domainInput = within(dialog).getByPlaceholderText('输入并搜索目标域名');
|
||||
|
||||
await user.type(domainInput, 'example');
|
||||
await user.keyboard('{ArrowDown}');
|
||||
await user.keyboard('{Enter}');
|
||||
|
||||
expect(domainInput).toHaveValue('app.example.com');
|
||||
expect(screen.queryByPlaceholderText('e.g. ai')).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('prefills managed domain and origin rows correctly when editing an existing rule', async () => {
|
||||
stubMatchMedia();
|
||||
|
||||
vi.stubGlobal(
|
||||
'fetch',
|
||||
buildBaseFetchStub({
|
||||
proxyRoutes: [
|
||||
{
|
||||
id: 3,
|
||||
domain: 'ai.example.com',
|
||||
origin_id: 1,
|
||||
origin_url: 'https://c2.internal:443',
|
||||
origin_host: '',
|
||||
upstreams: JSON.stringify(['https://c2.internal:443']),
|
||||
enabled: true,
|
||||
enable_https: true,
|
||||
cert_id: 1,
|
||||
redirect_http: false,
|
||||
cache_enabled: false,
|
||||
cache_policy: 'url',
|
||||
cache_rules: '[]',
|
||||
custom_headers: '[]',
|
||||
remark: '',
|
||||
created_at: '2026-03-20T08:00:00Z',
|
||||
updated_at: '2026-03-20T08:00:00Z',
|
||||
},
|
||||
],
|
||||
managedDomains: [
|
||||
{
|
||||
id: 3,
|
||||
domain: '*.example.com',
|
||||
cert_id: 1,
|
||||
enabled: true,
|
||||
remark: '',
|
||||
created_at: '2026-03-20T08:00:00Z',
|
||||
updated_at: '2026-03-20T08:00:00Z',
|
||||
},
|
||||
],
|
||||
}),
|
||||
);
|
||||
|
||||
renderProxyRoutesPage();
|
||||
|
||||
const user = userEvent.setup();
|
||||
await user.click(await screen.findByRole('button', { name: '编辑' }));
|
||||
const dialog = await screen.findByRole('dialog', { name: '编辑规则' });
|
||||
|
||||
const domainInput = within(dialog).getByPlaceholderText('输入并搜索目标域名');
|
||||
expect(domainInput).toHaveValue('*.example.com');
|
||||
expect(within(dialog).getByPlaceholderText('e.g. ai')).toHaveValue('ai');
|
||||
expect(within(dialog).getAllByPlaceholderText('192.168.1.45')).toHaveLength(1);
|
||||
expect(within(dialog).getByPlaceholderText('192.168.1.45')).toHaveValue(
|
||||
'c2.internal',
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user