[功能] 添加网站创建抽屉组件,支持域名和上游地址输入,更新相关逻辑和测试

This commit is contained in:
ryan
2026-03-30 15:07:59 +08:00
parent 383a039338
commit 25fe178cb2
7 changed files with 2140 additions and 2451 deletions
+6
View File
@@ -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}&section=${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&section=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&section=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',
);
});
});