-
- Origin
+
+
+
+
+
+ {totalDomains}
-
- {primaryOriginPreview || '未填写源站'}
+
+ 每个网站第一行域名为主域名
-
-
-
-
-
+
-
- form.setValue('enabled', checked, { shouldDirty: true })
- }
- />
-
- }
- >
-
-
-
-
- {selectedManagedDomain && isWildcardSelection ? (
-
-
-
- ) : null}
-
-
-
-
- }
- action={
-
- }
- >
+
- {originFields.map((field, index) => {
- const currentRow =
- watchedOriginRows?.[index] ?? defaultValues.origin_rows[0];
-
- return (
-
-
-
- {`协议 ${index + 1}`}
- {
- const nextScheme =
- nextValue as OriginRowFormValue['scheme'];
- form.setValue(
- `origin_rows.${index}.scheme`,
- nextScheme,
- {
- shouldDirty: true,
- shouldValidate: true,
- },
- );
-
- const currentPort = form.getValues(
- `origin_rows.${index}.port`,
- );
- if (
- !currentPort ||
- currentPort === '80' ||
- currentPort === '443'
- ) {
- form.setValue(
- `origin_rows.${index}.port`,
- getDefaultPortForScheme(nextScheme),
- {
- shouldDirty: true,
- shouldValidate: true,
- },
- );
- }
- }}
- >
-
- HTTPS
-
-
- HTTP
-
-
-
-
-
- form.setValue(`origin_rows.${index}.address`, nextValue, {
- shouldDirty: true,
- shouldValidate: true,
- })
- }
- />
-
-
- {`端口 ${index + 1}`}
-
-
-
-
-
-
- {(form.formState.errors.origin_rows?.[index]?.address
- ?.message ||
- form.formState.errors.origin_rows?.[index]?.port
- ?.message) && (
-
- {form.formState.errors.origin_rows?.[index]?.address
- ?.message ||
- form.formState.errors.origin_rows?.[index]?.port
- ?.message}
-
- )}
-
- );
- })}
-
-
-
-
+
+
+ {diff
+ ? `网站变更 ${diff.added_sites.length + diff.removed_sites.length + diff.modified_sites.length} 项`
+ : '正在读取 diff...'}
+
-
+
- }
- >
-
-
-
-
-
- HTTPS Access
-
-
- Enforce secure TLS connections for this rule.
-
-
-
{
- form.setValue('enable_https', checked, {
- shouldDirty: true,
- shouldValidate: true,
- });
+
+
+
新增网站:{diff?.added_sites.length ?? 0}
+
删除网站:{diff?.removed_sites.length ?? 0}
+
修改网站:{diff?.modified_sites.length ?? 0}
+
+
+
- if (
- checked &&
- !form.getValues('cert_id') &&
- selectedManagedDomain?.cert_id
- ) {
- form.setValue(
- 'cert_id',
- String(selectedManagedDomain.cert_id),
- {
- shouldDirty: true,
- shouldValidate: true,
- },
- );
- }
-
- if (!checked) {
- form.setValue('cert_id', '', {
- shouldDirty: true,
- shouldValidate: true,
- });
- form.setValue('redirect_http', false, {
- shouldDirty: true,
- shouldValidate: true,
- });
- }
- }}
- />
-
-
- {watchedEnableHttps ? (
-
-
-
- form.setValue('cert_id', nextValue, {
- shouldDirty: true,
- shouldValidate: true,
- })
- }
- >
- {matchResult?.candidate ? (
- <>
-
- 自动匹配
-
-
- {matchResult.candidate.certificate_name}
-
- >
- ) : null}
-
- 全部证书
-
- {certificates
- .filter(
- (certificate) =>
- String(certificate.id) !==
- String(matchResult?.candidate?.certificate_id ?? ''),
- )
- .map((certificate) => (
-
- {buildCertificateLabel(certificate)}
-
- ))}
-
-
-
-
- form.setValue('redirect_http', checked, {
- shouldDirty: true,
- shouldValidate: true,
- })
- }
- />
-
- ) : null}
+
+
+
+
+ setKeyword(event.target.value)}
+ placeholder="搜索站点标识、域名、上游或备注"
+ />
-
- {matchResult?.matched && matchResult.candidates.length > 1 ? (
+ {diff && hasConfigChanges(diff) ? (
- {matchResult.candidates.map((candidate) => (
+ {diff.modified_sites.length > 0 ? (
- ))}
+ ) : null}
+ {diff.added_sites.length > 0 ? (
+
+ ) : null}
+ {diff.removed_sites.length > 0 ? (
+
+ ) : null}
) : null}
-
-
-
+ ) : (
+
+ {filteredRoutes.map((route) => (
+
+
+
+
+
+
+
+ {route.site_name}
+
+ {getWebsiteStatusBadges(route).map((badge) => (
+
+ ))}
+
+
+ 主域名 {route.primary_domain},共 {route.domain_count} 个域名
+
+
+
- {isAdvancedOpen ? (
-
-
-
-
-
-
+
+
+ 配置
+
+ handleDelete(route)}
+ disabled={deleteMutation.isPending}
+ >
+ 删除
+
+
+
-
-
-
-
- Custom Request Headers
+
+
+
+ 域名列表
-
- 使用 Key | Value 横向排列,方便快速录入运维透传头。
+
+ {route.domains.join(' / ')}
+
+
+
+
+
+ 上游摘要
+
+
+ {getUpstreamSummary(route)}
+
+
+
+
+
+ HTTPS / 证书
+
+
+ {route.enable_https
+ ? route.redirect_http
+ ? 'HTTPS 开启,HTTP 自动跳转'
+ : 'HTTPS 开启'
+ : '仅 HTTP'}
+
+
+
+
+
+ 最近更新
+
+
+ {formatDateTime(route.updated_at)}
-
-
- {headerFields.map((field, index) => (
-
-
-
-
-
- {(form.formState.errors.custom_headers?.[index]?.key
- ?.message ||
- form.formState.errors.custom_headers?.[index]
- ?.value?.message) && (
-
- {form.formState.errors.custom_headers?.[index]
- ?.key?.message ||
- form.formState.errors.custom_headers?.[index]
- ?.value?.message}
-
- )}
-
- ))}
-
+
+ {route.remark || '暂无备注'}
+
-
-
-
- {
- form.setValue('cache_enabled', checked, {
- shouldDirty: true,
- shouldValidate: true,
- });
- if (!checked) {
- form.setValue('cache_policy', 'url', {
- shouldDirty: true,
- shouldValidate: true,
- });
- form.setValue('cache_rules_text', '', {
- shouldDirty: true,
- shouldValidate: true,
- });
- }
- }}
- />
-
-
-
- form.setValue(
- 'cache_policy',
- nextValue as ProxyRouteFormValues['cache_policy'],
- {
- shouldDirty: true,
- shouldValidate: true,
- },
- )
- }
- >
- 按 URL 缓存
- 按后缀匹配缓存
- 按路径前缀缓存
- 按精确路径缓存
-
-
-
-
-
-
-
-
-
-
-
-
+
+ ))}
- ) : null}
-
-
-
+ )}
+
+
+
+
+
{
+ await Promise.all([
+ queryClient.invalidateQueries({ queryKey: ['proxy-routes'] }),
+ queryClient.invalidateQueries({ queryKey: ['config-versions', 'diff'] }),
+ ]);
+ router.push(`/proxy-route/detail?id=${route.id}§ion=domains`);
+ }}
+ />
>
);
}
diff --git a/openflare_server/web/features/proxy-routes/helpers.ts b/openflare_server/web/features/proxy-routes/helpers.ts
new file mode 100644
index 00000000..faae3462
--- /dev/null
+++ b/openflare_server/web/features/proxy-routes/helpers.ts
@@ -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();
+ 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 {
+ 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 },
+ ];
+}
+
diff --git a/openflare_server/web/tests/unit/proxy-routes-page.test.tsx b/openflare_server/web/tests/unit/proxy-routes-page.test.tsx
index deb4b6fc..f1d28d03 100644
--- a/openflare_server/web/tests/unit/proxy-routes-page.test.tsx
+++ b/openflare_server/web/tests/unit/proxy-routes-page.test.tsx
@@ -1,441 +1,368 @@
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', () => {
+const pushMock = vi.fn();
+
+vi.mock('next/navigation', () => ({
+ useRouter: () => ({
+ push: pushMock,
+ }),
+}));
+
+function stubMatchMedia() {
+ vi.stubGlobal(
+ 'matchMedia',
+ vi.fn().mockImplementation(() => ({
+ matches: false,
+ addEventListener: vi.fn(),
+ removeEventListener: vi.fn(),
+ })),
+ );
+}
+
+function buildRoute(overrides: Record = {}) {
+ 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 = {}) {
+ 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(
+
+ {ui}
+ ,
+ );
+}
+
+describe('Proxy route website pages', () => {
+ beforeEach(() => {
+ pushMock.mockReset();
+ stubMatchMedia();
+ });
+
afterEach(() => {
vi.unstubAllGlobals();
});
- function stubMatchMedia() {
- vi.stubGlobal(
- 'matchMedia',
- vi.fn().mockImplementation(() => ({
- matches: false,
- addEventListener: vi.fn(),
- removeEventListener: vi.fn(),
- })),
- );
- }
-
- function renderProxyRoutesPage() {
- const queryClient = new QueryClient({
- defaultOptions: {
- queries: {
- retry: false,
- },
- },
- });
-
- render(
-
-
-
-
- ,
- );
- }
-
- function buildBaseFetchStub({
- proxyRoutes = [],
- managedDomains,
- origins = [],
- certificates = [{ id: 1, name: 'example-cert', not_after: null }],
- matchResult,
- }: {
- proxyRoutes?: Array>;
- managedDomains: Array>;
- origins?: Array>;
- certificates?: Array>;
- matchResult?: Record;
- }) {
- return vi.fn((input: RequestInfo | URL) => {
- const url = String(input);
-
- if (url.includes('/proxy-routes/')) {
- return Promise.resolve(
- new Response(
- JSON.stringify({
- success: true,
- message: '',
- data: proxyRoutes,
- }),
- ),
- );
- }
-
- if (url.includes('/managed-domains/match?')) {
- 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: [],
- },
- }),
- ),
- );
- }
-
- if (url.includes('/managed-domains/')) {
- return Promise.resolve(
- new Response(
- JSON.stringify({
- success: true,
- message: '',
- data: managedDomains,
- }),
- ),
- );
- }
-
- if (url.includes('/origins/')) {
- return Promise.resolve(
- new Response(
- JSON.stringify({
- success: true,
- message: '',
- data: origins,
- }),
- ),
- );
- }
-
- if (url.includes('/tls-certificates/')) {
- return Promise.resolve(
- new Response(
- JSON.stringify({
- success: true,
- message: '',
- data: certificates,
- }),
- ),
- );
- }
-
- return Promise.reject(new Error(`Unhandled fetch: ${url}`));
- });
- }
-
- it('shows wildcard subdomain input after selecting a wildcard website', async () => {
- stubMatchMedia();
-
+ it('renders website list summary with config entry', async () => {
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',
- },
- ],
+ vi.fn((input: RequestInfo | URL) => {
+ const url = String(input);
+
+ if (url.includes('/proxy-routes/')) {
+ return Promise.resolve(
+ new Response(
+ JSON.stringify({
+ success: true,
+ message: '',
+ data: [buildRoute()],
+ }),
+ ),
+ );
+ }
+
+ if (url.includes('/config-versions/diff')) {
+ return Promise.resolve(
+ new Response(
+ JSON.stringify({
+ success: true,
+ message: '',
+ data: buildDiff({
+ modified_sites: ['marketing-site'],
+ }),
+ }),
+ ),
+ );
+ }
+
+ return Promise.reject(new Error(`Unhandled fetch: ${url}`));
}),
);
- renderProxyRoutesPage();
+ renderWithProviders();
- 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('{Enter}');
-
- expect(await screen.findByPlaceholderText('e.g. ai')).toBeInTheDocument();
- expect(domainInput).toHaveValue('*.example.com');
-
- await user.type(screen.getByPlaceholderText('e.g. ai'), 'ai');
- expect(screen.getByPlaceholderText('e.g. ai')).toHaveValue('ai');
+ 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('uses exact website directly without showing subdomain input', async () => {
- stubMatchMedia();
+ it('creates a website and navigates to config page', async () => {
+ const routes: Array> = [];
vi.stubGlobal(
'fetch',
- buildBaseFetchStub({
- managedDomains: [
- {
- 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',
- },
- ],
- }),
- );
+ vi.fn((input: RequestInfo | URL, init?: RequestInit) => {
+ const url = String(input);
+ const method = init?.method?.toUpperCase() ?? 'GET';
- 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,
+ 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: '[]',
- 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',
- },
- ],
+ custom_header_list: [],
+ remark: payload.remark,
+ });
+ routes.splice(0, routes.length, created);
+
+ return Promise.resolve(
+ new Response(
+ JSON.stringify({
+ success: true,
+ message: '',
+ data: created,
+ }),
+ ),
+ );
+ }
+
+ if (url.includes('/proxy-routes/')) {
+ return Promise.resolve(
+ new Response(
+ JSON.stringify({
+ success: true,
+ message: '',
+ 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}`));
}),
);
- renderProxyRoutesPage();
+ renderWithProviders();
const user = userEvent.setup();
- await user.click(await screen.findByRole('button', { name: '编辑' }));
- const dialog = await screen.findByRole('dialog', { name: '编辑规则' });
+ const pageButtons = await screen.findAllByRole('button');
+ await user.click(pageButtons[1]);
- 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',
+ 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> = [];
+
+ 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(),
+ }),
+ ),
+ );
+ }
+
+ if (url.includes('/tls-certificates/')) {
+ return Promise.resolve(
+ new Response(
+ JSON.stringify({
+ success: true,
+ message: '',
+ data: [{ id: 1, name: 'example-cert', not_after: null }],
+ }),
+ ),
+ );
+ }
+
+ return Promise.reject(new Error(`Unhandled fetch: ${url}`));
+ }),
+ );
+
+ renderWithProviders(
+ ,
+ );
+
+ const user = userEvent.setup();
+ expect(await screen.findByText('marketing-site')).toBeInTheDocument();
+
+ const [siteNameInput, domainsText] = screen.getAllByRole('textbox');
+ await user.clear(siteNameInput);
+ await user.type(siteNameInput, 'brand-site');
+
+ await user.clear(domainsText);
+ await user.type(
+ domainsText,
+ 'brand.example.com{enter}www.brand.example.com',
+ );
+
+ 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);
+ });
+
+ expect(updateRequests[0]).toMatchObject({
+ site_name: 'brand-site',
+ domain: 'brand.example.com',
+ domains: ['brand.example.com', 'www.brand.example.com'],
+ enabled: true,
+ });
});
});