Files
OpenFlare/openflare_server/web/features/proxy-routes/components/proxy-routes-page.tsx
T

2193 lines
71 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
'use client';
import { Select, SelectItem, SelectSection } from '@heroui/select';
import { zodResolver } from '@hookform/resolvers/zod';
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
import { useEffect, useMemo, useRef, useState } from 'react';
import { useFieldArray, useForm, useWatch } from 'react-hook-form';
import { z } from 'zod';
import { Separator } from "@/components/ui/separator"
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 { Input } from '@/components/ui/input';
import { Label } from '@/components/ui/label';
import { AppModal } from '@/components/ui/app-modal';
import { StatusBadge } from '@/components/ui/status-badge';
import { Switch } from '@/components/ui/switch';
import { Textarea } from '@/components/ui/textarea';
import {
getConfigVersionDiff,
publishConfigVersion,
} from '@/features/config-versions/api/config-versions';
import { getManagedDomains } from '@/features/managed-domains/api/managed-domains';
import type { ManagedDomainItem } from '@/features/managed-domains/types';
import { getOrigins } from '@/features/origins/api/origins';
import type { OriginItem } from '@/features/origins/types';
import {
createProxyRoute,
deleteProxyRoute,
getProxyRoutes,
getTlsCertificates,
matchManagedDomainCertificate,
updateProxyRoute,
} from '@/features/proxy-routes/api/proxy-routes';
import type {
ManagedDomainMatchResult,
ProxyRouteCustomHeader,
ProxyRouteItem,
ProxyRouteMutationPayload,
TlsCertificateItem,
} from '@/features/proxy-routes/types';
import {
buildRouteDomain,
findManagedDomainForRoute,
isWildcardManagedDomain,
} from '@/features/proxy-routes/utils';
import {
DangerButton,
PrimaryButton,
ResourceField,
SecondaryButton,
ToggleField,
} from '@/features/shared/components/resource-primitives';
import { cn } from '@/lib/utils/cn';
import { formatDateTime } from '@/lib/utils/date';
const customHeaderSchema = z.object({
key: z.string(),
value: z.string(),
});
const originProtocolValues = ['http', 'https'] as const;
const cachePolicyValues = [
'url',
'suffix',
'path_prefix',
'path_exact',
] as const;
const originRowSchema = z.object({
scheme: z.enum(originProtocolValues),
address: z.string(),
port: z.string(),
});
const proxyRouteSchema = z
.object({
managed_domain_id: z.string().trim().min(1, '请选择目标域名'),
subdomain_label: z.string(),
origin_rows: z.array(originRowSchema).min(1),
origin_uri: z.string(),
origin_host: z
.string()
.trim()
.refine(
(value) =>
!value ||
(!/[\/\\\s]/.test(value) &&
!value.includes('://') &&
(() => {
try {
const parsed = new URL(`http://${value}`);
return parsed.host === value && Boolean(parsed.hostname);
} catch {
return false;
}
})()),
'请输入合法的回源主机名',
),
enabled: z.boolean(),
enable_https: z.boolean(),
cert_id: z.string(),
redirect_http: z.boolean(),
cache_enabled: z.boolean(),
cache_policy: z.enum(cachePolicyValues),
cache_rules_text: z.string(),
custom_headers: z.array(customHeaderSchema).min(1),
remark: z.string().max(255, '备注不能超过 255 个字符'),
})
.superRefine((value, context) => {
const selectedManagedDomain = value.managed_domain_id.trim();
const subdomainLabel = value.subdomain_label.trim();
const isWildcard = selectedManagedDomain.startsWith('*.');
if (isWildcard && !subdomainLabel) {
context.addIssue({
code: z.ZodIssueCode.custom,
path: ['subdomain_label'],
message: '请输入子域名前缀',
});
}
if (
isWildcard &&
subdomainLabel &&
!/^[A-Za-z0-9](?:[A-Za-z0-9-]{0,61}[A-Za-z0-9])?$/.test(subdomainLabel)
) {
context.addIssue({
code: z.ZodIssueCode.custom,
path: ['subdomain_label'],
message: '子域名前缀仅支持单个标签,且只能包含字母、数字和中划线',
});
}
if (value.enable_https && !value.cert_id) {
context.addIssue({
code: z.ZodIssueCode.custom,
path: ['cert_id'],
message: '启用 HTTPS 时必须选择证书',
});
}
const normalizedOriginURI = value.origin_uri.trim();
if (
normalizedOriginURI &&
!normalizedOriginURI.startsWith('/') &&
!normalizedOriginURI.startsWith('?')
) {
context.addIssue({
code: z.ZodIssueCode.custom,
path: ['origin_uri'],
message: '回源路径需以 / 或 ? 开头',
});
}
value.origin_rows.forEach((row, index) => {
const normalizedAddress = row.address.trim();
const normalizedPort = row.port.trim();
if (!normalizedAddress) {
context.addIssue({
code: z.ZodIssueCode.custom,
path: ['origin_rows', index, 'address'],
message: '请输入源站地址',
});
}
if (
normalizedAddress &&
(/[/?#]/.test(normalizedAddress) || normalizedAddress.includes('://'))
) {
context.addIssue({
code: z.ZodIssueCode.custom,
path: ['origin_rows', index, 'address'],
message: '源站地址仅支持 IP、域名或主机名',
});
}
if (!normalizedPort) {
context.addIssue({
code: z.ZodIssueCode.custom,
path: ['origin_rows', index, 'port'],
message: '请输入端口',
});
}
const portNumber = Number(normalizedPort);
if (
normalizedPort &&
(!/^\d+$/.test(normalizedPort) ||
!Number.isInteger(portNumber) ||
portNumber < 1 ||
portNumber > 65535)
) {
context.addIssue({
code: z.ZodIssueCode.custom,
path: ['origin_rows', index, 'port'],
message: '端口需为 1 到 65535 的整数',
});
}
const originURL = buildOriginUrl(
row.scheme,
row.address,
row.port,
index === 0 ? value.origin_uri : '',
);
if (!originURL) {
context.addIssue({
code: z.ZodIssueCode.custom,
path: ['origin_rows', index, 'address'],
message: '请输入完整的源站信息',
});
}
});
if (value.cache_enabled) {
const cacheRules = parseCacheRulesText(value.cache_rules_text);
if (value.cache_policy !== 'url' && cacheRules.length === 0) {
context.addIssue({
code: z.ZodIssueCode.custom,
path: ['cache_rules_text'],
message: '当前缓存策略至少需要填写一条规则',
});
}
}
value.custom_headers.forEach((header, index) => {
const key = header.key.trim();
const headerValue = header.value.trim();
if (!key && !headerValue) {
return;
}
if (!key) {
context.addIssue({
code: z.ZodIssueCode.custom,
path: ['custom_headers', index, 'key'],
message: '请求头名称不能为空',
});
}
if (key && !/^[A-Za-z0-9_-]+$/.test(key)) {
context.addIssue({
code: z.ZodIssueCode.custom,
path: ['custom_headers', index, 'key'],
message: '请求头名称仅支持字母、数字、下划线和中划线',
});
}
if (/\r|\n/.test(key) || /\r|\n/.test(headerValue)) {
context.addIssue({
code: z.ZodIssueCode.custom,
path: ['custom_headers', index, 'value'],
message: '请求头不能包含换行',
});
}
});
});
type ProxyRouteFormValues = z.infer<typeof proxyRouteSchema>;
type OriginRowFormValue = ProxyRouteFormValues['origin_rows'][number];
type FeedbackState = {
tone: 'info' | 'success' | 'danger';
message: string;
};
type SectionIconProps = {
className?: string;
};
const INPUT_CLASS_NAME = 'h-10 rounded-xl px-3 py-2 text-sm';
const PANEL_CLASS_NAME =
'rounded-2xl border border-[var(--border-default)] bg-[color:color-mix(in_srgb,var(--surface-elevated)_82%,white_18%)] p-4 shadow-[var(--shadow-soft)]';
const HERO_SELECT_CLASS_NAMES = {
base: 'w-full',
trigger: cn(
INPUT_CLASS_NAME,
'min-h-10 rounded-xl border border-[var(--border-default)] bg-[var(--surface-elevated)] px-3 shadow-none data-[hover=true]:bg-[var(--surface-elevated)] data-[open=true]:border-[var(--border-strong)]',
),
value: 'text-sm text-[var(--foreground-primary)]',
popoverContent:
'rounded-2xl border border-[var(--border-default)] bg-[var(--surface-panel)] shadow-[var(--shadow-soft)]',
listboxWrapper: 'p-1',
} as const;
const defaultValues: ProxyRouteFormValues = {
managed_domain_id: '',
subdomain_label: '',
origin_rows: [{ scheme: 'https', address: '', port: '443' }],
origin_uri: '',
origin_host: '',
enabled: true,
enable_https: false,
cert_id: '',
redirect_http: false,
cache_enabled: false,
cache_policy: 'url',
cache_rules_text: '',
custom_headers: [{ key: '', value: '' }],
remark: '',
};
const routesQueryKey = ['proxy-routes'];
const certificatesQueryKey = ['tls-certificates'];
const managedDomainsQueryKey = ['managed-domains'];
const originsQueryKey = ['origins'];
const versionsQueryKey = ['config-versions'];
function GlobeIcon({ className }: SectionIconProps) {
return (
<svg
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="1.8"
className={className}
aria-hidden="true"
>
<circle cx="12" cy="12" r="9" />
<path d="M3 12h18" />
<path d="M12 3c3 3.2 4.5 6.2 4.5 9s-1.5 5.8-4.5 9c-3-3.2-4.5-6.2-4.5-9S9 6.2 12 3Z" />
</svg>
);
}
function ServerIcon({ className }: SectionIconProps) {
return (
<svg
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="1.8"
className={className}
aria-hidden="true"
>
<rect x="4" y="4" width="16" height="6" rx="1.5" />
<rect x="4" y="14" width="16" height="6" rx="1.5" />
<path d="M8 7h.01M8 17h.01M12 7h6M12 17h6" />
</svg>
);
}
function ShieldIcon({ className }: SectionIconProps) {
return (
<svg
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="1.8"
className={className}
aria-hidden="true"
>
<path d="M12 3 5 6v5c0 4.7 2.8 8.9 7 10 4.2-1.1 7-5.3 7-10V6l-7-3Z" />
<path d="m9.5 12 1.8 1.8 3.7-4" />
</svg>
);
}
function SlidersIcon({ className }: SectionIconProps) {
return (
<svg
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="1.8"
className={className}
aria-hidden="true"
>
<path d="M5 6h14M5 18h14M8 6v12M16 6v12" />
<circle cx="8" cy="10" r="2" />
<circle cx="16" cy="14" r="2" />
</svg>
);
}
function SearchIcon({ className }: SectionIconProps) {
return (
<svg
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="1.8"
className={className}
aria-hidden="true"
>
<circle cx="11" cy="11" r="6" />
<path d="m20 20-4.2-4.2" />
</svg>
);
}
function PlusIcon({ className }: SectionIconProps) {
return (
<svg
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="1.8"
className={className}
aria-hidden="true"
>
<path d="M12 5v14M5 12h14" />
</svg>
);
}
function TrashIcon({ className }: SectionIconProps) {
return (
<svg
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="1.8"
className={className}
aria-hidden="true"
>
<path d="M4 7h16M9 7V5h6v2M8 10v7M12 10v7M16 10v7M6 7l1 12h10l1-12" />
</svg>
);
}
function ChevronIcon({
className,
expanded = false,
}: SectionIconProps & { expanded?: boolean }) {
return (
<svg
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="1.8"
className={cn(
'transition-transform duration-200',
expanded ? 'rotate-180' : '',
className,
)}
aria-hidden="true"
>
<path d="m6 9 6 6 6-6" />
</svg>
);
}
function hasConfigChanges(diff: {
active_version?: string;
added_domains: string[];
removed_domains: string[];
modified_domains: string[];
main_config_changed: boolean;
changed_option_keys: string[];
}) {
return (
diff.added_domains.length > 0 ||
diff.removed_domains.length > 0 ||
diff.modified_domains.length > 0 ||
diff.main_config_changed ||
diff.changed_option_keys.length > 0 ||
!diff.active_version
);
}
function getErrorMessage(error: unknown) {
return error instanceof Error ? error.message : '请求失败,请稍后重试。';
}
function parseCustomHeaders(rawValue: string) {
if (!rawValue) {
return [] as ProxyRouteCustomHeader[];
}
try {
const parsed = JSON.parse(rawValue) as ProxyRouteCustomHeader[];
return Array.isArray(parsed) ? parsed : [];
} catch {
return [];
}
}
function parseCacheRules(rawValue: string) {
if (!rawValue) {
return [] as string[];
}
try {
const parsed = JSON.parse(rawValue) as string[];
return Array.isArray(parsed) ? parsed.filter(Boolean) : [];
} catch {
return [];
}
}
function parseCacheRulesText(value: string) {
return value
.split(/\r?\n/)
.map((item) => item.trim())
.filter(Boolean);
}
function parseUpstreams(rawValue: string) {
if (!rawValue) {
return [] as string[];
}
try {
const parsed = JSON.parse(rawValue) as string[];
return Array.isArray(parsed) ? parsed.filter(Boolean) : [];
} catch {
return [];
}
}
function buildCachePolicyLabel(policy: string) {
switch (policy) {
case 'suffix':
return '按后缀';
case 'path_prefix':
return '按前缀';
case 'path_exact':
return '按路径';
default:
return '按 URL';
}
}
function getCacheRulesHint(policy: string) {
switch (policy) {
case 'suffix':
return '每行一个后缀,例如:jpg、css、js。';
case 'path_prefix':
return '每行一个路径前缀,例如:/assets、/static/images。';
case 'path_exact':
return '每行一个精确路径,例如:/robots.txt、/manifest.json。';
default:
return '按 URL 缓存时无需额外规则,系统会按请求 URL 粒度缓存。';
}
}
function buildCertificateLabel(certificate: TlsCertificateItem) {
return certificate.not_after
? `${certificate.name}(到期:${formatDateTime(certificate.not_after)})`
: certificate.name;
}
function buildOriginUrl(
scheme: 'http' | 'https',
address: string,
port: string,
uri: string,
) {
const normalizedAddress = address.trim();
const normalizedPort = port.trim();
if (!normalizedAddress || !normalizedPort) {
return '';
}
const host =
normalizedAddress.includes(':') && !normalizedAddress.startsWith('[')
? `[${normalizedAddress}]`
: normalizedAddress;
const normalizedURI = uri.trim();
return `${scheme}://${host}:${normalizedPort}${normalizedURI}`;
}
function parseOriginUrl(rawValue: string) {
try {
const parsed = new URL(rawValue);
const uri = parsed.pathname === '/' ? '' : parsed.pathname;
return {
scheme: (parsed.protocol.replace(':', '') || 'https') as 'http' | 'https',
address: parsed.hostname,
port: parsed.port || (parsed.protocol === 'http:' ? '80' : '443'),
uri: parsed.search ? `${uri}${parsed.search}` || parsed.search : uri,
};
} catch {
return {
scheme: 'https' as const,
address: '',
port: '443',
uri: '',
};
}
}
function getDefaultPortForScheme(scheme: 'http' | 'https') {
return scheme === 'http' ? '80' : '443';
}
function toPayload(
values: ProxyRouteFormValues,
origins: OriginItem[],
): ProxyRouteMutationPayload {
const primaryOrigin = values.origin_rows[0];
const primaryOriginUrl = buildOriginUrl(
primaryOrigin.scheme,
primaryOrigin.address,
primaryOrigin.port,
values.origin_uri,
);
const primaryOriginRecord =
origins.find(
(item) =>
item.address.toLowerCase() === primaryOrigin.address.trim().toLowerCase(),
) ?? null;
return {
domain: buildRouteDomain(values.managed_domain_id, values.subdomain_label),
origin_id: primaryOriginRecord ? primaryOriginRecord.id : null,
origin_url: primaryOriginUrl,
origin_scheme: primaryOrigin.scheme,
origin_address: primaryOrigin.address.trim(),
origin_port: primaryOrigin.port.trim(),
origin_uri: values.origin_uri.trim(),
origin_host: values.origin_host.trim(),
upstreams: values.origin_rows
.slice(1)
.map((row) => buildOriginUrl(row.scheme, row.address, row.port, ''))
.filter(Boolean),
enabled: values.enabled,
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,
cache_enabled: values.cache_enabled,
cache_policy: values.cache_enabled ? values.cache_policy : 'url',
cache_rules: values.cache_enabled
? parseCacheRulesText(values.cache_rules_text)
: [],
custom_headers: values.custom_headers
.map((item) => ({ key: item.key.trim(), value: item.value.trim() }))
.filter((item) => item.key || item.value),
remark: values.remark.trim(),
};
}
function toFormValues(
route: ProxyRouteItem,
managedDomains: ManagedDomainItem[],
): ProxyRouteFormValues {
const headers = parseCustomHeaders(route.custom_headers);
const cacheRules = parseCacheRules(route.cache_rules);
const upstreams = parseUpstreams(route.upstreams);
const managedDomainMatch = findManagedDomainForRoute(
route.domain,
managedDomains,
);
if (!managedDomainMatch) {
throw new Error(
`规则 ${route.domain} 未匹配到网站,请先补充对应网站后再编辑。`,
);
}
const primaryOrigin = parseOriginUrl(route.origin_url);
const normalizedOrigins = Array.from(
new Set([route.origin_url, ...upstreams].filter(Boolean)),
);
const originRows: OriginRowFormValue[] = normalizedOrigins.map((origin) => {
const parsed = parseOriginUrl(origin);
return {
scheme: parsed.scheme,
address: parsed.address,
port: parsed.port,
};
});
return {
managed_domain_id: managedDomainMatch.managedDomainId,
subdomain_label: managedDomainMatch.subdomainLabel,
origin_rows:
originRows.length > 0
? originRows
: [{ scheme: 'https', address: '', port: '443' }],
origin_uri: primaryOrigin.uri,
origin_host: route.origin_host || '',
enabled: route.enabled,
enable_https: route.enable_https,
cert_id: route.cert_id ? String(route.cert_id) : '',
redirect_http: route.redirect_http,
cache_enabled: route.cache_enabled,
cache_policy: (route.cache_policy ||
'url') as ProxyRouteFormValues['cache_policy'],
cache_rules_text: cacheRules.join('\n'),
custom_headers: headers.length > 0 ? headers : [{ key: '', value: '' }],
remark: route.remark || '',
};
}
function getMatchMessage(
matchResult: ManagedDomainMatchResult | null,
isMatching: boolean,
domain: string,
enabled: boolean,
) {
if (!enabled) {
return '若目标域名已绑定证书,HTTPS 会默认开启;你也可以稍后手动开启。';
}
if (isMatching) {
return '正在按域名自动匹配托管证书...';
}
if (!domain.trim()) {
return '输入完整域名后会自动匹配证书,并优先推荐精确匹配规则。';
}
if (matchResult?.matched && matchResult.candidate) {
return `已匹配${matchResult.candidate.match_type === 'exact' ? '精确' : '通配符'}证书规则 ${matchResult.candidate.domain},默认推荐 ${matchResult.candidate.certificate_name}`;
}
return '未找到匹配证书,可继续手动选择。';
}
function isLocalOriginAddress(address: string) {
const normalized = address.trim().toLowerCase();
return (
normalized === 'localhost' ||
normalized.endsWith('.local') ||
normalized.endsWith('.internal') ||
/^10\./.test(normalized) ||
/^192\.168\./.test(normalized) ||
/^172\.(1[6-9]|2\d|3[0-1])\./.test(normalized) ||
normalized === '::1' ||
normalized.startsWith('fc') ||
normalized.startsWith('fd')
);
}
function ProxyRuleSection({
title,
icon,
action,
children,
}: {
title: string;
icon: React.ReactNode;
action?: React.ReactNode;
children: React.ReactNode;
}) {
return (
<section className="space-y-3">
<div className="flex items-center justify-between gap-3">
<div className="flex items-center gap-3 text-[var(--foreground-primary)]">
<span className="flex h-8 w-8 items-center justify-center rounded-full border border-[var(--border-default)] bg-[var(--surface-elevated)] text-[var(--foreground-secondary)]">
{icon}
</span>
<h3 className="text-sm font-semibold uppercase tracking-[0.16em]">
{title}
</h3>
</div>
{action}
</div>
<div className={PANEL_CLASS_NAME}>{children}</div>
</section>
);
}
type SearchOption = {
key: string;
label: string;
keywords?: string[];
selected?: boolean;
content?: React.ReactNode;
trailing?: React.ReactNode;
};
function SearchableSelectInput({
label,
hint,
error,
showFieldMeta = true,
value,
placeholder,
emptyText,
options,
onValueChange,
onSelect,
}: {
label: string;
hint?: string;
error?: string;
showFieldMeta?: boolean;
value: string;
placeholder: string;
emptyText: string;
options: SearchOption[];
onValueChange: (value: string) => void;
onSelect: (option: SearchOption) => void;
}) {
const [isOpen, setIsOpen] = useState(false);
const [highlightedIndex, setHighlightedIndex] = useState(0);
const containerRef = useRef<HTMLDivElement | null>(null);
const filteredOptions = useMemo(() => {
const keyword = value.trim().toLowerCase();
if (!keyword) {
return options;
}
return options.filter((option) => {
const haystacks = [option.label, ...(option.keywords ?? [])];
return haystacks.some((item) => item.toLowerCase().includes(keyword));
});
}, [options, value]);
const highlightedOption =
filteredOptions[
Math.min(highlightedIndex, Math.max(filteredOptions.length - 1, 0))
] ?? null;
useEffect(() => {
setHighlightedIndex(0);
}, [value]);
useEffect(() => {
if (highlightedIndex > filteredOptions.length - 1) {
setHighlightedIndex(0);
}
}, [filteredOptions.length, highlightedIndex]);
useEffect(() => {
if (!isOpen) {
return;
}
const handlePointerDown = (event: MouseEvent) => {
if (!containerRef.current?.contains(event.target as Node)) {
setIsOpen(false);
}
};
document.addEventListener('mousedown', handlePointerDown);
return () => {
document.removeEventListener('mousedown', handlePointerDown);
};
}, [isOpen]);
const content = (
<div ref={containerRef} className="relative">
<div className="relative">
<SearchIcon className="pointer-events-none absolute left-3 top-1/2 z-10 h-4 w-4 -translate-y-1/2 text-[var(--foreground-muted)]" />
<input
value={value}
placeholder={placeholder}
className={cn(
'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 placeholder:text-[var(--foreground-muted)] focus:border-[var(--border-strong)] focus:ring-2 focus:ring-[var(--accent-soft)]',
INPUT_CLASS_NAME,
'pl-9 pr-10',
)}
onFocus={() => setIsOpen(true)}
onChange={(event) => {
onValueChange(event.target.value);
setIsOpen(true);
}}
onKeyDown={(event) => {
if (event.key === 'ArrowDown' && filteredOptions.length > 0) {
event.preventDefault();
setIsOpen(true);
setHighlightedIndex((current) =>
current >= filteredOptions.length - 1 ? 0 : current + 1,
);
return;
}
if (event.key === 'ArrowUp' && filteredOptions.length > 0) {
event.preventDefault();
setIsOpen(true);
setHighlightedIndex((current) =>
current <= 0 ? filteredOptions.length - 1 : current - 1,
);
return;
}
if (event.key === 'Enter' && highlightedOption) {
event.preventDefault();
onSelect(highlightedOption);
setIsOpen(false);
setHighlightedIndex(0);
return;
}
if (event.key === 'Escape') {
setIsOpen(false);
}
}}
/>
<button
type="button"
onClick={() => setIsOpen((current) => !current)}
aria-label="切换候选列表"
className="absolute right-1.5 top-1/2 flex h-7 w-7 -translate-y-1/2 items-center justify-center rounded-lg text-[var(--foreground-muted)] transition hover:bg-[var(--control-background-hover)]"
>
<ChevronIcon
expanded={isOpen}
className="h-4 w-4 text-[var(--foreground-muted)]"
/>
</button>
</div>
{isOpen ? (
<div className="absolute inset-x-0 top-[calc(100%+8px)] z-20 rounded-2xl border border-[var(--border-default)] bg-[var(--surface-panel)] p-3 shadow-[var(--shadow-soft)]">
<div className="mb-2 flex items-center justify-between gap-3 px-1 text-xs text-[var(--foreground-secondary)]">
<span>
{highlightedOption
? `按 Enter 选择 ${highlightedOption.label}`
: '继续输入以匹配候选项'}
</span>
<span>{`${filteredOptions.length} 个结果`}</span>
</div>
<div className="max-h-56 space-y-1 overflow-y-auto">
{filteredOptions.length > 0 ? (
filteredOptions.map((option, index) => (
<button
key={option.key}
type="button"
onMouseDown={(event) => event.preventDefault()}
onClick={() => {
onSelect(option);
setIsOpen(false);
setHighlightedIndex(0);
}}
className={cn(
'flex w-full items-center justify-between rounded-xl px-3 py-2 text-left text-sm transition hover:bg-[var(--control-background-hover)]',
option.selected
? 'bg-[var(--accent-soft)] text-[var(--foreground-primary)]'
: index === highlightedIndex
? 'bg-[var(--control-background)] text-[var(--foreground-primary)]'
: 'text-[var(--foreground-secondary)]',
)}
>
<span>{option.content ?? option.label}</span>
<div className="flex items-center gap-2">
{index === highlightedIndex ? (
<span className="rounded-full bg-[var(--surface-elevated)] px-2 py-0.5 text-[10px] font-semibold uppercase tracking-[0.12em] text-[var(--foreground-muted)]">
Enter
</span>
) : null}
{option.trailing}
</div>
</button>
))
) : (
<p className="rounded-xl border border-dashed border-[var(--border-default)] px-3 py-4 text-sm text-[var(--foreground-secondary)]">
{emptyText}
</p>
)}
</div>
</div>
) : null}
</div>
);
if (!showFieldMeta) {
return content;
}
return (
<ResourceField label={label} hint={hint} error={error}>
{content}
</ResourceField>
);
}
function SearchableManagedDomainField({
value,
domains,
error,
onSelect,
}: {
value: string;
domains: ManagedDomainItem[];
error?: string;
onSelect: (value: string) => void;
}) {
const [inputValue, setInputValue] = useState('');
useEffect(() => {
if (value) {
setInputValue(value);
return;
}
setInputValue('');
}, [value]);
return (
<SearchableSelectInput
label="Select Target Domain"
hint="直接输入域名关键字进行本地模糊匹配,按 Enter 选择当前匹配项。"
error={error}
value={inputValue}
placeholder="输入并搜索目标域名"
emptyText="未发现匹配域名"
options={domains.map((domain) => ({
key: String(domain.id),
label: domain.domain,
keywords: [domain.domain],
selected: value === domain.domain,
trailing: domain.cert_id ? (
<span className="rounded-full bg-[var(--surface-elevated)] px-2 py-0.5 text-[10px] font-semibold uppercase tracking-[0.12em] text-[var(--foreground-muted)]">
TLS
</span>
) : null,
}))}
onValueChange={setInputValue}
onSelect={(option) => {
onSelect(option.label);
setInputValue(option.label);
}}
/>
);
}
function SearchableOriginAddressField({
value,
origins,
onValueChange,
}: {
value: string;
origins: OriginItem[];
onValueChange: (value: string) => void;
}) {
return (
<SearchableSelectInput
label="源站地址"
showFieldMeta={false}
value={value}
placeholder="192.168.1.45"
emptyText="未发现匹配资产,请手动输入"
options={origins.map((origin) => ({
key: String(origin.id),
label: origin.address,
keywords: [origin.address, origin.name],
selected: value.trim().toLowerCase() === origin.address.toLowerCase(),
content: (
<span className="text-[var(--foreground-primary)]">
{origin.address}
{origin.name ? (
<span className="ml-1 text-[var(--foreground-muted)]">
({origin.name})
</span>
) : null}
</span>
),
trailing: isLocalOriginAddress(origin.address) ? (
<span className="rounded-md bg-[var(--accent-soft)] px-2 py-0.5 text-[10px] font-semibold uppercase tracking-[0.12em] text-[var(--foreground-secondary)]">
Local
</span>
) : null,
}))}
onValueChange={onValueChange}
onSelect={(option) => onValueChange(option.label)}
/>
);
}
function HeroTextInput(
props: React.ComponentProps<typeof Input> & { className?: string },
) {
const { className, ...restProps } = props;
return (
<Input
className={cn(INPUT_CLASS_NAME, className)}
{...restProps}
/>
);
}
function HeroTextArea(
props: React.ComponentProps<typeof Textarea> & { className?: string },
) {
const { className, ...restProps } = props;
return (
<Textarea
className={className}
{...restProps}
/>
);
}
function getSelectedKeys(value: string) {
return value ? new Set([value]) : new Set<string>();
}
function HeroSelectField({
ariaLabel,
value,
disabled,
onChange,
children,
}: {
ariaLabel: string;
value: string;
disabled?: boolean;
onChange: (value: string) => void;
children: React.ComponentProps<typeof Select>['children'];
}) {
return (
<Select
aria-label={ariaLabel}
variant="bordered"
radius="lg"
disallowEmptySelection
selectedKeys={getSelectedKeys(value)}
isDisabled={disabled}
classNames={HERO_SELECT_CLASS_NAMES}
onSelectionChange={(keys) => {
if (keys === 'all') {
return;
}
const [nextValue] = Array.from(keys);
if (typeof nextValue === 'string') {
onChange(nextValue);
}
}}
>
{children}
</Select>
);
}
function HeroSwitchField({
id,
label,
description,
checked,
disabled,
onChange,
}: {
id: string;
label: string;
description?: string;
checked: boolean;
disabled?: boolean;
onChange: (checked: boolean) => void;
}) {
return (
<div className="flex items-start justify-between gap-4 rounded-2xl border border-[var(--border-default)] bg-[color:color-mix(in_srgb,var(--surface-elevated)_82%,white_18%)] px-4 py-4">
<div className="space-y-1">
<Label htmlFor={id}>{label}</Label>
{description ? (
<p className="text-sm leading-5 text-[var(--foreground-secondary)]">
{description}
</p>
) : null}
</div>
<Switch
id={id}
checked={checked}
disabled={disabled}
aria-label={label}
onCheckedChange={onChange}
/>
</div>
);
}
export function ProxyRoutesPage() {
const queryClient = useQueryClient();
const [feedback, setFeedback] = useState<FeedbackState | null>(null);
const [editingRouteId, setEditingRouteId] = useState<number | null>(null);
const [isEditorOpen, setIsEditorOpen] = useState(false);
const [matchResult, setMatchResult] =
useState<ManagedDomainMatchResult | null>(null);
const [isMatching, setIsMatching] = useState(false);
const [isAdvancedOpen, setIsAdvancedOpen] = useState(true);
const form = useForm<ProxyRouteFormValues>({
resolver: zodResolver(proxyRouteSchema),
defaultValues,
});
const {
fields: originFields,
append: appendOrigin,
remove: removeOrigin,
replace: replaceOrigins,
} = useFieldArray({
control: form.control,
name: 'origin_rows',
});
const {
fields: headerFields,
append: appendHeader,
remove: removeHeader,
replace: replaceHeaders,
} = useFieldArray({
control: form.control,
name: 'custom_headers',
});
const watchedManagedDomain = useWatch({
control: form.control,
name: 'managed_domain_id',
});
const watchedSubdomainLabel = useWatch({
control: form.control,
name: 'subdomain_label',
});
const watchedOriginRows = useWatch({
control: form.control,
name: 'origin_rows',
});
const watchedOriginURI = useWatch({
control: form.control,
name: 'origin_uri',
});
const watchedEnabled = useWatch({ control: form.control, name: 'enabled' });
const watchedEnableHttps = useWatch({
control: form.control,
name: 'enable_https',
});
const watchedRedirectHttp = useWatch({
control: form.control,
name: 'redirect_http',
});
const watchedCacheEnabled = useWatch({
control: form.control,
name: 'cache_enabled',
});
const watchedCachePolicy = useWatch({
control: form.control,
name: 'cache_policy',
});
const watchedCertId = useWatch({ control: form.control, name: 'cert_id' });
const routesQuery = useQuery({
queryKey: routesQueryKey,
queryFn: getProxyRoutes,
});
const certificatesQuery = useQuery({
queryKey: certificatesQueryKey,
queryFn: getTlsCertificates,
});
const managedDomainsQuery = useQuery({
queryKey: managedDomainsQueryKey,
queryFn: getManagedDomains,
});
const originsQuery = useQuery({
queryKey: originsQueryKey,
queryFn: getOrigins,
});
const managedDomains = useMemo(
() => managedDomainsQuery.data ?? [],
[managedDomainsQuery.data],
);
const origins = useMemo(() => originsQuery.data ?? [], [originsQuery.data]);
const certificates = useMemo(
() => certificatesQuery.data ?? [],
[certificatesQuery.data],
);
const selectedManagedDomain = useMemo(
() =>
managedDomains.find((item) => item.domain === watchedManagedDomain) ??
null,
[managedDomains, watchedManagedDomain],
);
const selectedManagedDomainValue =
selectedManagedDomain?.domain ?? watchedManagedDomain;
const isWildcardSelection = isWildcardManagedDomain(
selectedManagedDomainValue,
);
const effectiveDomain = buildRouteDomain(
selectedManagedDomainValue,
watchedSubdomainLabel,
);
const primaryOriginRow =
watchedOriginRows?.[0] ?? defaultValues.origin_rows[0];
const saveMutation = useMutation({
mutationFn: async (values: ProxyRouteFormValues) => {
const payload = toPayload(values, origins);
return editingRouteId
? updateProxyRoute(editingRouteId, payload)
: createProxyRoute(payload);
},
onSuccess: async () => {
setFeedback({
tone: 'success',
message: editingRouteId ? '规则已更新。' : '规则已创建。',
});
setEditingRouteId(null);
setIsEditorOpen(false);
setMatchResult(null);
setIsAdvancedOpen(false);
form.reset(defaultValues);
await Promise.all([
queryClient.invalidateQueries({ queryKey: routesQueryKey }),
queryClient.invalidateQueries({ queryKey: managedDomainsQueryKey }),
queryClient.invalidateQueries({ queryKey: originsQueryKey }),
]);
},
onError: (error) => {
setFeedback({ tone: 'danger', message: getErrorMessage(error) });
},
});
const deleteMutation = useMutation({
mutationFn: deleteProxyRoute,
onSuccess: async () => {
setFeedback({ tone: 'success', message: '规则已删除。' });
await queryClient.invalidateQueries({ queryKey: routesQueryKey });
},
onError: (error) => {
setFeedback({ tone: 'danger', message: getErrorMessage(error) });
},
});
const publishMutation = useMutation({
mutationFn: publishConfigVersion,
onSuccess: async (version) => {
setFeedback({
tone: 'success',
message: `发布成功,版本 ${version.version}`,
});
await queryClient.invalidateQueries({ queryKey: versionsQueryKey });
},
onError: (error) => {
setFeedback({ tone: 'danger', message: getErrorMessage(error) });
},
});
useEffect(() => {
if (!watchedEnableHttps) {
setMatchResult(null);
setIsMatching(false);
return;
}
const normalizedDomain = effectiveDomain.trim().toLowerCase();
if (!normalizedDomain) {
setMatchResult(null);
return;
}
let cancelled = false;
const timer = window.setTimeout(async () => {
try {
setIsMatching(true);
const result = await matchManagedDomainCertificate(normalizedDomain);
if (cancelled) {
return;
}
setMatchResult(result);
if (result.candidate?.certificate_id && !form.getValues('cert_id')) {
form.setValue('cert_id', String(result.candidate.certificate_id), {
shouldDirty: true,
shouldValidate: true,
});
}
} catch (error) {
if (!cancelled) {
setMatchResult(null);
setFeedback({ tone: 'danger', message: getErrorMessage(error) });
}
} finally {
if (!cancelled) {
setIsMatching(false);
}
}
}, 400);
return () => {
cancelled = true;
window.clearTimeout(timer);
};
}, [effectiveDomain, form, watchedEnableHttps]);
const handlePublish = async () => {
setFeedback(null);
try {
const diff = await getConfigVersionDiff();
if (!hasConfigChanges(diff)) {
setFeedback({
tone: 'info',
message: '当前规则没有变更,已阻止重复发布。',
});
return;
}
publishMutation.mutate();
} catch (error) {
setFeedback({ tone: 'danger', message: getErrorMessage(error) });
}
};
const handleReset = () => {
setFeedback(null);
setEditingRouteId(null);
setIsEditorOpen(false);
setMatchResult(null);
setIsAdvancedOpen(false);
form.reset(defaultValues);
};
const handleCreate = () => {
setFeedback(null);
setEditingRouteId(null);
setMatchResult(null);
setIsAdvancedOpen(false);
form.reset(defaultValues);
setIsEditorOpen(true);
};
const handleSubmit = form.handleSubmit((values) => {
setFeedback(null);
saveMutation.mutate(values);
});
const handleEdit = (route: ProxyRouteItem) => {
setFeedback(null);
setEditingRouteId(route.id);
setMatchResult(null);
try {
form.reset(toFormValues(route, managedDomains));
setIsAdvancedOpen(Boolean(route.origin_host || route.remark));
setIsEditorOpen(true);
} catch (error) {
setFeedback({ tone: 'danger', message: getErrorMessage(error) });
}
};
const handleDelete = (route: ProxyRouteItem) => {
if (!window.confirm(`确认删除规则 ${route.domain} 吗?`)) {
return;
}
setFeedback(null);
deleteMutation.mutate(route.id);
};
const handleRemoveHeader = (index: number) => {
if (headerFields.length === 1) {
replaceHeaders([{ key: '', value: '' }]);
return;
}
removeHeader(index);
};
const handleManagedDomainSelect = (domainValue: string) => {
const domain = managedDomains.find((item) => item.domain === domainValue);
form.setValue('managed_domain_id', domainValue, {
shouldDirty: true,
shouldValidate: true,
});
if (!isWildcardManagedDomain(domainValue)) {
form.setValue('subdomain_label', '', {
shouldDirty: true,
shouldValidate: true,
});
}
const currentCertId = form.getValues('cert_id');
const autoCertId = domain?.cert_id ? String(domain.cert_id) : '';
if (autoCertId) {
form.setValue('enable_https', true, {
shouldDirty: true,
shouldValidate: true,
});
if (!currentCertId) {
form.setValue('cert_id', autoCertId, {
shouldDirty: true,
shouldValidate: true,
});
}
}
};
const routes = routesQuery.data || [];
return (
<>
<div className="space-y-6">
<PageHeader
title="反代规则"
description="维护域名到源站的映射、HTTPS 证书绑定与自定义请求头,并可直接触发配置发布。"
action={
<>
<PrimaryButton
type="button"
onClick={() => void handlePublish()}
disabled={publishMutation.isPending}
>
{publishMutation.isPending ? '发布中...' : '发布当前规则'}
</PrimaryButton>
<SecondaryButton type="button" onClick={handleCreate}>
新增规则
</SecondaryButton>
</>
}
/>
{feedback ? (
<InlineMessage tone={feedback.tone} message={feedback.message} />
) : null}
<AppCard title="规则列表">
{routesQuery.isLoading ? (
<LoadingState />
) : routesQuery.isError ? (
<ErrorState
title="规则列表加载失败"
description={getErrorMessage(routesQuery.error)}
/>
) : routes.length === 0 ? (
<EmptyState
title="暂无反代规则"
description="请先创建至少一条规则,然后再进行发布。"
/>
) : (
<div className="overflow-x-auto">
<table className="min-w-full divide-y divide-[var(--border-default)] text-left text-sm">
<thead>
<tr className="text-[var(--foreground-secondary)]">
<th className="px-3 py-3 font-medium">域名</th>
<th className="px-3 py-3 font-medium">源站地址</th>
<th className="px-3 py-3 font-medium">HTTPS</th>
<th className="px-3 py-3 font-medium">缓存</th>
<th className="px-3 py-3 font-medium">请求头</th>
<th className="px-3 py-3 font-medium">状态</th>
<th className="px-3 py-3 font-medium">备注</th>
<th className="px-3 py-3 font-medium">更新时间</th>
<th className="px-3 py-3 font-medium">操作</th>
</tr>
</thead>
<tbody className="divide-y divide-[var(--border-default)]">
{routes.map((route) => {
const headers = parseCustomHeaders(route.custom_headers);
const cacheRules = parseCacheRules(route.cache_rules);
const upstreams = parseUpstreams(route.upstreams);
return (
<tr key={route.id} className="align-top">
<td className="px-3 py-4 font-medium text-[var(--foreground-primary)]">
{route.domain}
</td>
<td className="px-3 py-4 text-[var(--foreground-secondary)]">
<div className="space-y-1">
<p>{route.origin_url}</p>
<p className="text-xs text-[var(--foreground-muted)]">
回源主机名: {route.origin_host || '$host'}
</p>
<p className="text-xs text-[var(--foreground-muted)]">
上游数量: {Math.max(upstreams.length, 1)}
</p>
</div>
</td>
<td className="px-3 py-4">
{route.enable_https ? (
<div className="space-y-2">
<StatusBadge
label={
route.redirect_http
? 'HTTPS + 重定向'
: 'HTTPS'
}
variant="info"
/>
</div>
) : (
<StatusBadge label="HTTP" variant="warning" />
)}
</td>
<td className="px-3 py-4">
{route.cache_enabled ? (
<div className="space-y-2">
<StatusBadge
label={buildCachePolicyLabel(
route.cache_policy,
)}
variant="success"
/>
<p className="text-xs text-[var(--foreground-muted)]">
{cacheRules.length > 0
? `${cacheRules.length} 条规则`
: '按 URL 粒度缓存'}
</p>
</div>
) : (
<StatusBadge label="关闭" variant="warning" />
)}
</td>
<td className="px-3 py-4">
<StatusBadge
label={
headers.length > 0 ? `${headers.length} 条` : '无'
}
variant={headers.length > 0 ? 'success' : 'warning'}
/>
</td>
<td className="px-3 py-4">
<StatusBadge
label={route.enabled ? '启用' : '停用'}
variant={route.enabled ? 'success' : 'warning'}
/>
</td>
<td className="px-3 py-4 text-[var(--foreground-secondary)]">
{route.remark || '—'}
</td>
<td className="px-3 py-4 text-[var(--foreground-secondary)]">
{formatDateTime(route.updated_at)}
</td>
<td className="px-3 py-4">
<div className="flex flex-wrap gap-2">
<SecondaryButton
type="button"
onClick={() => handleEdit(route)}
className="px-3 py-2 text-xs"
>
编辑
</SecondaryButton>
<DangerButton
type="button"
onClick={() => handleDelete(route)}
disabled={deleteMutation.isPending}
className="px-3 py-2 text-xs"
>
删除
</DangerButton>
</div>
</td>
</tr>
);
})}
</tbody>
</table>
</div>
)}
</AppCard>
</div>
<AppModal
isOpen={isEditorOpen}
onClose={handleReset}
title={editingRouteId ? '编辑规则' : '新增规则'}
description="新增或修改反代规则后,可直接回到列表页继续发布。"
size="xl"
footer={
<div className="flex flex-wrap justify-end gap-3">
<SecondaryButton
type="button"
onClick={handleReset}
disabled={saveMutation.isPending}
>
取消
</SecondaryButton>
<PrimaryButton
type="submit"
form="proxy-route-editor-form"
disabled={saveMutation.isPending}
>
{saveMutation.isPending
? '保存中...'
: editingRouteId
? '保存修改'
: '新增规则'}
</PrimaryButton>
</div>
}
>
<form
id="proxy-route-editor-form"
className="space-y-6"
onSubmit={handleSubmit}
>
<HeroSwitchField
id="proxy-route-enabled"
label="启用规则"
description="关闭后该规则不会参与配置渲染与发布。"
checked={watchedEnabled}
onChange={(checked) =>
form.setValue('enabled', checked, { shouldDirty: true })
}
/>
<ProxyRuleSection
title="Domain"
icon={<GlobeIcon className="h-4 w-4" />}
>
<div className="space-y-4">
<div
className={cn(
'grid gap-4',
selectedManagedDomain && isWildcardSelection
? 'md:grid-cols-2'
: 'md:grid-cols-1',
)}
>
<SearchableManagedDomainField
value={watchedManagedDomain}
domains={managedDomains}
error={form.formState.errors.managed_domain_id?.message}
onSelect={handleManagedDomainSelect}
/>
{selectedManagedDomain && isWildcardSelection ? (
<ResourceField
label="Subdomain Prefix"
hint="当前选择的是通配符域名,仅输入最前面的前缀即可。"
error={form.formState.errors.subdomain_label?.message}
>
<HeroTextInput
aria-label="Subdomain Prefix"
placeholder="e.g. ai"
{...form.register('subdomain_label')}
/>
</ResourceField>
) : null}
</div>
</div>
</ProxyRuleSection>
<ProxyRuleSection
title="Origin"
icon={<ServerIcon className="h-4 w-4" />}
action={
<button
type="button"
onClick={() =>
appendOrigin({
scheme: primaryOriginRow.scheme,
address: '',
port: getDefaultPortForScheme(primaryOriginRow.scheme),
})
}
className="inline-flex items-center gap-1 text-sm font-medium text-[var(--brand-primary)] transition hover:opacity-80"
>
<PlusIcon className="h-4 w-4" />
Add Row
</button>
}
>
<div className="space-y-3">
{originFields.map((field, index) => {
const currentRow =
watchedOriginRows?.[index] ?? defaultValues.origin_rows[0];
return (
<div key={field.id} className="space-y-2">
<div className="grid gap-3 md:grid-cols-[120px_minmax(0,1fr)_96px_44px]">
<div>
<span className="sr-only">{`协议 ${index + 1}`}</span>
<HeroSelectField
ariaLabel={`协议 ${index + 1}`}
value={currentRow.scheme}
onChange={(nextValue) => {
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,
},
);
}
}}
>
<SelectItem key="https">HTTPS</SelectItem>
<SelectItem key="http">HTTP</SelectItem>
</HeroSelectField>
</div>
<SearchableOriginAddressField
value={currentRow.address}
origins={origins}
onValueChange={(nextValue) =>
form.setValue(`origin_rows.${index}.address`, nextValue, {
shouldDirty: true,
shouldValidate: true,
})
}
/>
<div>
<span className="sr-only">{`端口 ${index + 1}`}</span>
<HeroTextInput
aria-label={`端口 ${index + 1}`}
placeholder="443"
{...form.register(`origin_rows.${index}.port`)}
/>
</div>
<button
type="button"
onClick={() => {
if (originFields.length === 1) {
replaceOrigins([defaultValues.origin_rows[0]]);
return;
}
removeOrigin(index);
}}
className="flex h-10 w-10 items-center justify-center rounded-xl border border-[var(--border-default)] bg-[var(--surface-panel)] text-[var(--foreground-muted)] transition hover:border-[var(--status-danger-border)] hover:text-[var(--status-danger-foreground)]"
aria-label={`删除源站 ${index + 1}`}
>
<TrashIcon className="h-4 w-4" />
</button>
</div>
{(form.formState.errors.origin_rows?.[index]?.address
?.message ||
form.formState.errors.origin_rows?.[index]?.port
?.message) && (
<p className="text-xs text-[var(--status-danger-foreground)]">
{form.formState.errors.origin_rows?.[index]?.address
?.message ||
form.formState.errors.origin_rows?.[index]?.port
?.message}
</p>
)}
</div>
);
})}
<ResourceField
label="Origin Path / Query"
error={form.formState.errors.origin_uri?.message}
>
<HeroTextInput
aria-label="Origin Path / Query"
placeholder="/api"
{...form.register('origin_uri')}
/>
</ResourceField>
</div>
</ProxyRuleSection>
<ProxyRuleSection
title="Protocol"
icon={<ShieldIcon className="h-4 w-4" />}
>
<div className="space-y-4">
<div className="rounded-2xl border border-[var(--border-default)] bg-[color:color-mix(in_srgb,var(--surface-elevated)_82%,white_18%)] p-4">
<div className="flex items-start justify-between gap-4">
<div className="space-y-1">
<p className="text-sm font-semibold text-[var(--foreground-primary)]">
HTTPS Access
</p>
<p className="text-sm leading-5 text-[var(--foreground-secondary)]">
Enforce secure TLS connections for this rule.
</p>
</div>
<Switch
id="proxy-route-enable-https"
checked={watchedEnableHttps}
aria-label="HTTPS Access"
onCheckedChange={(checked) => {
form.setValue('enable_https', checked, {
shouldDirty: true,
shouldValidate: true,
});
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,
});
}
}}
/>
</div>
{watchedEnableHttps ? (
<div className="mt-5 space-y-4">
<ResourceField
label="Select Certificate"
hint={getMatchMessage(
matchResult,
isMatching,
effectiveDomain,
watchedEnableHttps,
)}
error={form.formState.errors.cert_id?.message}
>
<HeroSelectField
ariaLabel="Select Certificate"
value={watchedCertId}
disabled={certificatesQuery.isLoading}
onChange={(nextValue) =>
form.setValue('cert_id', nextValue, {
shouldDirty: true,
shouldValidate: true,
})
}
>
{matchResult?.candidate ? (
<SelectSection title="自动匹配">
<SelectItem key={String(matchResult.candidate.certificate_id)}>
{matchResult.candidate.certificate_name}
</SelectItem>
</SelectSection>
) : null}
<SelectSection title="全部证书">
{certificates
.filter(
(certificate) =>
String(certificate.id) !==
String(matchResult?.candidate?.certificate_id ?? ''),
)
.map((certificate) => (
<SelectItem
key={String(certificate.id)}
textValue={buildCertificateLabel(certificate)}
>
{buildCertificateLabel(certificate)}
</SelectItem>
))}
</SelectSection>
</HeroSelectField>
</ResourceField>
<ToggleField
label="HTTP 跳转 HTTPS"
description="开启后会将 HTTP 请求重定向到 HTTPS。"
checked={watchedRedirectHttp}
disabled={!watchedEnableHttps}
onChange={(checked) =>
form.setValue('redirect_http', checked, {
shouldDirty: true,
shouldValidate: true,
})
}
/>
</div>
) : null}
</div>
{matchResult?.matched && matchResult.candidates.length > 1 ? (
<div className="flex flex-wrap gap-2">
{matchResult.candidates.map((candidate) => (
<StatusBadge
key={`${candidate.managed_domain_id}-${candidate.certificate_id}`}
label={`${candidate.domain} → ${candidate.certificate_name}`}
variant={
candidate.match_type === 'exact' ? 'success' : 'info'
}
/>
))}
</div>
) : null}
</div>
</ProxyRuleSection>
<section className="space-y-3">
<button
type="button"
onClick={() => setIsAdvancedOpen((current) => !current)}
className="flex w-full items-center justify-between gap-3 text-left"
>
<div className="flex items-center gap-3 text-[var(--foreground-primary)]">
<span className="flex h-8 w-8 items-center justify-center rounded-full border border-[var(--border-default)] bg-[var(--surface-elevated)] text-[var(--foreground-secondary)]">
<SlidersIcon className="h-4 w-4" />
</span>
<h3 className="text-sm font-semibold uppercase tracking-[0.16em]">
Advanced
</h3>
</div>
<ChevronIcon
expanded={isAdvancedOpen}
className="h-5 w-5 text-[var(--foreground-muted)]"
/>
</button>
{isAdvancedOpen ? (
<div className={cn(PANEL_CLASS_NAME, 'space-y-4')}>
<div className="grid gap-4 lg:grid-cols-1">
<div className="space-y-4">
<ResourceField
label="Origin Host Header"
hint="留空则默认使用访问域名 $host。"
error={form.formState.errors.origin_host?.message}
>
<HeroTextInput
aria-label="Origin Host Header"
placeholder="example.com"
{...form.register('origin_host')}
/>
</ResourceField>
<div className="rounded-2xl border border-[var(--border-default)] bg-[var(--surface-panel)] p-4">
<div className="flex flex-wrap items-center justify-between gap-3">
<div>
<p className="text-sm font-semibold text-[var(--foreground-primary)]">
Custom Request Headers
</p>
<p className="mt-1 text-xs leading-5 text-[var(--foreground-secondary)]">
使用 Key | Value 横向排列,方便快速录入运维透传头。
</p>
</div>
<button
type="button"
onClick={() => appendHeader({ key: '', value: '' })}
className="text-sm font-medium text-[var(--brand-primary)] transition hover:opacity-80"
>
+ Add Header
</button>
</div>
<div className="mt-4 space-y-3">
{headerFields.map((field, index) => (
<div
key={field.id}
className="grid gap-3 md:grid-cols-[1fr_1fr_28px]"
>
<HeroTextInput
aria-label={`Custom Header Key ${index + 1}`}
placeholder="X-Forwarded-For"
{...form.register(`custom_headers.${index}.key`)}
/>
<HeroTextInput
aria-label={`Custom Header Value ${index + 1}`}
placeholder="$remote_addr"
{...form.register(`custom_headers.${index}.value`)}
/>
<button
type="button"
onClick={() => handleRemoveHeader(index)}
className="flex h-10 w-7 items-center justify-center text-lg text-[var(--foreground-muted)] transition hover:text-[var(--status-danger-foreground)]"
aria-label={`删除请求头 ${index + 1}`}
>
×
</button>
{(form.formState.errors.custom_headers?.[index]?.key
?.message ||
form.formState.errors.custom_headers?.[index]
?.value?.message) && (
<p className="md:col-span-3 text-xs text-[var(--status-danger-foreground)]">
{form.formState.errors.custom_headers?.[index]
?.key?.message ||
form.formState.errors.custom_headers?.[index]
?.value?.message}
</p>
)}
</div>
))}
</div>
</div>
</div>
<Separator />
<div className="space-y-4">
<ToggleField
label="Enable Rule Caching"
description="仅对当前规则生效;系统会自动绕过非 GET、Authorization 和常见登录态 Cookie 请求。"
checked={watchedCacheEnabled}
onChange={(checked) => {
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,
});
}
}}
/>
<ResourceField
label="缓存策略"
hint="按 URL 会缓存所有符合安全条件的 URL;其余策略会先匹配规则再决定是否缓存。"
>
<HeroSelectField
ariaLabel="缓存策略"
value={watchedCachePolicy}
disabled={!watchedCacheEnabled}
onChange={(nextValue) =>
form.setValue(
'cache_policy',
nextValue as ProxyRouteFormValues['cache_policy'],
{
shouldDirty: true,
shouldValidate: true,
},
)
}
>
<SelectItem key="url">按 URL 缓存</SelectItem>
<SelectItem key="suffix">按后缀匹配缓存</SelectItem>
<SelectItem key="path_prefix">按路径前缀缓存</SelectItem>
<SelectItem key="path_exact">按精确路径缓存</SelectItem>
</HeroSelectField>
</ResourceField>
<ResourceField
label="缓存规则"
hint={getCacheRulesHint(watchedCachePolicy)}
error={form.formState.errors.cache_rules_text?.message}
>
<HeroTextArea
aria-label="缓存规则"
placeholder={
watchedCachePolicy === 'suffix'
? 'jpg\ncss\njs'
: watchedCachePolicy === 'path_prefix'
? '/assets\n/static/images'
: watchedCachePolicy === 'path_exact'
? '/robots.txt\n/manifest.json'
: '按 URL 缓存无需填写规则'
}
disabled={
!watchedCacheEnabled || watchedCachePolicy === 'url'
}
className="min-h-32"
{...form.register('cache_rules_text')}
/>
</ResourceField>
</div>
</div>
<ResourceField
label="Remarks"
error={form.formState.errors.remark?.message}
>
<HeroTextArea
aria-label="Remarks"
placeholder="Internal notes for this rule..."
{...form.register('remark')}
/>
</ResourceField>
</div>
) : null}
</section>
</form>
</AppModal>
</>
);
}