Files
OpenFlare/openflare_server/web/features/proxy-routes/components/proxy-route-config-page.tsx
T
2026-04-19 23:00:57 +08:00

1256 lines
38 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 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 { Controller, 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 { getManagedDomains } from '@/features/managed-domains/api/managed-domains';
import {
getProxyRoute,
updateProxyRoute,
} from '@/features/proxy-routes/api/proxy-routes';
import {
buildDomainRowsFromRoute,
DomainListInput,
type DomainListRow,
} from '@/features/proxy-routes/components/domain-list-input';
import {
buildPayloadFromRoute,
customHeadersToText,
getErrorMessage,
getWebsiteConfigSection,
linesFromTextarea,
normalizeLimitRate,
parseCustomHeadersText,
parseOriginUrl,
parseOriginUrls,
validateCacheRules,
validateDomains,
validateLimitRate,
validateOriginHost,
websiteConfigSections,
} from '@/features/proxy-routes/helpers';
import type {
ProxyRouteItem,
ProxyRouteMutationPayload,
} from '@/features/proxy-routes/types';
import { getTlsCertificates } from '@/features/tls-certificates/api/tls-certificates';
import type { TlsCertificateItem } from '@/features/tls-certificates/types';
import {
PrimaryButton,
ResourceField,
ResourceInput,
ResourceSelect,
ResourceTextarea,
SecondaryButton,
ToggleField,
} from '@/features/shared/components/resource-primitives';
import { cn } from '@/lib/utils/cn';
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 个字符'),
domain_rows: z
.array(
z.object({
domain: z.string(),
certificateId: z.string(),
}),
)
.min(1),
enabled: z.boolean(),
redirect_http: z.boolean(),
})
.superRefine((value, context) => {
const domains = value.domain_rows
.map((item) => item.domain.trim().toLowerCase())
.filter(Boolean);
const error = validateDomains(domains);
if (error) {
context.addIssue({
code: z.ZodIssueCode.custom,
path: ['domain_rows'],
message: error,
});
}
const selectedCertificateCount = new Set(
value.domain_rows
.map((item) => Number(item.certificateId))
.filter((item) => Number.isFinite(item) && item > 0),
).size;
if (value.redirect_http && selectedCertificateCount === 0) {
context.addIssue({
code: z.ZodIssueCode.custom,
path: ['redirect_http'],
message: '启用 HTTP 跳转前,请先为域名选择证书',
});
}
});
const rateLimitSchema = z
.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 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 CacheValues = z.infer<typeof cacheSchema>;
function normalizeSelectedCertificateIDs(rows: DomainListRow[]) {
return Array.from(
new Set(
rows
.filter((item) => item.domain.trim() !== '')
.map((item) => Number(item.certificateId))
.filter((item) => Number.isFinite(item) && item > 0),
),
);
}
function buildDomainCertificateIDs(rows: DomainListRow[]) {
return rows
.filter((item) => item.domain.trim() !== '')
.map((item) => {
const certificateID = Number(item.certificateId);
return Number.isFinite(certificateID) && certificateID > 0
? certificateID
: 0;
});
}
function buildDomainRows(route: ProxyRouteItem) {
const selectedCertIDs =
route.cert_ids.length > 0
? route.cert_ids
: route.cert_id
? [route.cert_id]
: [];
return buildDomainRowsFromRoute(
route.domains,
route.domain_cert_ids,
selectedCertIDs,
);
}
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,
certificates,
saving,
onSave,
suggestionSources,
}: {
route: ProxyRouteItem;
certificates: TlsCertificateItem[];
saving: boolean;
onSave: SaveHandler;
suggestionSources: string[];
}) {
const form = useForm<DomainSettingsValues>({
resolver: zodResolver(domainSettingsSchema),
defaultValues: {
site_name: route.site_name,
domain_rows: buildDomainRows(route),
enabled: route.enabled,
redirect_http: route.redirect_http,
},
});
useEffect(() => {
form.reset({
site_name: route.site_name,
domain_rows: buildDomainRows(route),
enabled: route.enabled,
redirect_http: route.redirect_http,
});
}, [form, route]);
const selectedCertificateIDs = normalizeSelectedCertificateIDs(
form.watch('domain_rows'),
);
return (
<ConfigSectionShell
title="域名设置"
description="在一个列表里同时维护域名、证书和 HTTPS 跳转。保存时会自动汇总站点证书集合。"
formId="proxy-route-domains-form"
saving={saving}
>
<form
id="proxy-route-domains-form"
className="space-y-5"
onSubmit={form.handleSubmit((values) => {
const domains = values.domain_rows
.map((item) => item.domain.trim().toLowerCase())
.filter(Boolean);
const domainCertIDs = buildDomainCertificateIDs(values.domain_rows);
const certIDs = normalizeSelectedCertificateIDs(values.domain_rows);
onSave(
buildPayloadFromRoute(route, {
site_name: values.site_name.trim(),
domain: domains[0],
domains,
enabled: values.enabled,
enable_https: certIDs.length > 0,
cert_id: certIDs[0] ?? null,
cert_ids: certIDs,
domain_cert_ids: domainCertIDs,
redirect_http: certIDs.length > 0 ? values.redirect_http : false,
}),
{ message: '域名设置已保存。' },
);
})}
>
<ToggleField
label="启用站点"
description="关闭后会保留配置,但不会参与发布。"
checked={form.watch('enabled')}
onChange={(checked) =>
form.setValue('enabled', checked, { shouldDirty: true })
}
/>
<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.domain_rows?.message as string | undefined}
container="div"
>
<Controller
control={form.control}
name="domain_rows"
render={({ field }) => (
<DomainListInput
rows={field.value}
onChange={field.onChange}
onBlur={field.onBlur}
suggestionSources={suggestionSources}
certificates={certificates}
/>
)}
/>
</ResourceField>
<ToggleField
label="HTTP 自动跳转到 HTTPS"
description={
selectedCertificateIDs.length > 0
? '开启后会额外生成 80 端口重定向规则。'
: '至少为一个域名选择证书后才能启用。'
}
checked={form.watch('redirect_http')}
disabled={selectedCertificateIDs.length === 0}
onChange={(checked) =>
form.setValue('redirect_http', checked, { shouldDirty: true })
}
/>
</form>
</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="站点限流,空值或 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="限制当前站点最大并发连接数。"
error={form.formState.errors.limit_conn_per_server?.message}
>
<ResourceInput
placeholder="120"
{...form.register('limit_conn_per_server')}
/>
</ResourceField>
<ResourceField
label="单 IP 限制"
hint="限制单个 IP 的最大并发数。"
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/1m" {...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
aria-label="上游地址"
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 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>
);
}
type PowListValues = {
ips: string;
ip_cidrs: string;
paths: string;
path_regexes: string;
user_agents: string;
};
const defaultPowList: PowListValues = {
ips: '',
ip_cidrs: '',
paths: '',
path_regexes: '',
user_agents: '',
};
const powSchema = z
.object({
pow_enabled: z.boolean(),
difficulty: z.coerce.number().int().min(1).max(16),
algorithm: z.enum(['fast', 'slow']),
session_ttl: z.coerce.number().int().min(60),
challenge_ttl: z.coerce.number().int().min(30),
whitelist: z.object({
ips: z.string(),
ip_cidrs: z.string(),
paths: z.string(),
path_regexes: z.string(),
user_agents: z.string(),
}),
blacklist: z.object({
ips: z.string(),
ip_cidrs: z.string(),
paths: z.string(),
path_regexes: z.string(),
user_agents: z.string(),
}),
})
.superRefine((value, context) => {
if (!value.pow_enabled) return;
const dimensions: { key: string; label: string }[] = [
{ key: 'ips', label: 'IP' },
{ key: 'ip_cidrs', label: 'IP CIDR' },
{ key: 'paths', label: '路径' },
{ key: 'path_regexes', label: '路径正则' },
{ key: 'user_agents', label: 'User-Agent' },
];
for (const dim of dimensions) {
const wl = linesFromTextarea(
(value.whitelist as Record<string, string>)[dim.key] || '',
);
const bl = linesFromTextarea(
(value.blacklist as Record<string, string>)[dim.key] || '',
);
if (wl.length > 0 && bl.length > 0) {
context.addIssue({
code: z.ZodIssueCode.custom,
message: `${dim.label} 不能同时配置白名单和黑名单`,
path: ['blacklist', dim.key],
});
}
}
});
type PowValues = z.infer<typeof powSchema>;
function buildPowListFromConfig(
list:
| { ips?: string[]; ip_cidrs?: string[]; paths?: string[]; path_regexes?: string[]; user_agents?: string[] }
| undefined,
): PowListValues {
return {
ips: (list?.ips ?? []).join('\n'),
ip_cidrs: (list?.ip_cidrs ?? []).join('\n'),
paths: (list?.paths ?? []).join('\n'),
path_regexes: (list?.path_regexes ?? []).join('\n'),
user_agents: (list?.user_agents ?? []).join('\n'),
};
}
function PowSection({
route,
saving,
onSave,
}: {
route: ProxyRouteItem;
saving: boolean;
onSave: SaveHandler;
}) {
const powConfig = route.pow_config;
const form = useForm<PowValues>({
resolver: zodResolver(powSchema),
defaultValues: {
pow_enabled: route.pow_enabled,
difficulty: powConfig?.difficulty ?? 4,
algorithm: powConfig?.algorithm ?? 'fast',
session_ttl: powConfig?.session_ttl ?? 86400,
challenge_ttl: powConfig?.challenge_ttl ?? 300,
whitelist: buildPowListFromConfig(powConfig?.whitelist),
blacklist: buildPowListFromConfig(powConfig?.blacklist),
},
});
useEffect(() => {
form.reset({
pow_enabled: route.pow_enabled,
difficulty: powConfig?.difficulty ?? 4,
algorithm: powConfig?.algorithm ?? 'fast',
session_ttl: powConfig?.session_ttl ?? 86400,
challenge_ttl: powConfig?.challenge_ttl ?? 300,
whitelist: buildPowListFromConfig(powConfig?.whitelist),
blacklist: buildPowListFromConfig(powConfig?.blacklist),
});
}, [form, route, powConfig]);
const watchedEnabled = form.watch('pow_enabled');
const parseList = (text: string): string[] =>
linesFromTextarea(text).filter(Boolean);
return (
<ConfigSectionShell
title="PoW 防护"
description="启用 Proof-of-Work 反爬虫验证。首次访问的浏览器需要完成计算挑战才能继续。"
formId="proxy-route-pow-form"
saving={saving}
>
<form
id="proxy-route-pow-form"
className="space-y-5"
onSubmit={form.handleSubmit((values) => {
const powConfigPayload = JSON.stringify({
difficulty: values.difficulty,
algorithm: values.algorithm,
session_ttl: values.session_ttl,
challenge_ttl: values.challenge_ttl,
whitelist: {
ips: parseList(values.whitelist.ips),
ip_cidrs: parseList(values.whitelist.ip_cidrs),
paths: parseList(values.whitelist.paths),
path_regexes: parseList(values.whitelist.path_regexes),
user_agents: parseList(values.whitelist.user_agents),
},
blacklist: {
ips: parseList(values.blacklist.ips),
ip_cidrs: parseList(values.blacklist.ip_cidrs),
paths: parseList(values.blacklist.paths),
path_regexes: parseList(values.blacklist.path_regexes),
user_agents: parseList(values.blacklist.user_agents),
},
});
onSave(
buildPayloadFromRoute(route, {
pow_enabled: values.pow_enabled,
pow_config: powConfigPayload,
}),
{ message: 'PoW 防护设置已保存。' },
);
})}
>
<ToggleField
label="启用 PoW 防护"
description="对访问此站点的请求进行 Proof-of-Work 验证,阻止自动化爬虫。"
checked={watchedEnabled}
onChange={(checked) =>
form.setValue('pow_enabled', checked, { shouldDirty: true })
}
/>
<ResourceField label="验证算法">
<ResourceSelect
disabled={!watchedEnabled}
{...form.register('algorithm')}
>
<option value="fast">Fast(WebCrypto SHA-256)</option>
<option value="slow">Slow(兼容模式)</option>
</ResourceSelect>
</ResourceField>
<ResourceField
label="难度"
hint="数值越高验证越慢,1-16。推荐 3-5。"
error={form.formState.errors.difficulty?.message}
>
<ResourceInput
type="number"
min={1}
max={16}
disabled={!watchedEnabled}
{...form.register('difficulty')}
/>
</ResourceField>
<ResourceField
label="会话有效期(秒)"
hint="通过验证后 Cookie 的有效期。"
error={form.formState.errors.session_ttl?.message}
>
<ResourceInput
type="number"
min={60}
disabled={!watchedEnabled}
{...form.register('session_ttl')}
/>
</ResourceField>
<ResourceField
label="挑战有效期(秒)"
hint="挑战令牌的有效期。"
error={form.formState.errors.challenge_ttl?.message}
>
<ResourceInput
type="number"
min={30}
disabled={!watchedEnabled}
{...form.register('challenge_ttl')}
/>
</ResourceField>
<div className="grid grid-cols-1 gap-5 md:grid-cols-2">
<fieldset disabled={!watchedEnabled} className="space-y-4">
<legend className="text-sm font-medium text-[var(--foreground-primary)] mb-2">
白名单(匹配的请求跳过 PoW)
</legend>
<ResourceField label="IP" hint="每行一个 IP 地址">
<ResourceTextarea
className="min-h-20"
placeholder="1.2.3.4&#10;5.6.7.8"
{...form.register('whitelist.ips')}
/>
</ResourceField>
<ResourceField label="IP CIDR" hint="每行一个 CIDR 范围">
<ResourceTextarea
className="min-h-20"
placeholder="10.0.0.0/8&#10;192.168.0.0/16"
{...form.register('whitelist.ip_cidrs')}
/>
</ResourceField>
<ResourceField label="路径" hint="每行一个路径通配符">
<ResourceTextarea
className="min-h-20"
placeholder="/.well-known/*&#10;/favicon.ico"
{...form.register('whitelist.paths')}
/>
</ResourceField>
<ResourceField label="路径正则" hint="每行一个正则表达式">
<ResourceTextarea
className="min-h-20"
placeholder="^/api/public/"
{...form.register('whitelist.path_regexes')}
/>
</ResourceField>
<ResourceField label="User-Agent" hint="每行一个关键字">
<ResourceTextarea
className="min-h-20"
placeholder="Googlebot&#10;bingbot"
{...form.register('whitelist.user_agents')}
/>
</ResourceField>
</fieldset>
<fieldset disabled={!watchedEnabled} className="space-y-4">
<legend className="text-sm font-medium text-[var(--foreground-primary)] mb-2">
黑名单(匹配的请求必须 PoW)
</legend>
<ResourceField label="IP" hint="每行一个 IP 地址">
<ResourceTextarea
className="min-h-20"
placeholder="1.2.3.4"
{...form.register('blacklist.ips')}
/>
</ResourceField>
<ResourceField label="IP CIDR" hint="每行一个 CIDR 范围">
<ResourceTextarea
className="min-h-20"
placeholder="10.0.0.0/8"
{...form.register('blacklist.ip_cidrs')}
/>
</ResourceField>
<ResourceField label="路径" hint="每行一个路径通配符">
<ResourceTextarea
className="min-h-20"
placeholder="/admin/*"
{...form.register('blacklist.paths')}
/>
</ResourceField>
<ResourceField label="路径正则" hint="每行一个正则表达式">
<ResourceTextarea
className="min-h-20"
placeholder="^/private/"
{...form.register('blacklist.path_regexes')}
/>
</ResourceField>
<ResourceField label="User-Agent" hint="每行一个关键字">
<ResourceTextarea
className="min-h-20"
placeholder="bot&#10;crawler"
{...form.register('blacklist.user_agents')}
/>
</ResourceField>
</fieldset>
</div>
{form.formState.errors.blacklist && (
<p className="text-sm text-[var(--color-danger)]">
{Object.values(form.formState.errors.blacklist)
.flatMap((e) =>
e && typeof e === 'object' && 'message' in e
? [e.message as string]
: [],
)
.join('; ')}
</p>
)}
</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 managedDomainsQuery = useQuery({
queryKey: ['managed-domains'],
queryFn: getManagedDomains,
});
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],
);
const domainSuggestionSources = useMemo(
() => [
...(route?.domains ?? []),
...(managedDomainsQuery.data?.map((item) => item.domain) ?? []),
],
[managedDomainsQuery.data, route?.domains],
);
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 无法匹配到记录。"
/>
);
}
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="配置分区">
<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>
</aside>
<div className="min-w-0 space-y-6">
{currentSection === 'domains' ? (
<DomainSettingsSection
route={route}
certificates={certificates}
saving={saveMutation.isPending}
suggestionSources={domainSuggestionSources}
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 === 'cache' ? (
<CacheSection
route={route}
saving={saveMutation.isPending}
onSave={(payload, context) =>
saveMutation.mutate({ payload, context })
}
/>
) : null}
{currentSection === 'pow' ? (
<PowSection
route={route}
saving={saveMutation.isPending}
onSave={(payload, context) =>
saveMutation.mutate({ payload, context })
}
/>
) : null}
</div>
</div>
</div>
);
}