mirror of
https://github.com/Rain-kl/OpenFlare.git
synced 2026-10-13 18:36:37 +08:00
feat(frontend): 控制台 next-intl 中英双语
接入无 URL 前缀的 zh-CN/en,顶栏与外观设置可切换语言;选择写入 cookie 后刷新生效。
This commit is contained in:
@@ -7,42 +7,54 @@ import type {
|
||||
} from '@/lib/services/openflare';
|
||||
import { ZoneService } from '@/lib/services/openflare';
|
||||
|
||||
export const proxyRouteConfigSections = [
|
||||
{
|
||||
key: 'domains' as const,
|
||||
label: '域名设置',
|
||||
description: '维护站点标识,并从 Zone 中选择绑定域名。',
|
||||
},
|
||||
{
|
||||
key: 'limits' as const,
|
||||
label: '流量限制',
|
||||
description: '设置连接数和限速(可继承全局默认)。',
|
||||
},
|
||||
{
|
||||
key: 'proxy' as const,
|
||||
label: '反向代理',
|
||||
description: '配置主回源和上游地址。',
|
||||
},
|
||||
{
|
||||
key: 'cache' as const,
|
||||
label: '缓存',
|
||||
description: '配置站点缓存策略。',
|
||||
},
|
||||
{
|
||||
key: 'waf' as const,
|
||||
label: 'WAF',
|
||||
description: '绑定 WAF 规则组,并查看当前站点生效策略。',
|
||||
},
|
||||
{
|
||||
key: 'auth' as const,
|
||||
label: '认证配置',
|
||||
description: '配置基础鉴权访问,需要输入账号密码才能访问网站。',
|
||||
},
|
||||
] satisfies ReadonlyArray<{
|
||||
key: ProxyRouteConfigSection;
|
||||
label: string;
|
||||
description: string;
|
||||
}>;
|
||||
export type TranslateFn = (
|
||||
key: string,
|
||||
values?: Record<string, string | number>,
|
||||
) => string;
|
||||
|
||||
const proxyRouteConfigSectionKeys = [
|
||||
'domains',
|
||||
'limits',
|
||||
'proxy',
|
||||
'cache',
|
||||
'waf',
|
||||
'auth',
|
||||
] as const satisfies ReadonlyArray<ProxyRouteConfigSection>;
|
||||
|
||||
export function getProxyRouteConfigSections(t: TranslateFn) {
|
||||
return [
|
||||
{
|
||||
key: 'domains' as const,
|
||||
label: t('sections.domains'),
|
||||
description: t('sections.domainsDesc'),
|
||||
},
|
||||
{
|
||||
key: 'limits' as const,
|
||||
label: t('sections.limits'),
|
||||
description: t('sections.limitsDesc'),
|
||||
},
|
||||
{
|
||||
key: 'proxy' as const,
|
||||
label: t('sections.proxy'),
|
||||
description: t('sections.proxyDesc'),
|
||||
},
|
||||
{
|
||||
key: 'cache' as const,
|
||||
label: t('sections.cache'),
|
||||
description: t('sections.cacheDesc'),
|
||||
},
|
||||
{
|
||||
key: 'waf' as const,
|
||||
label: t('sections.waf'),
|
||||
description: t('sections.wafDesc'),
|
||||
},
|
||||
{
|
||||
key: 'auth' as const,
|
||||
label: t('sections.auth'),
|
||||
description: t('sections.authDesc'),
|
||||
},
|
||||
];
|
||||
}
|
||||
|
||||
const domainPattern =
|
||||
/^(?=.{1,253}$)(?:[a-z0-9](?:[a-z0-9-]{0,61}[a-z0-9])?\.)+[a-z]{2,63}$/i;
|
||||
@@ -50,18 +62,21 @@ const domainPattern =
|
||||
export function getProxyRouteConfigSection(
|
||||
value: string | null | undefined,
|
||||
): ProxyRouteConfigSection {
|
||||
return proxyRouteConfigSections.some((section) => section.key === value)
|
||||
return proxyRouteConfigSectionKeys.some((key) => key === value)
|
||||
? (value as ProxyRouteConfigSection)
|
||||
: 'domains';
|
||||
}
|
||||
|
||||
export function validateDomain(domain: string): string | null {
|
||||
export function validateDomain(
|
||||
domain: string,
|
||||
t: TranslateFn,
|
||||
): string | null {
|
||||
const normalized = domain.trim().toLowerCase();
|
||||
if (!normalized) {
|
||||
return '请输入域名';
|
||||
return t('validation.enterDomain');
|
||||
}
|
||||
if (!domainPattern.test(normalized)) {
|
||||
return '域名格式不合法';
|
||||
return t('validation.invalidDomain');
|
||||
}
|
||||
return null;
|
||||
}
|
||||
@@ -88,23 +103,29 @@ export function getRoutePrimaryDomain(route: ProxyRouteItem): string {
|
||||
return getRouteDomainNames(route)[0] ?? '';
|
||||
}
|
||||
|
||||
export function getRouteDomainsLabel(route: ProxyRouteItem): string {
|
||||
export function getRouteDomainsLabel(
|
||||
route: ProxyRouteItem,
|
||||
t: TranslateFn,
|
||||
): string {
|
||||
const names = getRouteDomainNames(route);
|
||||
return names.length > 0 ? names.join(', ') : '未绑定域名';
|
||||
return names.length > 0 ? names.join(', ') : t('unboundDomain');
|
||||
}
|
||||
|
||||
/** Upstream address labels for display (supports multi-upstream). */
|
||||
export function getUpstreamLabels(route: ProxyRouteItem): string[] {
|
||||
export function getUpstreamLabels(
|
||||
route: ProxyRouteItem,
|
||||
t: TranslateFn,
|
||||
): string[] {
|
||||
if (route.upstream_type === 'pages') {
|
||||
return [
|
||||
route.pages_project_id
|
||||
? `Pages 项目 #${route.pages_project_id}`
|
||||
: 'Pages 项目未绑定',
|
||||
? t('pagesProjectId', { id: route.pages_project_id })
|
||||
: t('pagesUnbound'),
|
||||
];
|
||||
}
|
||||
if (route.upstream_type === 'tunnel') {
|
||||
const protocol = route.tunnel_target_protocol || 'http';
|
||||
const target = route.tunnel_target_addr || '未配置目标';
|
||||
const target = route.tunnel_target_addr || t('tunnelTargetUnset');
|
||||
return [`Tunnel → ${protocol}://${target}`];
|
||||
}
|
||||
const list = (route.upstream_list ?? []).filter(Boolean);
|
||||
@@ -114,10 +135,13 @@ export function getUpstreamLabels(route: ProxyRouteItem): string[] {
|
||||
return route.origin_url ? [route.origin_url] : [];
|
||||
}
|
||||
|
||||
export function getUpstreamSummary(route: ProxyRouteItem): string {
|
||||
const labels = getUpstreamLabels(route);
|
||||
export function getUpstreamSummary(
|
||||
route: ProxyRouteItem,
|
||||
t: TranslateFn,
|
||||
): string {
|
||||
const labels = getUpstreamLabels(route, t);
|
||||
if (labels.length === 0) {
|
||||
return '未配置上游';
|
||||
return t('upstreamUnset');
|
||||
}
|
||||
if (labels.length === 1) {
|
||||
return labels[0];
|
||||
@@ -130,8 +154,10 @@ const originHostPattern =
|
||||
const headerKeyPattern = /^[A-Za-z0-9_-]+$/;
|
||||
const limitRatePattern = /^\d+(?:[kKmM])?$/;
|
||||
|
||||
export function getErrorMessage(error: unknown) {
|
||||
return error instanceof Error ? error.message : '请求失败,请稍后重试。';
|
||||
export function getErrorMessage(error: unknown, fallback?: string) {
|
||||
return error instanceof Error
|
||||
? error.message
|
||||
: (fallback ?? '请求失败,请稍后重试。');
|
||||
}
|
||||
|
||||
export function linesFromTextarea(value: string) {
|
||||
@@ -141,23 +167,23 @@ export function linesFromTextarea(value: string) {
|
||||
.filter(Boolean);
|
||||
}
|
||||
|
||||
export function validateDomains(domains: string[]) {
|
||||
export function validateDomains(domains: string[], t: TranslateFn) {
|
||||
if (domains.length === 0) {
|
||||
return '请至少填写一个域名';
|
||||
return t('validation.enterAtLeastOneDomain');
|
||||
}
|
||||
|
||||
const seen = new Set<string>();
|
||||
for (const domain of domains) {
|
||||
const normalized = domain.trim().toLowerCase();
|
||||
const error = validateDomain(normalized);
|
||||
const error = validateDomain(normalized, t);
|
||||
if (error && normalized) {
|
||||
return `域名格式不合法:${domain}`;
|
||||
return t('validation.invalidDomainWithValue', { domain });
|
||||
}
|
||||
if (!normalized) {
|
||||
continue;
|
||||
}
|
||||
if (seen.has(normalized)) {
|
||||
return `域名重复:${domain}`;
|
||||
return t('validation.duplicateDomain', { domain });
|
||||
}
|
||||
seen.add(normalized);
|
||||
}
|
||||
@@ -165,10 +191,10 @@ export function validateDomains(domains: string[]) {
|
||||
return null;
|
||||
}
|
||||
|
||||
export function parseOriginUrls(value: string) {
|
||||
export function parseOriginUrls(value: string, t: TranslateFn) {
|
||||
const urls = linesFromTextarea(value);
|
||||
if (urls.length === 0) {
|
||||
return { urls: [], error: '请至少填写一个上游地址' };
|
||||
return { urls: [], error: t('validation.enterAtLeastOneUpstream') };
|
||||
}
|
||||
|
||||
let sharedScheme = '';
|
||||
@@ -177,25 +203,31 @@ export function parseOriginUrls(value: string) {
|
||||
try {
|
||||
parsed = new URL(originUrl);
|
||||
} catch {
|
||||
return { urls: [], error: `上游地址格式不合法:${originUrl}` };
|
||||
return {
|
||||
urls: [],
|
||||
error: t('validation.invalidUpstream', { url: originUrl }),
|
||||
};
|
||||
}
|
||||
|
||||
if (parsed.protocol !== 'http:' && parsed.protocol !== 'https:') {
|
||||
return {
|
||||
urls: [],
|
||||
error: `上游地址必须以 http:// 或 https:// 开头:${originUrl}`,
|
||||
error: t('validation.upstreamMustBeHttp', { url: originUrl }),
|
||||
};
|
||||
}
|
||||
|
||||
if (!parsed.hostname) {
|
||||
return { urls: [], error: `上游地址缺少主机名:${originUrl}` };
|
||||
return {
|
||||
urls: [],
|
||||
error: t('validation.upstreamMissingHost', { url: originUrl }),
|
||||
};
|
||||
}
|
||||
|
||||
if (urls.length > 1) {
|
||||
if ((parsed.pathname && parsed.pathname !== '/') || parsed.search) {
|
||||
return {
|
||||
urls: [],
|
||||
error: '多上游模式暂不支持带路径或查询参数的地址',
|
||||
error: t('validation.multiUpstreamNoPath'),
|
||||
};
|
||||
}
|
||||
|
||||
@@ -204,7 +236,7 @@ export function parseOriginUrls(value: string) {
|
||||
} else if (sharedScheme !== parsed.protocol) {
|
||||
return {
|
||||
urls: [],
|
||||
error: '同一站点的多个上游必须使用相同协议',
|
||||
error: t('validation.multiUpstreamSameProtocol'),
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -213,7 +245,7 @@ export function parseOriginUrls(value: string) {
|
||||
return { urls, error: null };
|
||||
}
|
||||
|
||||
export function validateOriginHost(value: string) {
|
||||
export function validateOriginHost(value: string, t: TranslateFn) {
|
||||
const normalized = value.trim();
|
||||
if (!normalized) {
|
||||
return null;
|
||||
@@ -223,12 +255,12 @@ export function validateOriginHost(value: string) {
|
||||
/[/\\\s]/.test(normalized) ||
|
||||
!originHostPattern.test(normalized)
|
||||
) {
|
||||
return '回源 Host 格式不合法';
|
||||
return t('validation.invalidOriginHost');
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
export function parseCustomHeadersText(value: string) {
|
||||
export function parseCustomHeadersText(value: string, t: TranslateFn) {
|
||||
const lines = linesFromTextarea(value);
|
||||
const headers: ProxyRouteCustomHeader[] = [];
|
||||
|
||||
@@ -237,7 +269,7 @@ export function parseCustomHeadersText(value: string) {
|
||||
if (separatorIndex <= 0) {
|
||||
return {
|
||||
headers: [],
|
||||
error: `自定义请求头格式不合法:${line}`,
|
||||
error: t('validation.invalidHeader', { line }),
|
||||
};
|
||||
}
|
||||
|
||||
@@ -247,7 +279,7 @@ export function parseCustomHeadersText(value: string) {
|
||||
if (!headerKeyPattern.test(key)) {
|
||||
return {
|
||||
headers: [],
|
||||
error: `自定义请求头名称不合法:${key}`,
|
||||
error: t('validation.invalidHeaderName', { key }),
|
||||
};
|
||||
}
|
||||
|
||||
@@ -261,13 +293,13 @@ export function customHeadersToText(headers: ProxyRouteCustomHeader[]) {
|
||||
return headers.map((header) => `${header.key}: ${header.value}`).join('\n');
|
||||
}
|
||||
|
||||
export function validateLimitRate(value: string) {
|
||||
export function validateLimitRate(value: string, t: TranslateFn) {
|
||||
const normalized = value.trim();
|
||||
if (!normalized || normalized === '0' || normalized === '-1') {
|
||||
return null;
|
||||
}
|
||||
if (!limitRatePattern.test(normalized)) {
|
||||
return '限速格式不合法,请使用 512k、1m、纯数字,或 -1 关闭';
|
||||
return t('validation.invalidLimitRate');
|
||||
}
|
||||
return null;
|
||||
}
|
||||
@@ -280,13 +312,13 @@ export function normalizeLimitRate(value: string) {
|
||||
|
||||
const limitReqPattern = /^\d+r\/[sm]$/i;
|
||||
|
||||
export function validateLimitReqPerIP(value: string) {
|
||||
export function validateLimitReqPerIP(value: string, t: TranslateFn) {
|
||||
const normalized = value.trim();
|
||||
if (!normalized || normalized === '0' || normalized === '-1') {
|
||||
return null;
|
||||
}
|
||||
if (!limitReqPattern.test(normalized)) {
|
||||
return '请求频率格式不合法,请使用 10r/s、100r/m,或 -1 关闭';
|
||||
return t('validation.invalidLimitReq');
|
||||
}
|
||||
return null;
|
||||
}
|
||||
@@ -300,20 +332,21 @@ export function normalizeLimitReqPerIP(value: string) {
|
||||
export function validateCacheRules(
|
||||
policy: 'static' | 'all' | 'url' | 'suffix' | 'path_prefix' | 'path_exact',
|
||||
rules: string[],
|
||||
t: TranslateFn,
|
||||
) {
|
||||
if (policy === 'static' || policy === 'all' || policy === 'url') {
|
||||
return null;
|
||||
}
|
||||
|
||||
if (rules.length === 0) {
|
||||
return '当前缓存策略至少需要一条规则';
|
||||
return t('validation.cacheRuleRequired');
|
||||
}
|
||||
|
||||
if (policy === 'suffix') {
|
||||
for (const rule of rules) {
|
||||
const normalized = rule.replace(/^\./, '');
|
||||
if (!normalized || /[/\\\s]/.test(normalized)) {
|
||||
return `缓存后缀格式不合法:${rule}`;
|
||||
return t('validation.invalidCacheSuffix', { rule });
|
||||
}
|
||||
}
|
||||
return null;
|
||||
@@ -321,7 +354,7 @@ export function validateCacheRules(
|
||||
|
||||
for (const rule of rules) {
|
||||
if (!rule.startsWith('/') || rule.includes('://') || /[\s]/.test(rule)) {
|
||||
return `缓存路径规则格式不合法:${rule}`;
|
||||
return t('validation.invalidCachePath', { rule });
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -44,70 +44,22 @@ import {
|
||||
zoneQueryKey,
|
||||
} from '@/lib/services/openflare';
|
||||
|
||||
import { useTranslations } from 'next-intl';
|
||||
|
||||
import { listAllZoneDomains, parseOriginUrl, parseOriginUrls } from './helpers';
|
||||
import { ZoneDomainSelector } from './zone-domain-selector';
|
||||
|
||||
const createProxyRouteSchema = z
|
||||
.object({
|
||||
site_name: z.string().trim().max(255, '站点标识不能超过 255 个字符'),
|
||||
zone_domain_ids: z
|
||||
.array(z.number().int().positive())
|
||||
.min(1, '请至少选择一个域名'),
|
||||
upstream_type: z.enum(['direct', 'tunnel', 'pages']),
|
||||
origin_urls_text: z.string().trim(),
|
||||
tunnel_id: z.string().optional(),
|
||||
tunnel_target_addr: z.string().trim().optional(),
|
||||
tunnel_target_protocol: z.enum(['http', 'https']).optional(),
|
||||
pages_project_id: z.string().optional(),
|
||||
enabled: z.boolean(),
|
||||
})
|
||||
.superRefine((value, context) => {
|
||||
if (value.upstream_type === 'direct') {
|
||||
if (!value.origin_urls_text.trim()) {
|
||||
context.addIssue({
|
||||
code: z.ZodIssueCode.custom,
|
||||
path: ['origin_urls_text'],
|
||||
message: '请至少填写一个上游地址',
|
||||
});
|
||||
} else {
|
||||
const { error } = parseOriginUrls(value.origin_urls_text);
|
||||
if (error) {
|
||||
context.addIssue({
|
||||
code: z.ZodIssueCode.custom,
|
||||
path: ['origin_urls_text'],
|
||||
message: error,
|
||||
});
|
||||
}
|
||||
}
|
||||
return;
|
||||
}
|
||||
if (value.upstream_type === 'tunnel') {
|
||||
if (!value.tunnel_id) {
|
||||
context.addIssue({
|
||||
code: z.ZodIssueCode.custom,
|
||||
path: ['tunnel_id'],
|
||||
message: '请选择内网穿透隧道',
|
||||
});
|
||||
}
|
||||
if (!value.tunnel_target_addr?.trim()) {
|
||||
context.addIssue({
|
||||
code: z.ZodIssueCode.custom,
|
||||
path: ['tunnel_target_addr'],
|
||||
message: '请填写内网服务地址 (如 127.0.0.1:8080)',
|
||||
});
|
||||
}
|
||||
return;
|
||||
}
|
||||
if (!value.pages_project_id) {
|
||||
context.addIssue({
|
||||
code: z.ZodIssueCode.custom,
|
||||
path: ['pages_project_id'],
|
||||
message: '请选择 Pages 项目',
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
type CreateProxyRouteFormValues = z.infer<typeof createProxyRouteSchema>;
|
||||
type CreateProxyRouteFormValues = {
|
||||
site_name: string;
|
||||
zone_domain_ids: number[];
|
||||
upstream_type: 'direct' | 'tunnel' | 'pages';
|
||||
origin_urls_text: string;
|
||||
tunnel_id?: string;
|
||||
tunnel_target_addr?: string;
|
||||
tunnel_target_protocol?: 'http' | 'https';
|
||||
pages_project_id?: string;
|
||||
enabled: boolean;
|
||||
};
|
||||
|
||||
const defaultValues: CreateProxyRouteFormValues = {
|
||||
site_name: '',
|
||||
@@ -132,6 +84,67 @@ export function ProxyRouteCreateSheet({
|
||||
onOpenChange,
|
||||
onCreated,
|
||||
}: ProxyRouteCreateSheetProps) {
|
||||
const t = useTranslations('proxyRoutes');
|
||||
const tc = useTranslations('common');
|
||||
const createProxyRouteSchema = z
|
||||
.object({
|
||||
site_name: z.string().trim().max(255, t('validation.siteNameTooLong')),
|
||||
zone_domain_ids: z
|
||||
.array(z.number().int().positive())
|
||||
.min(1, t('validation.selectAtLeastOneDomain')),
|
||||
upstream_type: z.enum(['direct', 'tunnel', 'pages']),
|
||||
origin_urls_text: z.string().trim(),
|
||||
tunnel_id: z.string().optional(),
|
||||
tunnel_target_addr: z.string().trim().optional(),
|
||||
tunnel_target_protocol: z.enum(['http', 'https']).optional(),
|
||||
pages_project_id: z.string().optional(),
|
||||
enabled: z.boolean(),
|
||||
})
|
||||
.superRefine((value, context) => {
|
||||
if (value.upstream_type === 'direct') {
|
||||
if (!value.origin_urls_text.trim()) {
|
||||
context.addIssue({
|
||||
code: z.ZodIssueCode.custom,
|
||||
path: ['origin_urls_text'],
|
||||
message: t('validation.enterAtLeastOneUpstream'),
|
||||
});
|
||||
} else {
|
||||
const { error } = parseOriginUrls(value.origin_urls_text, t);
|
||||
if (error) {
|
||||
context.addIssue({
|
||||
code: z.ZodIssueCode.custom,
|
||||
path: ['origin_urls_text'],
|
||||
message: error,
|
||||
});
|
||||
}
|
||||
}
|
||||
return;
|
||||
}
|
||||
if (value.upstream_type === 'tunnel') {
|
||||
if (!value.tunnel_id) {
|
||||
context.addIssue({
|
||||
code: z.ZodIssueCode.custom,
|
||||
path: ['tunnel_id'],
|
||||
message: t('validation.selectTunnel'),
|
||||
});
|
||||
}
|
||||
if (!value.tunnel_target_addr?.trim()) {
|
||||
context.addIssue({
|
||||
code: z.ZodIssueCode.custom,
|
||||
path: ['tunnel_target_addr'],
|
||||
message: t('validation.enterTunnelTarget'),
|
||||
});
|
||||
}
|
||||
return;
|
||||
}
|
||||
if (!value.pages_project_id) {
|
||||
context.addIssue({
|
||||
code: z.ZodIssueCode.custom,
|
||||
path: ['pages_project_id'],
|
||||
message: t('validation.selectPagesProject'),
|
||||
});
|
||||
}
|
||||
});
|
||||
const form = useForm<CreateProxyRouteFormValues>({
|
||||
resolver: zodResolver(createProxyRouteSchema),
|
||||
defaultValues,
|
||||
@@ -185,7 +198,7 @@ export function ProxyRouteCreateSheet({
|
||||
let upstreams: string[] = [];
|
||||
|
||||
if (values.upstream_type === 'direct') {
|
||||
const { urls } = parseOriginUrls(values.origin_urls_text);
|
||||
const { urls } = parseOriginUrls(values.origin_urls_text, t);
|
||||
const primaryOrigin = parseOriginUrl(urls[0]);
|
||||
originUrl = urls[0];
|
||||
originScheme = primaryOrigin.scheme;
|
||||
@@ -257,7 +270,7 @@ export function ProxyRouteCreateSheet({
|
||||
} catch (error) {
|
||||
form.setError('root', {
|
||||
message:
|
||||
error instanceof Error ? error.message : '创建失败,请稍后重试',
|
||||
error instanceof Error ? error.message : t('createFailed'),
|
||||
});
|
||||
}
|
||||
});
|
||||
@@ -266,11 +279,8 @@ export function ProxyRouteCreateSheet({
|
||||
<Sheet open={open} onOpenChange={onOpenChange}>
|
||||
<SheetContent side='right' className='w-full sm:max-w-lg overflow-y-auto'>
|
||||
<SheetHeader>
|
||||
<SheetTitle>新建规则</SheetTitle>
|
||||
<SheetDescription>
|
||||
从已注册的 Zone
|
||||
域名中选择绑定关系,创建后可继续配置缓存和限流等高级选项。
|
||||
</SheetDescription>
|
||||
<SheetTitle>{t('createRule')}</SheetTitle>
|
||||
<SheetDescription>{t('createDesc')}</SheetDescription>
|
||||
</SheetHeader>
|
||||
|
||||
<Form {...form}>
|
||||
@@ -280,13 +290,11 @@ export function ProxyRouteCreateSheet({
|
||||
name='site_name'
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>站点标识</FormLabel>
|
||||
<FormLabel>{t('siteId')}</FormLabel>
|
||||
<FormControl>
|
||||
<Input placeholder='marketing-site' {...field} />
|
||||
</FormControl>
|
||||
<FormDescription>
|
||||
可选,留空时会自动使用首个域名。
|
||||
</FormDescription>
|
||||
<FormDescription>{t('siteIdOptionalDesc')}</FormDescription>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
@@ -297,7 +305,7 @@ export function ProxyRouteCreateSheet({
|
||||
name='zone_domain_ids'
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>绑定域名</FormLabel>
|
||||
<FormLabel>{t('bindDomains')}</FormLabel>
|
||||
<FormControl>
|
||||
<ZoneDomainSelector
|
||||
value={field.value}
|
||||
@@ -320,13 +328,13 @@ export function ProxyRouteCreateSheet({
|
||||
name='upstream_type'
|
||||
render={({ field }) => (
|
||||
<FormItem className='space-y-3'>
|
||||
<FormLabel>回源方式</FormLabel>
|
||||
<FormLabel>{t('upstreamType')}</FormLabel>
|
||||
<div className='flex flex-wrap gap-4'>
|
||||
{(
|
||||
[
|
||||
['direct', '直连上游'],
|
||||
['tunnel', '内网穿透 (Tunnel)'],
|
||||
['pages', 'Pages 静态站点'],
|
||||
['direct', t('upstreamDirect')],
|
||||
['tunnel', t('upstreamTunnel')],
|
||||
['pages', t('upstreamPages')],
|
||||
] as const
|
||||
).map(([value, label]) => (
|
||||
<label
|
||||
@@ -355,7 +363,7 @@ export function ProxyRouteCreateSheet({
|
||||
name='origin_urls_text'
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>上游地址</FormLabel>
|
||||
<FormLabel>{t('upstreamAddresses')}</FormLabel>
|
||||
<FormControl>
|
||||
<Textarea
|
||||
className='min-h-32 font-mono text-xs'
|
||||
@@ -365,11 +373,7 @@ export function ProxyRouteCreateSheet({
|
||||
{...field}
|
||||
/>
|
||||
</FormControl>
|
||||
<FormDescription>
|
||||
每行一个完整
|
||||
URL。第一行作为主回源,多上游模式请保持相同协议且不要包含
|
||||
path 或 query。
|
||||
</FormDescription>
|
||||
<FormDescription>{t('upstreamUrlsHint')}</FormDescription>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
@@ -383,7 +387,7 @@ export function ProxyRouteCreateSheet({
|
||||
name='tunnel_id'
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>选择内网穿透隧道</FormLabel>
|
||||
<FormLabel>{t('selectTunnel')}</FormLabel>
|
||||
<Select
|
||||
value={field.value || 'none'}
|
||||
onValueChange={(value) =>
|
||||
@@ -392,25 +396,26 @@ export function ProxyRouteCreateSheet({
|
||||
>
|
||||
<FormControl>
|
||||
<SelectTrigger>
|
||||
<SelectValue placeholder='请选择...' />
|
||||
<SelectValue placeholder={t('pleaseSelect')} />
|
||||
</SelectTrigger>
|
||||
</FormControl>
|
||||
<SelectContent>
|
||||
<SelectItem value='none'>请选择...</SelectItem>
|
||||
<SelectItem value='none'>{t('pleaseSelect')}</SelectItem>
|
||||
{tunnelClients.map((tunnel) => (
|
||||
<SelectItem
|
||||
key={tunnel.id}
|
||||
value={String(tunnel.id)}
|
||||
>
|
||||
{tunnel.name} (
|
||||
{tunnel.status === 'online' ? '在线' : '离线'})
|
||||
{tunnel.status === 'online'
|
||||
? t('online')
|
||||
: t('offline')}
|
||||
)
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
<FormDescription>
|
||||
将请求转发到该隧道连接的客户端节点。
|
||||
</FormDescription>
|
||||
<FormDescription>{t('tunnelForwardHint')}</FormDescription>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
@@ -421,7 +426,7 @@ export function ProxyRouteCreateSheet({
|
||||
name='tunnel_target_protocol'
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>内网服务协议</FormLabel>
|
||||
<FormLabel>{t('tunnelProtocol')}</FormLabel>
|
||||
<Select
|
||||
value={field.value}
|
||||
onValueChange={field.onChange}
|
||||
@@ -446,13 +451,11 @@ export function ProxyRouteCreateSheet({
|
||||
name='tunnel_target_addr'
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>内网服务地址</FormLabel>
|
||||
<FormLabel>{t('tunnelAddress')}</FormLabel>
|
||||
<FormControl>
|
||||
<Input placeholder='127.0.0.1:8080' {...field} />
|
||||
</FormControl>
|
||||
<FormDescription>
|
||||
例如: 127.0.0.1:8080 或 192.168.1.10:80
|
||||
</FormDescription>
|
||||
<FormDescription>{t('tunnelAddressHint')}</FormDescription>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
@@ -467,7 +470,7 @@ export function ProxyRouteCreateSheet({
|
||||
name='pages_project_id'
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>选择 Pages 项目</FormLabel>
|
||||
<FormLabel>{t('selectPagesProject')}</FormLabel>
|
||||
<Select
|
||||
value={field.value || 'none'}
|
||||
onValueChange={(value) =>
|
||||
@@ -476,11 +479,11 @@ export function ProxyRouteCreateSheet({
|
||||
>
|
||||
<FormControl>
|
||||
<SelectTrigger>
|
||||
<SelectValue placeholder='请选择...' />
|
||||
<SelectValue placeholder={t('pleaseSelect')} />
|
||||
</SelectTrigger>
|
||||
</FormControl>
|
||||
<SelectContent>
|
||||
<SelectItem value='none'>请选择...</SelectItem>
|
||||
<SelectItem value='none'>{t('pleaseSelect')}</SelectItem>
|
||||
{pagesProjects.map((project) => (
|
||||
<SelectItem
|
||||
key={project.id}
|
||||
@@ -504,10 +507,8 @@ export function ProxyRouteCreateSheet({
|
||||
render={({ field }) => (
|
||||
<FormItem className='flex items-center justify-between rounded-lg border p-3'>
|
||||
<div className='space-y-0.5'>
|
||||
<FormLabel>启用站点</FormLabel>
|
||||
<FormDescription>
|
||||
关闭后会保留配置,但不会参与发布。
|
||||
</FormDescription>
|
||||
<FormLabel>{t('enableSite')}</FormLabel>
|
||||
<FormDescription>{t('enableSiteDesc')}</FormDescription>
|
||||
</div>
|
||||
<FormControl>
|
||||
<Switch
|
||||
@@ -531,10 +532,10 @@ export function ProxyRouteCreateSheet({
|
||||
variant='outline'
|
||||
onClick={() => onOpenChange(false)}
|
||||
>
|
||||
取消
|
||||
{tc('cancel')}
|
||||
</Button>
|
||||
<Button type='submit' disabled={form.formState.isSubmitting}>
|
||||
{form.formState.isSubmitting ? '创建中…' : '创建'}
|
||||
{form.formState.isSubmitting ? t('creating') : t('create')}
|
||||
</Button>
|
||||
</SheetFooter>
|
||||
</form>
|
||||
|
||||
@@ -11,6 +11,8 @@ import { Input } from '@/components/ui/input';
|
||||
import { cn } from '@/lib/utils';
|
||||
import type { ZoneDomainItem, ZoneItem } from '@/lib/services/openflare';
|
||||
|
||||
import { useTranslations } from 'next-intl';
|
||||
|
||||
import { QuickCreateZoneDomainDialog } from '../../websites/components/quick-create-zone-domain-dialog';
|
||||
|
||||
export interface ZoneDomainSelectorProps {
|
||||
@@ -36,6 +38,7 @@ export function ZoneDomainSelector({
|
||||
className,
|
||||
onDomainCreated,
|
||||
}: ZoneDomainSelectorProps) {
|
||||
const t = useTranslations('proxyRoutes');
|
||||
const [keyword, setKeyword] = useState('');
|
||||
const [createOpen, setCreateOpen] = useState(false);
|
||||
const selected = useMemo(() => new Set(value), [value]);
|
||||
@@ -97,7 +100,7 @@ export function ZoneDomainSelector({
|
||||
<Input
|
||||
value={keyword}
|
||||
onChange={(event) => setKeyword(event.target.value)}
|
||||
placeholder='搜索域名或 Zone…'
|
||||
placeholder={t('searchDomainOrZone')}
|
||||
disabled={disabled}
|
||||
className='sm:flex-1'
|
||||
/>
|
||||
@@ -110,27 +113,27 @@ export function ZoneDomainSelector({
|
||||
onClick={() => setCreateOpen(true)}
|
||||
>
|
||||
<Plus className='mr-1 size-3.5' />
|
||||
快捷新增域名
|
||||
{t('quickAddDomain')}
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{filtered.length === 0 ? (
|
||||
<div className='rounded-lg border border-dashed px-4 py-8 text-center text-sm text-muted-foreground'>
|
||||
{domains.length === 0 ? (
|
||||
<>
|
||||
暂无可用 Zone 域名。请先在{' '}
|
||||
<Link
|
||||
href='/websites'
|
||||
className='text-primary underline-offset-4 hover:underline'
|
||||
>
|
||||
网站管理
|
||||
</Link>{' '}
|
||||
中添加 FQDN,或使用「快捷新增域名」。
|
||||
</>
|
||||
t.rich('noZoneDomains', {
|
||||
websites: (chunks) => (
|
||||
<Link
|
||||
href='/websites'
|
||||
className='text-primary underline-offset-4 hover:underline'
|
||||
>
|
||||
{chunks}
|
||||
</Link>
|
||||
),
|
||||
})
|
||||
) : availableDomains.length === 0 ? (
|
||||
'没有可绑定的域名(其余域名已绑定其他路由)。可用「快捷新增域名」创建。'
|
||||
t('noBindableDomains')
|
||||
) : (
|
||||
'没有匹配的域名'
|
||||
t('noMatchingDomains')
|
||||
)}
|
||||
</div>
|
||||
) : (
|
||||
@@ -160,11 +163,11 @@ export function ZoneDomainSelector({
|
||||
</span>
|
||||
{domain.cert_id ? (
|
||||
<Badge variant='secondary' className='text-[10px]'>
|
||||
证书 #{domain.cert_id}
|
||||
{t('certNumber', { id: domain.cert_id })}
|
||||
</Badge>
|
||||
) : (
|
||||
<Badge variant='outline' className='text-[10px]'>
|
||||
无证书
|
||||
{t('noCert')}
|
||||
</Badge>
|
||||
)}
|
||||
</span>
|
||||
@@ -175,11 +178,11 @@ export function ZoneDomainSelector({
|
||||
className='inline-flex items-center gap-0.5 hover:text-foreground'
|
||||
onClick={(event) => event.stopPropagation()}
|
||||
>
|
||||
Zone {zoneRoot}
|
||||
{t('zoneName', { name: zoneRoot })}
|
||||
<ExternalLink className='size-3' />
|
||||
</Link>
|
||||
) : (
|
||||
<span>Zone #{domain.zone_id}</span>
|
||||
<span>{t('zoneId', { id: domain.zone_id })}</span>
|
||||
)}
|
||||
</span>
|
||||
</span>
|
||||
|
||||
@@ -25,37 +25,17 @@ import {
|
||||
} from '@/components/ui/select';
|
||||
import type { ProxyRouteItem } from '@/lib/services/openflare';
|
||||
|
||||
import { useTranslations } from 'next-intl';
|
||||
|
||||
import { proxyRouteFormIds } from '../helpers';
|
||||
import { useRouteSectionSave } from '../hooks/use-route-section-save';
|
||||
import { SectionShell } from './section-shell';
|
||||
|
||||
const authSchema = z
|
||||
.object({
|
||||
auth_mode: z.enum(['none', 'basic']),
|
||||
basic_auth_username: z.string(),
|
||||
basic_auth_password: z.string(),
|
||||
})
|
||||
.superRefine((value, context) => {
|
||||
if (value.auth_mode !== 'basic') {
|
||||
return;
|
||||
}
|
||||
if (!value.basic_auth_username.trim()) {
|
||||
context.addIssue({
|
||||
code: z.ZodIssueCode.custom,
|
||||
path: ['basic_auth_username'],
|
||||
message: '请输入账号',
|
||||
});
|
||||
}
|
||||
if (!value.basic_auth_password.trim()) {
|
||||
context.addIssue({
|
||||
code: z.ZodIssueCode.custom,
|
||||
path: ['basic_auth_password'],
|
||||
message: '请输入密码',
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
type AuthValues = z.infer<typeof authSchema>;
|
||||
type AuthValues = {
|
||||
auth_mode: 'none' | 'basic';
|
||||
basic_auth_username: string;
|
||||
basic_auth_password: string;
|
||||
};
|
||||
|
||||
function resolveAuthMode(route: ProxyRouteItem): AuthValues['auth_mode'] {
|
||||
return route.basic_auth_enabled ? 'basic' : 'none';
|
||||
@@ -72,6 +52,32 @@ export function AuthSection({
|
||||
onRouteUpdate,
|
||||
onSavingChange,
|
||||
}: AuthSectionProps) {
|
||||
const t = useTranslations('proxyRoutes');
|
||||
const authSchema = z
|
||||
.object({
|
||||
auth_mode: z.enum(['none', 'basic']),
|
||||
basic_auth_username: z.string(),
|
||||
basic_auth_password: z.string(),
|
||||
})
|
||||
.superRefine((value, context) => {
|
||||
if (value.auth_mode !== 'basic') {
|
||||
return;
|
||||
}
|
||||
if (!value.basic_auth_username.trim()) {
|
||||
context.addIssue({
|
||||
code: z.ZodIssueCode.custom,
|
||||
path: ['basic_auth_username'],
|
||||
message: t('validation.enterUsername'),
|
||||
});
|
||||
}
|
||||
if (!value.basic_auth_password.trim()) {
|
||||
context.addIssue({
|
||||
code: z.ZodIssueCode.custom,
|
||||
path: ['basic_auth_password'],
|
||||
message: t('validation.enterPassword'),
|
||||
});
|
||||
}
|
||||
});
|
||||
const { saving, save } = useRouteSectionSave(
|
||||
route,
|
||||
onRouteUpdate,
|
||||
@@ -99,8 +105,8 @@ export function AuthSection({
|
||||
|
||||
return (
|
||||
<SectionShell
|
||||
title='认证配置'
|
||||
description='配置 Basic Auth 限制未授权访问。PoW 防护请在 WAF 规则组中配置。'
|
||||
title={t('auth')}
|
||||
description={t('authDesc')}
|
||||
formId={proxyRouteFormIds.auth}
|
||||
saving={saving}
|
||||
>
|
||||
@@ -122,11 +128,11 @@ export function AuthSection({
|
||||
? values.basic_auth_password.trim()
|
||||
: '',
|
||||
},
|
||||
'认证配置已保存',
|
||||
t('authSaved'),
|
||||
);
|
||||
},
|
||||
() => {
|
||||
toast.error('请检查认证配置表单');
|
||||
toast.error(t('authFormInvalid'));
|
||||
},
|
||||
)}
|
||||
>
|
||||
@@ -135,7 +141,7 @@ export function AuthSection({
|
||||
name='auth_mode'
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>认证模式</FormLabel>
|
||||
<FormLabel>{t('authMode')}</FormLabel>
|
||||
<Select
|
||||
value={field.value}
|
||||
onValueChange={(mode) => {
|
||||
@@ -152,12 +158,12 @@ export function AuthSection({
|
||||
</SelectTrigger>
|
||||
</FormControl>
|
||||
<SelectContent>
|
||||
<SelectItem value='none'>无认证</SelectItem>
|
||||
<SelectItem value='none'>{t('authNone')}</SelectItem>
|
||||
<SelectItem value='basic'>Basic Auth</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
<FormDescription>
|
||||
PoW 防护仅支持在 WAF 规则组中启用并绑定站点。
|
||||
{t('powHint')}
|
||||
</FormDescription>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
@@ -171,7 +177,7 @@ export function AuthSection({
|
||||
name='basic_auth_username'
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>账号</FormLabel>
|
||||
<FormLabel>{t('username')}</FormLabel>
|
||||
<FormControl>
|
||||
<Input placeholder='admin' {...field} />
|
||||
</FormControl>
|
||||
@@ -185,7 +191,7 @@ export function AuthSection({
|
||||
name='basic_auth_password'
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>密码</FormLabel>
|
||||
<FormLabel>{t('password')}</FormLabel>
|
||||
<FormControl>
|
||||
<Input type='text' placeholder='secret123' {...field} />
|
||||
</FormControl>
|
||||
|
||||
@@ -33,6 +33,8 @@ import { Switch } from '@/components/ui/switch';
|
||||
import { Textarea } from '@/components/ui/textarea';
|
||||
import type { ProxyRouteItem } from '@/lib/services/openflare';
|
||||
|
||||
import { useTranslations } from 'next-intl';
|
||||
|
||||
import {
|
||||
linesFromTextarea,
|
||||
validateCacheRules,
|
||||
@@ -41,35 +43,11 @@ import { proxyRouteFormIds } from '../helpers';
|
||||
import { useRouteSectionSave } from '../hooks/use-route-section-save';
|
||||
import { SectionShell } from './section-shell';
|
||||
|
||||
const cacheSchema = z
|
||||
.object({
|
||||
cache_enabled: z.boolean(),
|
||||
cache_policy: z.enum([
|
||||
'static',
|
||||
'all',
|
||||
'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 CacheValues = z.infer<typeof cacheSchema>;
|
||||
type CacheValues = {
|
||||
cache_enabled: boolean;
|
||||
cache_policy: 'static' | 'all' | 'suffix' | 'path_prefix' | 'path_exact';
|
||||
cache_rules_text: string;
|
||||
};
|
||||
|
||||
interface CacheSectionProps {
|
||||
route: ProxyRouteItem;
|
||||
@@ -101,6 +79,7 @@ function needsRulesForPolicy(policy: string) {
|
||||
}
|
||||
|
||||
function CacheHelpButton() {
|
||||
const t = useTranslations('proxyRoutes');
|
||||
return (
|
||||
<Popover>
|
||||
<PopoverTrigger asChild>
|
||||
@@ -109,39 +88,38 @@ function CacheHelpButton() {
|
||||
variant='ghost'
|
||||
size='icon-sm'
|
||||
className='size-7 text-muted-foreground'
|
||||
aria-label='缓存使用说明'
|
||||
aria-label={t('cacheHelpTitle')}
|
||||
>
|
||||
<CircleHelp />
|
||||
</Button>
|
||||
</PopoverTrigger>
|
||||
<PopoverContent align='start' className='w-80 flex flex-col gap-3 p-4'>
|
||||
<div className='text-sm font-medium'>缓存使用说明</div>
|
||||
<div className='text-sm font-medium'>{t('cacheHelpTitle')}</div>
|
||||
<div className='flex flex-col gap-2 text-xs text-muted-foreground leading-relaxed'>
|
||||
<p>{t('cacheHelpIntro')}</p>
|
||||
<p>
|
||||
扩展名/策略决定是否可缓存,源站头与Set-Cookie
|
||||
决定是否入库。须同时开启性能设置中的全局 OpenResty 缓存。
|
||||
</p>
|
||||
<p>
|
||||
<span className='font-medium text-foreground'>推荐策略:</span>
|
||||
新建站点建议使用「标准静态资源」(含
|
||||
css/js/map/图片/字体/媒体等,不含 HTML/JSON)。
|
||||
</p>
|
||||
<p>
|
||||
<span className='font-medium text-foreground'>通用规则:</span>非
|
||||
GET 不缓存;登录 Cookie 不会单独跳过缓存;源站 private/no-store
|
||||
或响应 Set-Cookie 不会写入边缘。
|
||||
<span className='font-medium text-foreground'>
|
||||
{t('cacheHelpRecommendLabel')}
|
||||
</span>
|
||||
{t('cacheHelpRecommend')}
|
||||
</p>
|
||||
<p>
|
||||
<span className='font-medium text-foreground'>
|
||||
所有可缓存 GET:
|
||||
{t('cacheHelpGeneralLabel')}
|
||||
</span>
|
||||
高级选项,类似 Cache Everything。个性化页面须由源站声明
|
||||
private/no-store,否则 HTML 可能被边缘缓存并串给其他用户。
|
||||
{t('cacheHelpGeneral')}
|
||||
</p>
|
||||
<p>
|
||||
<span className='font-medium text-foreground'>自定义规则:</span>
|
||||
后缀填 jpg/css/js;路径前缀填 /assets;精确路径填 /robots.txt。
|
||||
保存后须重新发布配置版本才会在节点生效。
|
||||
<span className='font-medium text-foreground'>
|
||||
{t('cacheHelpAllGetLabel')}
|
||||
</span>
|
||||
{t('cacheHelpAllGet')}
|
||||
</p>
|
||||
<p>
|
||||
<span className='font-medium text-foreground'>
|
||||
{t('cacheHelpCustomLabel')}
|
||||
</span>
|
||||
{t('cacheHelpCustom')}
|
||||
</p>
|
||||
</div>
|
||||
</PopoverContent>
|
||||
@@ -154,6 +132,34 @@ export function CacheSection({
|
||||
onRouteUpdate,
|
||||
onSavingChange,
|
||||
}: CacheSectionProps) {
|
||||
const t = useTranslations('proxyRoutes');
|
||||
const cacheSchema = z
|
||||
.object({
|
||||
cache_enabled: z.boolean(),
|
||||
cache_policy: z.enum([
|
||||
'static',
|
||||
'all',
|
||||
'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, t);
|
||||
if (error) {
|
||||
context.addIssue({
|
||||
code: z.ZodIssueCode.custom,
|
||||
path: ['cache_rules_text'],
|
||||
message: error,
|
||||
});
|
||||
}
|
||||
});
|
||||
const { saving, save } = useRouteSectionSave(
|
||||
route,
|
||||
onRouteUpdate,
|
||||
@@ -192,14 +198,14 @@ export function CacheSection({
|
||||
|
||||
const rulesHint =
|
||||
watchedPolicy === 'suffix'
|
||||
? '每行一个后缀,例如 jpg、css、js。'
|
||||
? t('cacheHintSuffix')
|
||||
: watchedPolicy === 'path_prefix'
|
||||
? '每行一个路径前缀,例如 /assets、/static。'
|
||||
? t('cacheHintPrefix')
|
||||
: watchedPolicy === 'path_exact'
|
||||
? '每行一个精确路径,例如 /robots.txt。'
|
||||
? t('cacheHintExact')
|
||||
: watchedPolicy === 'static'
|
||||
? '标准静态资源使用内置扩展名列表,无需填写规则。'
|
||||
: '当前策略无需额外路径规则。';
|
||||
? t('cacheHintStatic')
|
||||
: t('cacheHintNone');
|
||||
|
||||
const rulesPlaceholder =
|
||||
watchedPolicy === 'suffix'
|
||||
@@ -208,12 +214,12 @@ export function CacheSection({
|
||||
? '/assets\n/static'
|
||||
: watchedPolicy === 'path_exact'
|
||||
? '/robots.txt\n/manifest.json'
|
||||
: '当前策略无需额外规则';
|
||||
: t('cachePlaceholderNone');
|
||||
|
||||
return (
|
||||
<SectionShell
|
||||
title='缓存'
|
||||
description='配置站点边缘缓存策略。'
|
||||
title={t('cache')}
|
||||
description={t('cacheDesc')}
|
||||
titleExtra={<CacheHelpButton />}
|
||||
formId={proxyRouteFormIds.cache}
|
||||
saving={saving}
|
||||
@@ -234,7 +240,7 @@ export function CacheSection({
|
||||
? rules
|
||||
: [],
|
||||
},
|
||||
'缓存设置已保存',
|
||||
t('cacheSaved'),
|
||||
);
|
||||
})}
|
||||
>
|
||||
@@ -244,8 +250,8 @@ export function CacheSection({
|
||||
render={({ field }) => (
|
||||
<FormItem className='flex items-center justify-between rounded-lg border p-3'>
|
||||
<div className='space-y-0.5'>
|
||||
<FormLabel>启用站点缓存</FormLabel>
|
||||
<FormDescription>新建推荐「标准静态资源」。</FormDescription>
|
||||
<FormLabel>{t('enableCache')}</FormLabel>
|
||||
<FormDescription>{t('enableCacheDesc')}</FormDescription>
|
||||
</div>
|
||||
<FormControl>
|
||||
<Switch
|
||||
@@ -262,7 +268,7 @@ export function CacheSection({
|
||||
name='cache_policy'
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>缓存策略</FormLabel>
|
||||
<FormLabel>{t('cachePolicy')}</FormLabel>
|
||||
<Select
|
||||
disabled={!watchedEnabled}
|
||||
value={field.value}
|
||||
@@ -274,11 +280,13 @@ export function CacheSection({
|
||||
</SelectTrigger>
|
||||
</FormControl>
|
||||
<SelectContent>
|
||||
<SelectItem value='static'>标准静态资源(推荐)</SelectItem>
|
||||
<SelectItem value='all'>所有可缓存 GET(高级)</SelectItem>
|
||||
<SelectItem value='suffix'>自定义后缀</SelectItem>
|
||||
<SelectItem value='path_prefix'>路径前缀</SelectItem>
|
||||
<SelectItem value='path_exact'>精确路径</SelectItem>
|
||||
<SelectItem value='static'>{t('policyStatic')}</SelectItem>
|
||||
<SelectItem value='all'>{t('policyAll')}</SelectItem>
|
||||
<SelectItem value='suffix'>{t('policySuffix')}</SelectItem>
|
||||
<SelectItem value='path_prefix'>
|
||||
{t('policyPrefix')}
|
||||
</SelectItem>
|
||||
<SelectItem value='path_exact'>{t('policyExact')}</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
<FormMessage />
|
||||
@@ -289,11 +297,8 @@ export function CacheSection({
|
||||
{watchedEnabled && watchedPolicy === 'all' ? (
|
||||
<Alert>
|
||||
<TriangleAlert />
|
||||
<AlertTitle>高级策略风险</AlertTitle>
|
||||
<AlertDescription>
|
||||
个性化 HTML 在源站未声明 private / no-store
|
||||
时可能被边缘缓存并串给其他用户。详情见标题旁帮助。
|
||||
</AlertDescription>
|
||||
<AlertTitle>{t('advancedRiskTitle')}</AlertTitle>
|
||||
<AlertDescription>{t('advancedRiskDesc')}</AlertDescription>
|
||||
</Alert>
|
||||
) : null}
|
||||
|
||||
@@ -302,7 +307,7 @@ export function CacheSection({
|
||||
name='cache_rules_text'
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>缓存规则</FormLabel>
|
||||
<FormLabel>{t('cacheRules')}</FormLabel>
|
||||
<FormControl>
|
||||
<Textarea
|
||||
className='min-h-32'
|
||||
|
||||
@@ -20,26 +20,20 @@ import { Switch } from '@/components/ui/switch';
|
||||
import type { ProxyRouteItem } from '@/lib/services/openflare';
|
||||
import { zoneQueryKey, ZoneService } from '@/lib/services/openflare';
|
||||
|
||||
import { useTranslations } from 'next-intl';
|
||||
|
||||
import { listAllZoneDomains } from '../../components/helpers';
|
||||
import { ZoneDomainSelector } from '../../components/zone-domain-selector';
|
||||
import { proxyRouteFormIds } from '../helpers';
|
||||
import { useRouteSectionSave } from '../hooks/use-route-section-save';
|
||||
import { SectionShell } from './section-shell';
|
||||
|
||||
const domainSettingsSchema = z.object({
|
||||
site_name: z
|
||||
.string()
|
||||
.trim()
|
||||
.min(1, '请输入站点标识')
|
||||
.max(255, '站点标识不能超过 255 个字符'),
|
||||
zone_domain_ids: z
|
||||
.array(z.number().int().positive())
|
||||
.min(1, '请至少选择一个域名'),
|
||||
enabled: z.boolean(),
|
||||
redirect_http: z.boolean(),
|
||||
});
|
||||
|
||||
type DomainSettingsValues = z.infer<typeof domainSettingsSchema>;
|
||||
type DomainSettingsValues = {
|
||||
site_name: string;
|
||||
zone_domain_ids: number[];
|
||||
enabled: boolean;
|
||||
redirect_http: boolean;
|
||||
};
|
||||
|
||||
interface DomainSectionProps {
|
||||
route: ProxyRouteItem;
|
||||
@@ -52,6 +46,19 @@ export function DomainSection({
|
||||
onRouteUpdate,
|
||||
onSavingChange,
|
||||
}: DomainSectionProps) {
|
||||
const t = useTranslations('proxyRoutes');
|
||||
const domainSettingsSchema = z.object({
|
||||
site_name: z
|
||||
.string()
|
||||
.trim()
|
||||
.min(1, t('validation.enterSiteId'))
|
||||
.max(255, t('validation.siteNameTooLong')),
|
||||
zone_domain_ids: z
|
||||
.array(z.number().int().positive())
|
||||
.min(1, t('validation.selectAtLeastOneDomain')),
|
||||
enabled: z.boolean(),
|
||||
redirect_http: z.boolean(),
|
||||
});
|
||||
const { saving, save } = useRouteSectionSave(
|
||||
route,
|
||||
onRouteUpdate,
|
||||
@@ -121,8 +128,8 @@ export function DomainSection({
|
||||
|
||||
return (
|
||||
<SectionShell
|
||||
title='域名设置'
|
||||
description='绑定已在 Zone 中注册的 FQDN。证书请在 Zone 域名管理中维护。'
|
||||
title={t('domainSettings')}
|
||||
description={t('domainSettingsDesc')}
|
||||
formId={proxyRouteFormIds.domains}
|
||||
saving={saving}
|
||||
>
|
||||
@@ -133,8 +140,7 @@ export function DomainSection({
|
||||
onSubmit={form.handleSubmit(async (values) => {
|
||||
if (values.redirect_http && !hasCertificate) {
|
||||
form.setError('redirect_http', {
|
||||
message:
|
||||
'启用 HTTP 跳转前,请先为所选域名绑定证书(在 Zone 中配置)',
|
||||
message: t('redirectNeedsCert'),
|
||||
});
|
||||
return;
|
||||
}
|
||||
@@ -147,7 +153,7 @@ export function DomainSection({
|
||||
enable_https: hasCertificate,
|
||||
redirect_http: hasCertificate ? values.redirect_http : false,
|
||||
},
|
||||
'域名设置已保存',
|
||||
t('domainSettingsSaved'),
|
||||
);
|
||||
})}
|
||||
>
|
||||
@@ -157,10 +163,8 @@ export function DomainSection({
|
||||
render={({ field }) => (
|
||||
<FormItem className='flex items-center justify-between rounded-lg border p-3'>
|
||||
<div className='space-y-0.5'>
|
||||
<FormLabel>启用站点</FormLabel>
|
||||
<FormDescription>
|
||||
关闭后会保留配置,但不会参与发布。
|
||||
</FormDescription>
|
||||
<FormLabel>{t('enableSite')}</FormLabel>
|
||||
<FormDescription>{t('enableSiteDesc')}</FormDescription>
|
||||
</div>
|
||||
<FormControl>
|
||||
<Switch
|
||||
@@ -177,13 +181,11 @@ export function DomainSection({
|
||||
name='site_name'
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>站点标识</FormLabel>
|
||||
<FormLabel>{t('siteId')}</FormLabel>
|
||||
<FormControl>
|
||||
<Input placeholder='marketing-site' {...field} />
|
||||
</FormControl>
|
||||
<FormDescription>
|
||||
建议使用稳定、可读的业务标识,不必与域名完全一致。
|
||||
</FormDescription>
|
||||
<FormDescription>{t('siteIdStableDesc')}</FormDescription>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
@@ -194,7 +196,7 @@ export function DomainSection({
|
||||
name='zone_domain_ids'
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>绑定域名</FormLabel>
|
||||
<FormLabel>{t('bindDomains')}</FormLabel>
|
||||
<FormControl>
|
||||
<ZoneDomainSelector
|
||||
value={field.value}
|
||||
@@ -208,10 +210,7 @@ export function DomainSection({
|
||||
}}
|
||||
/>
|
||||
</FormControl>
|
||||
<FormDescription>
|
||||
从已登记域名中勾选绑定;可用「快捷新增域名」在 Zone 下创建
|
||||
FQDN 并自动勾选。
|
||||
</FormDescription>
|
||||
<FormDescription>{t('bindDomainsHint')}</FormDescription>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
@@ -223,11 +222,11 @@ export function DomainSection({
|
||||
render={({ field }) => (
|
||||
<FormItem className='flex items-center justify-between rounded-lg border p-3'>
|
||||
<div className='space-y-0.5'>
|
||||
<FormLabel>HTTP 自动跳转到 HTTPS</FormLabel>
|
||||
<FormLabel>{t('redirectHttp')}</FormLabel>
|
||||
<FormDescription>
|
||||
{hasCertificate
|
||||
? '开启后会额外生成 80 端口重定向规则。'
|
||||
: '所选域名至少绑定一张证书后才能启用。'}
|
||||
? t('redirectHttpEnabledHint')
|
||||
: t('redirectHttpNeedsCert')}
|
||||
</FormDescription>
|
||||
</div>
|
||||
<FormControl>
|
||||
|
||||
@@ -17,6 +17,8 @@ import {
|
||||
import { Input } from '@/components/ui/input';
|
||||
import type { ProxyRouteItem } from '@/lib/services/openflare';
|
||||
|
||||
import { useTranslations } from 'next-intl';
|
||||
|
||||
import {
|
||||
normalizeLimitRate,
|
||||
normalizeLimitReqPerIP,
|
||||
@@ -27,51 +29,12 @@ import { proxyRouteFormIds } from '../helpers';
|
||||
import { useRouteSectionSave } from '../hooks/use-route-section-save';
|
||||
import { SectionShell } from './section-shell';
|
||||
|
||||
const rateLimitSchema = z
|
||||
.object({
|
||||
limit_conn_per_server: z.string(),
|
||||
limit_conn_per_ip: z.string(),
|
||||
limit_rate: z.string(),
|
||||
limit_req_per_ip: 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 (!/^-1$|^\d+$/.test(rawValue)) {
|
||||
context.addIssue({
|
||||
code: z.ZodIssueCode.custom,
|
||||
path: [field],
|
||||
message: '请输入 -1、0 或正整数',
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
const limitRateError = validateLimitRate(value.limit_rate);
|
||||
if (limitRateError) {
|
||||
context.addIssue({
|
||||
code: z.ZodIssueCode.custom,
|
||||
path: ['limit_rate'],
|
||||
message: limitRateError,
|
||||
});
|
||||
}
|
||||
|
||||
const limitReqError = validateLimitReqPerIP(value.limit_req_per_ip);
|
||||
if (limitReqError) {
|
||||
context.addIssue({
|
||||
code: z.ZodIssueCode.custom,
|
||||
path: ['limit_req_per_ip'],
|
||||
message: limitReqError,
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
type RateLimitValues = z.infer<typeof rateLimitSchema>;
|
||||
type RateLimitValues = {
|
||||
limit_conn_per_server: string;
|
||||
limit_conn_per_ip: string;
|
||||
limit_rate: string;
|
||||
limit_req_per_ip: string;
|
||||
};
|
||||
|
||||
function formatConnValue(value: number | null | undefined) {
|
||||
if (value === null || value === undefined || value === 0) {
|
||||
@@ -99,6 +62,50 @@ export function LimitsSection({
|
||||
onRouteUpdate,
|
||||
onSavingChange,
|
||||
}: LimitsSectionProps) {
|
||||
const t = useTranslations('proxyRoutes');
|
||||
const rateLimitSchema = z
|
||||
.object({
|
||||
limit_conn_per_server: z.string(),
|
||||
limit_conn_per_ip: z.string(),
|
||||
limit_rate: z.string(),
|
||||
limit_req_per_ip: 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 (!/^-1$|^\d+$/.test(rawValue)) {
|
||||
context.addIssue({
|
||||
code: z.ZodIssueCode.custom,
|
||||
path: [field],
|
||||
message: t('validation.enterIntegerOrMinusOne'),
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
const limitRateError = validateLimitRate(value.limit_rate, t);
|
||||
if (limitRateError) {
|
||||
context.addIssue({
|
||||
code: z.ZodIssueCode.custom,
|
||||
path: ['limit_rate'],
|
||||
message: limitRateError,
|
||||
});
|
||||
}
|
||||
|
||||
const limitReqError = validateLimitReqPerIP(value.limit_req_per_ip, t);
|
||||
if (limitReqError) {
|
||||
context.addIssue({
|
||||
code: z.ZodIssueCode.custom,
|
||||
path: ['limit_req_per_ip'],
|
||||
message: limitReqError,
|
||||
});
|
||||
}
|
||||
});
|
||||
const { saving, save } = useRouteSectionSave(
|
||||
route,
|
||||
onRouteUpdate,
|
||||
@@ -126,8 +133,8 @@ export function LimitsSection({
|
||||
|
||||
return (
|
||||
<SectionShell
|
||||
title='流量限制'
|
||||
description='站点限流。空或 0 继承全局默认;-1 显式关闭;大于 0 为自定义。'
|
||||
title={t('limits')}
|
||||
description={t('limitsDesc')}
|
||||
formId={proxyRouteFormIds.limits}
|
||||
saving={saving}
|
||||
>
|
||||
@@ -147,7 +154,7 @@ export function LimitsSection({
|
||||
values.limit_req_per_ip,
|
||||
),
|
||||
},
|
||||
'流量限制已保存',
|
||||
t('limitsSaved'),
|
||||
);
|
||||
})}
|
||||
>
|
||||
@@ -156,13 +163,11 @@ export function LimitsSection({
|
||||
name='limit_conn_per_server'
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>并发限制</FormLabel>
|
||||
<FormLabel>{t('connLimit')}</FormLabel>
|
||||
<FormControl>
|
||||
<Input placeholder='120' {...field} />
|
||||
</FormControl>
|
||||
<FormDescription>
|
||||
空或 0 继承全局默认;-1 关闭;大于 0 为自定义并发上限。
|
||||
</FormDescription>
|
||||
<FormDescription>{t('connLimitHint')}</FormDescription>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
@@ -173,13 +178,11 @@ export function LimitsSection({
|
||||
name='limit_conn_per_ip'
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>单 IP 限制</FormLabel>
|
||||
<FormLabel>{t('ipLimit')}</FormLabel>
|
||||
<FormControl>
|
||||
<Input placeholder='12' {...field} />
|
||||
</FormControl>
|
||||
<FormDescription>
|
||||
空或 0 继承全局默认;-1 关闭;大于 0 为单 IP 自定义上限。
|
||||
</FormDescription>
|
||||
<FormDescription>{t('ipLimitHint')}</FormDescription>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
@@ -190,13 +193,11 @@ export function LimitsSection({
|
||||
name='limit_rate'
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>限速</FormLabel>
|
||||
<FormLabel>{t('rateLimit')}</FormLabel>
|
||||
<FormControl>
|
||||
<Input placeholder='512k/1m 或 -1' {...field} />
|
||||
<Input placeholder={t('rateLimitPlaceholder')} {...field} />
|
||||
</FormControl>
|
||||
<FormDescription>
|
||||
空或 0 继承全局默认;-1 关闭;例如 512k、1m 为自定义带宽。
|
||||
</FormDescription>
|
||||
<FormDescription>{t('rateLimitHint')}</FormDescription>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
@@ -207,14 +208,11 @@ export function LimitsSection({
|
||||
name='limit_req_per_ip'
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>单 IP 请求频率</FormLabel>
|
||||
<FormLabel>{t('reqLimit')}</FormLabel>
|
||||
<FormControl>
|
||||
<Input placeholder='10r/s / 100r/m 或 -1' {...field} />
|
||||
<Input placeholder={t('reqLimitPlaceholder')} {...field} />
|
||||
</FormControl>
|
||||
<FormDescription>
|
||||
空或 0 继承全局默认;-1 关闭;例如 10r/s、100r/m
|
||||
为自定义频率。
|
||||
</FormDescription>
|
||||
<FormDescription>{t('reqLimitHint')}</FormDescription>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
|
||||
@@ -28,6 +28,8 @@ import { Textarea } from '@/components/ui/textarea';
|
||||
import type { ProxyRouteItem } from '@/lib/services/openflare';
|
||||
import { NodeService, PagesService } from '@/lib/services/openflare';
|
||||
|
||||
import { useTranslations } from 'next-intl';
|
||||
|
||||
import {
|
||||
customHeadersToText,
|
||||
parseCustomHeadersText,
|
||||
@@ -39,80 +41,16 @@ import { proxyRouteFormIds } from '../helpers';
|
||||
import { useRouteSectionSave } from '../hooks/use-route-section-save';
|
||||
import { SectionShell } from './section-shell';
|
||||
|
||||
const reverseProxySchema = z
|
||||
.object({
|
||||
upstream_type: z.enum(['direct', 'tunnel', 'pages']),
|
||||
origin_urls_text: z.string().trim(),
|
||||
origin_host: z.string(),
|
||||
tunnel_id: z.string().optional(),
|
||||
tunnel_target_addr: z.string().trim().optional(),
|
||||
tunnel_target_protocol: z.enum(['http', 'https']).optional(),
|
||||
pages_project_id: z.string().optional(),
|
||||
custom_headers_text: z.string(),
|
||||
})
|
||||
.superRefine((value, context) => {
|
||||
if (value.upstream_type === 'direct') {
|
||||
if (!value.origin_urls_text.trim()) {
|
||||
context.addIssue({
|
||||
code: z.ZodIssueCode.custom,
|
||||
path: ['origin_urls_text'],
|
||||
message: '请至少填写一个上游地址',
|
||||
});
|
||||
} else {
|
||||
const { error } = parseOriginUrls(value.origin_urls_text);
|
||||
if (error) {
|
||||
context.addIssue({
|
||||
code: z.ZodIssueCode.custom,
|
||||
path: ['origin_urls_text'],
|
||||
message: error,
|
||||
});
|
||||
}
|
||||
}
|
||||
} else if (value.upstream_type === 'tunnel') {
|
||||
if (!value.tunnel_id) {
|
||||
context.addIssue({
|
||||
code: z.ZodIssueCode.custom,
|
||||
path: ['tunnel_id'],
|
||||
message: '请选择内网穿透隧道',
|
||||
});
|
||||
}
|
||||
if (!value.tunnel_target_addr) {
|
||||
context.addIssue({
|
||||
code: z.ZodIssueCode.custom,
|
||||
path: ['tunnel_target_addr'],
|
||||
message: '请填写内网服务地址 (如 127.0.0.1:8080)',
|
||||
});
|
||||
}
|
||||
} else if (!value.pages_project_id) {
|
||||
context.addIssue({
|
||||
code: z.ZodIssueCode.custom,
|
||||
path: ['pages_project_id'],
|
||||
message: '请选择 Pages 项目',
|
||||
});
|
||||
}
|
||||
|
||||
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,
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
type ReverseProxyValues = z.infer<typeof reverseProxySchema>;
|
||||
type ReverseProxyValues = {
|
||||
upstream_type: 'direct' | 'tunnel' | 'pages';
|
||||
origin_urls_text: string;
|
||||
origin_host: string;
|
||||
tunnel_id?: string;
|
||||
tunnel_target_addr?: string;
|
||||
tunnel_target_protocol?: 'http' | 'https';
|
||||
pages_project_id?: string;
|
||||
custom_headers_text: string;
|
||||
};
|
||||
|
||||
interface ProxySectionProps {
|
||||
route: ProxyRouteItem;
|
||||
@@ -125,6 +63,80 @@ export function ProxySection({
|
||||
onRouteUpdate,
|
||||
onSavingChange,
|
||||
}: ProxySectionProps) {
|
||||
const t = useTranslations('proxyRoutes');
|
||||
const reverseProxySchema = z
|
||||
.object({
|
||||
upstream_type: z.enum(['direct', 'tunnel', 'pages']),
|
||||
origin_urls_text: z.string().trim(),
|
||||
origin_host: z.string(),
|
||||
tunnel_id: z.string().optional(),
|
||||
tunnel_target_addr: z.string().trim().optional(),
|
||||
tunnel_target_protocol: z.enum(['http', 'https']).optional(),
|
||||
pages_project_id: z.string().optional(),
|
||||
custom_headers_text: z.string(),
|
||||
})
|
||||
.superRefine((value, context) => {
|
||||
if (value.upstream_type === 'direct') {
|
||||
if (!value.origin_urls_text.trim()) {
|
||||
context.addIssue({
|
||||
code: z.ZodIssueCode.custom,
|
||||
path: ['origin_urls_text'],
|
||||
message: t('validation.enterAtLeastOneUpstream'),
|
||||
});
|
||||
} else {
|
||||
const { error } = parseOriginUrls(value.origin_urls_text, t);
|
||||
if (error) {
|
||||
context.addIssue({
|
||||
code: z.ZodIssueCode.custom,
|
||||
path: ['origin_urls_text'],
|
||||
message: error,
|
||||
});
|
||||
}
|
||||
}
|
||||
} else if (value.upstream_type === 'tunnel') {
|
||||
if (!value.tunnel_id) {
|
||||
context.addIssue({
|
||||
code: z.ZodIssueCode.custom,
|
||||
path: ['tunnel_id'],
|
||||
message: t('validation.selectTunnel'),
|
||||
});
|
||||
}
|
||||
if (!value.tunnel_target_addr) {
|
||||
context.addIssue({
|
||||
code: z.ZodIssueCode.custom,
|
||||
path: ['tunnel_target_addr'],
|
||||
message: t('validation.enterTunnelTarget'),
|
||||
});
|
||||
}
|
||||
} else if (!value.pages_project_id) {
|
||||
context.addIssue({
|
||||
code: z.ZodIssueCode.custom,
|
||||
path: ['pages_project_id'],
|
||||
message: t('validation.selectPagesProject'),
|
||||
});
|
||||
}
|
||||
|
||||
const originHostError = validateOriginHost(value.origin_host, t);
|
||||
if (originHostError) {
|
||||
context.addIssue({
|
||||
code: z.ZodIssueCode.custom,
|
||||
path: ['origin_host'],
|
||||
message: originHostError,
|
||||
});
|
||||
}
|
||||
|
||||
const { error: headerError } = parseCustomHeadersText(
|
||||
value.custom_headers_text,
|
||||
t,
|
||||
);
|
||||
if (headerError) {
|
||||
context.addIssue({
|
||||
code: z.ZodIssueCode.custom,
|
||||
path: ['custom_headers_text'],
|
||||
message: headerError,
|
||||
});
|
||||
}
|
||||
});
|
||||
const { saving, save } = useRouteSectionSave(
|
||||
route,
|
||||
onRouteUpdate,
|
||||
@@ -185,8 +197,8 @@ export function ProxySection({
|
||||
|
||||
return (
|
||||
<SectionShell
|
||||
title='反向代理'
|
||||
description='配置请求回源上游的策略与地址。'
|
||||
title={t('reverseProxy')}
|
||||
description={t('reverseProxyDesc')}
|
||||
formId={proxyRouteFormIds.proxy}
|
||||
saving={saving}
|
||||
>
|
||||
@@ -203,7 +215,7 @@ export function ProxySection({
|
||||
let upstreams: string[] = [];
|
||||
|
||||
if (values.upstream_type === 'direct') {
|
||||
const { urls } = parseOriginUrls(values.origin_urls_text);
|
||||
const { urls } = parseOriginUrls(values.origin_urls_text, t);
|
||||
const primaryOrigin = parseOriginUrl(urls[0]);
|
||||
originUrl = urls[0];
|
||||
originScheme = primaryOrigin.scheme;
|
||||
@@ -224,6 +236,7 @@ export function ProxySection({
|
||||
|
||||
const { headers } = parseCustomHeadersText(
|
||||
values.custom_headers_text,
|
||||
t,
|
||||
);
|
||||
|
||||
await save(
|
||||
@@ -255,7 +268,7 @@ export function ProxySection({
|
||||
? Number(values.pages_project_id)
|
||||
: null,
|
||||
},
|
||||
'反向代理设置已保存',
|
||||
t('proxySaved'),
|
||||
);
|
||||
})}
|
||||
>
|
||||
@@ -264,13 +277,13 @@ export function ProxySection({
|
||||
name='upstream_type'
|
||||
render={({ field }) => (
|
||||
<FormItem className='space-y-3'>
|
||||
<FormLabel>回源方式</FormLabel>
|
||||
<FormLabel>{t('upstreamType')}</FormLabel>
|
||||
<div className='flex flex-wrap gap-4'>
|
||||
{(
|
||||
[
|
||||
['direct', '直连上游'],
|
||||
['tunnel', '内网穿透 (Tunnel)'],
|
||||
['pages', 'Pages 静态站点'],
|
||||
['direct', t('upstreamDirect')],
|
||||
['tunnel', t('upstreamTunnel')],
|
||||
['pages', t('upstreamPages')],
|
||||
] as const
|
||||
).map(([value, label]) => (
|
||||
<label
|
||||
@@ -299,7 +312,7 @@ export function ProxySection({
|
||||
name='origin_urls_text'
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>上游地址</FormLabel>
|
||||
<FormLabel>{t('upstreamAddresses')}</FormLabel>
|
||||
<FormControl>
|
||||
<Textarea
|
||||
className='min-h-40 font-mono text-xs'
|
||||
@@ -309,11 +322,7 @@ export function ProxySection({
|
||||
{...field}
|
||||
/>
|
||||
</FormControl>
|
||||
<FormDescription>
|
||||
每行一个完整
|
||||
URL。第一行作为主回源,多上游模式请保持相同协议且不要包含
|
||||
path 或 query。
|
||||
</FormDescription>
|
||||
<FormDescription>{t('upstreamUrlsHint')}</FormDescription>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
@@ -327,7 +336,7 @@ export function ProxySection({
|
||||
name='tunnel_id'
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>选择内网穿透隧道</FormLabel>
|
||||
<FormLabel>{t('selectTunnel')}</FormLabel>
|
||||
<Select
|
||||
value={field.value || 'none'}
|
||||
onValueChange={(value) =>
|
||||
@@ -336,22 +345,23 @@ export function ProxySection({
|
||||
>
|
||||
<FormControl>
|
||||
<SelectTrigger>
|
||||
<SelectValue placeholder='请选择...' />
|
||||
<SelectValue placeholder={t('pleaseSelect')} />
|
||||
</SelectTrigger>
|
||||
</FormControl>
|
||||
<SelectContent>
|
||||
<SelectItem value='none'>请选择...</SelectItem>
|
||||
<SelectItem value='none'>{t('pleaseSelect')}</SelectItem>
|
||||
{tunnelClients.map((tunnel) => (
|
||||
<SelectItem key={tunnel.id} value={String(tunnel.id)}>
|
||||
{tunnel.name} (
|
||||
{tunnel.status === 'online' ? '在线' : '离线'})
|
||||
{tunnel.status === 'online'
|
||||
? t('online')
|
||||
: t('offline')}
|
||||
)
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
<FormDescription>
|
||||
将请求转发到该隧道连接的客户端节点。
|
||||
</FormDescription>
|
||||
<FormDescription>{t('tunnelForwardHint')}</FormDescription>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
@@ -362,7 +372,7 @@ export function ProxySection({
|
||||
name='tunnel_target_protocol'
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>内网服务协议</FormLabel>
|
||||
<FormLabel>{t('tunnelProtocol')}</FormLabel>
|
||||
<Select value={field.value} onValueChange={field.onChange}>
|
||||
<FormControl>
|
||||
<SelectTrigger>
|
||||
@@ -384,13 +394,11 @@ export function ProxySection({
|
||||
name='tunnel_target_addr'
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>内网服务地址</FormLabel>
|
||||
<FormLabel>{t('tunnelAddress')}</FormLabel>
|
||||
<FormControl>
|
||||
<Input placeholder='127.0.0.1:8080' {...field} />
|
||||
</FormControl>
|
||||
<FormDescription>
|
||||
例如: 127.0.0.1:8080 或 192.168.1.10:80
|
||||
</FormDescription>
|
||||
<FormDescription>{t('tunnelAddressHint')}</FormDescription>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
@@ -405,7 +413,7 @@ export function ProxySection({
|
||||
name='pages_project_id'
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>选择 Pages 项目</FormLabel>
|
||||
<FormLabel>{t('selectPagesProject')}</FormLabel>
|
||||
<Select
|
||||
value={field.value || 'none'}
|
||||
onValueChange={(value) =>
|
||||
@@ -414,11 +422,11 @@ export function ProxySection({
|
||||
>
|
||||
<FormControl>
|
||||
<SelectTrigger>
|
||||
<SelectValue placeholder='请选择...' />
|
||||
<SelectValue placeholder={t('pleaseSelect')} />
|
||||
</SelectTrigger>
|
||||
</FormControl>
|
||||
<SelectContent>
|
||||
<SelectItem value='none'>请选择...</SelectItem>
|
||||
<SelectItem value='none'>{t('pleaseSelect')}</SelectItem>
|
||||
{pagesProjects.map((project) => (
|
||||
<SelectItem
|
||||
key={project.id}
|
||||
@@ -429,9 +437,7 @@ export function ProxySection({
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
<FormDescription>
|
||||
仅显示已启用且已有激活部署的 Pages 项目。
|
||||
</FormDescription>
|
||||
<FormDescription>{t('pagesProjectHint')}</FormDescription>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
@@ -449,7 +455,7 @@ export function ProxySection({
|
||||
<Input placeholder='origin.example.internal' {...field} />
|
||||
</FormControl>
|
||||
<FormDescription>
|
||||
留空时默认透传访问域名 $host。
|
||||
{t('originHostHint')}
|
||||
</FormDescription>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
@@ -461,7 +467,7 @@ export function ProxySection({
|
||||
name='custom_headers_text'
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>自定义请求头</FormLabel>
|
||||
<FormLabel>{t('customHeaders')}</FormLabel>
|
||||
<FormControl>
|
||||
<Textarea
|
||||
className='min-h-32 font-mono text-xs'
|
||||
@@ -469,7 +475,7 @@ export function ProxySection({
|
||||
{...field}
|
||||
/>
|
||||
</FormControl>
|
||||
<FormDescription>每行一条,格式为 Key: Value。</FormDescription>
|
||||
<FormDescription>{t('customHeadersHint')}</FormDescription>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
|
||||
@@ -22,6 +22,8 @@ import type {
|
||||
} from '@/lib/services/openflare';
|
||||
import { ConfigVersionService } from '@/lib/services/openflare';
|
||||
|
||||
import { useTranslations } from 'next-intl';
|
||||
|
||||
import { getErrorMessage } from '../../components/helpers';
|
||||
|
||||
function hasConfigDiff(diff: ConfigDiffResult) {
|
||||
@@ -44,6 +46,8 @@ interface RouteHeaderProps {
|
||||
}
|
||||
|
||||
export function RouteHeader({ route }: RouteHeaderProps) {
|
||||
const t = useTranslations('proxyRoutes');
|
||||
const tc = useTranslations('common');
|
||||
const router = useRouter();
|
||||
const [publishConfirmOpen, setPublishConfirmOpen] = useState(false);
|
||||
const [diff, setDiff] = useState<ConfigDiffResult | null>(null);
|
||||
@@ -57,13 +61,13 @@ export function RouteHeader({ route }: RouteHeaderProps) {
|
||||
setDiff(diffData);
|
||||
return diffData;
|
||||
} catch (error) {
|
||||
const message = getErrorMessage(error);
|
||||
toast.error('获取配置差异失败', { description: message });
|
||||
const message = getErrorMessage(error, t('requestFailed'));
|
||||
toast.error(t('diffFailed'), { description: message });
|
||||
return null;
|
||||
} finally {
|
||||
setDiffLoading(false);
|
||||
}
|
||||
}, []);
|
||||
}, [t]);
|
||||
|
||||
const handlePublishClick = useCallback(async () => {
|
||||
const diffData = diff ?? (await loadDiff());
|
||||
@@ -71,25 +75,29 @@ export function RouteHeader({ route }: RouteHeaderProps) {
|
||||
return;
|
||||
}
|
||||
if (!hasConfigDiff(diffData)) {
|
||||
toast.info('当前配置与激活版本一致,无需发布');
|
||||
toast.info(t('noPublishNeeded'));
|
||||
return;
|
||||
}
|
||||
setPublishConfirmOpen(true);
|
||||
}, [diff, loadDiff]);
|
||||
}, [diff, loadDiff, t]);
|
||||
|
||||
const handlePublish = useCallback(async () => {
|
||||
setPublishing(true);
|
||||
try {
|
||||
const version = await ConfigVersionService.publish();
|
||||
toast.success('发布成功', { description: `版本 ${version.version}` });
|
||||
toast.success(t('publishSuccess'), {
|
||||
description: t('versionLabel', { version: version.version }),
|
||||
});
|
||||
setPublishConfirmOpen(false);
|
||||
setDiff(null);
|
||||
} catch (error) {
|
||||
toast.error('发布失败', { description: getErrorMessage(error) });
|
||||
toast.error(t('publishFailed'), {
|
||||
description: getErrorMessage(error, t('requestFailed')),
|
||||
});
|
||||
} finally {
|
||||
setPublishing(false);
|
||||
}
|
||||
}, []);
|
||||
}, [t]);
|
||||
|
||||
return (
|
||||
<>
|
||||
@@ -101,7 +109,7 @@ export function RouteHeader({ route }: RouteHeaderProps) {
|
||||
onClick={() => router.back()}
|
||||
>
|
||||
<ArrowLeft className='size-3.5' />
|
||||
返回
|
||||
{t('back')}
|
||||
</Button>
|
||||
|
||||
<div className='flex flex-col gap-4 sm:flex-row sm:items-center sm:justify-between'>
|
||||
@@ -112,7 +120,7 @@ export function RouteHeader({ route }: RouteHeaderProps) {
|
||||
{route.site_name}
|
||||
</h1>
|
||||
<Badge variant={route.enabled ? 'default' : 'secondary'}>
|
||||
{route.enabled ? '已启用' : '已停用'}
|
||||
{route.enabled ? t('enabled') : t('disabled')}
|
||||
</Badge>
|
||||
</div>
|
||||
</div>
|
||||
@@ -129,7 +137,7 @@ export function RouteHeader({ route }: RouteHeaderProps) {
|
||||
) : (
|
||||
<Upload className='size-3.5' />
|
||||
)}
|
||||
发布配置
|
||||
{t('publish')}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
@@ -141,18 +149,16 @@ export function RouteHeader({ route }: RouteHeaderProps) {
|
||||
>
|
||||
<AlertDialogContent>
|
||||
<AlertDialogHeader>
|
||||
<AlertDialogTitle>确认发布配置</AlertDialogTitle>
|
||||
<AlertDialogDescription>
|
||||
将把当前待发布配置生成新版本并设为激活版本,节点将随后拉取更新。
|
||||
</AlertDialogDescription>
|
||||
<AlertDialogTitle>{t('publishTitle')}</AlertDialogTitle>
|
||||
<AlertDialogDescription>{t('publishDesc')}</AlertDialogDescription>
|
||||
</AlertDialogHeader>
|
||||
<AlertDialogFooter>
|
||||
<AlertDialogCancel disabled={publishing}>取消</AlertDialogCancel>
|
||||
<AlertDialogCancel disabled={publishing}>{tc('cancel')}</AlertDialogCancel>
|
||||
<Button onClick={() => void handlePublish()} disabled={publishing}>
|
||||
{publishing ? (
|
||||
<Loader2 className='size-4 animate-spin' />
|
||||
) : (
|
||||
'确认发布'
|
||||
t('confirmPublish')
|
||||
)}
|
||||
</Button>
|
||||
</AlertDialogFooter>
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
'use client';
|
||||
|
||||
import type { ReactNode } from 'react';
|
||||
import { useTranslations } from 'next-intl';
|
||||
|
||||
import { Button } from '@/components/ui/button';
|
||||
import {
|
||||
@@ -28,6 +29,8 @@ export function SectionShell({
|
||||
saving = false,
|
||||
children,
|
||||
}: SectionShellProps) {
|
||||
const t = useTranslations('proxyRoutes');
|
||||
const tc = useTranslations('common');
|
||||
return (
|
||||
<Card>
|
||||
<CardHeader className='flex flex-row items-start justify-between gap-4 space-y-0'>
|
||||
@@ -45,7 +48,7 @@ export function SectionShell({
|
||||
className='h-8 shrink-0 text-xs'
|
||||
disabled={saving}
|
||||
>
|
||||
{saving ? '保存中...' : '保存'}
|
||||
{saving ? t('saving') : tc('save')}
|
||||
</Button>
|
||||
</CardHeader>
|
||||
<CardContent>{children}</CardContent>
|
||||
|
||||
@@ -39,6 +39,8 @@ import { LoadingStateWithBorder } from '@/components/layout/loading';
|
||||
import type { ProxyRouteItem } from '@/lib/services/openflare';
|
||||
import { WafService } from '@/lib/services/openflare';
|
||||
|
||||
import { useTranslations } from 'next-intl';
|
||||
|
||||
import { getErrorMessage } from '../../components/helpers';
|
||||
import { proxyRouteFormIds } from '../helpers';
|
||||
import { SectionShell } from './section-shell';
|
||||
@@ -67,6 +69,7 @@ export function reorderRuleIDs(
|
||||
}
|
||||
|
||||
function SortableRule({ id, name, index, total, onMove }: SortableRuleProps) {
|
||||
const t = useTranslations('proxyRoutes');
|
||||
const { attributes, listeners, setNodeRef, transform, transition } =
|
||||
useSortable({ id });
|
||||
return (
|
||||
@@ -79,7 +82,7 @@ function SortableRule({ id, name, index, total, onMove }: SortableRuleProps) {
|
||||
type='button'
|
||||
variant='ghost'
|
||||
size='icon-sm'
|
||||
aria-label={`拖动${name}`}
|
||||
aria-label={t('dragRule', { name })}
|
||||
{...attributes}
|
||||
{...listeners}
|
||||
>
|
||||
@@ -92,7 +95,7 @@ function SortableRule({ id, name, index, total, onMove }: SortableRuleProps) {
|
||||
type='button'
|
||||
variant='ghost'
|
||||
size='icon-sm'
|
||||
aria-label={`上移${name}`}
|
||||
aria-label={t('moveUpRule', { name })}
|
||||
disabled={index === 0}
|
||||
onClick={() => onMove(index, index - 1)}
|
||||
>
|
||||
@@ -102,7 +105,7 @@ function SortableRule({ id, name, index, total, onMove }: SortableRuleProps) {
|
||||
type='button'
|
||||
variant='ghost'
|
||||
size='icon-sm'
|
||||
aria-label={`下移${name}`}
|
||||
aria-label={t('moveDownRule', { name })}
|
||||
disabled={index === total - 1}
|
||||
onClick={() => onMove(index, index + 1)}
|
||||
>
|
||||
@@ -113,6 +116,7 @@ function SortableRule({ id, name, index, total, onMove }: SortableRuleProps) {
|
||||
}
|
||||
|
||||
export function WafSection({ route, onSavingChange }: WafSectionProps) {
|
||||
const t = useTranslations('proxyRoutes');
|
||||
const queryClient = useQueryClient();
|
||||
const [selectedIDs, setSelectedIDs] = useState<number[]>([]);
|
||||
const sensors = useSensors(
|
||||
@@ -138,7 +142,7 @@ export function WafSection({ route, onSavingChange }: WafSectionProps) {
|
||||
},
|
||||
onSuccess: async (result) => {
|
||||
setSelectedIDs(result.applied_ids);
|
||||
toast.success('WAF 规则组已更新');
|
||||
toast.success(t('wafUpdated'));
|
||||
await Promise.all([
|
||||
queryClient.invalidateQueries({
|
||||
queryKey: ['openflare', 'waf', 'site-rule-groups', route.id],
|
||||
@@ -152,7 +156,9 @@ export function WafSection({ route, onSavingChange }: WafSectionProps) {
|
||||
]);
|
||||
},
|
||||
onError: (error) => {
|
||||
toast.error('保存失败', { description: getErrorMessage(error) });
|
||||
toast.error(t('saveFailed'), {
|
||||
description: getErrorMessage(error, t('requestFailed')),
|
||||
});
|
||||
},
|
||||
});
|
||||
|
||||
@@ -186,15 +192,15 @@ export function WafSection({ route, onSavingChange }: WafSectionProps) {
|
||||
return (
|
||||
<SectionShell
|
||||
title='WAF'
|
||||
description='全局规则组始终生效;这里可以为当前网站叠加自定义规则组。'
|
||||
description={t('wafDesc')}
|
||||
formId={proxyRouteFormIds.waf}
|
||||
saving={wafMutation.isPending}
|
||||
>
|
||||
{wafQuery.isLoading ? (
|
||||
<LoadingStateWithBorder description='加载 WAF 规则组...' />
|
||||
<LoadingStateWithBorder description={t('loadingWaf')} />
|
||||
) : wafQuery.isError ? (
|
||||
<ErrorInline
|
||||
message={getErrorMessage(wafQuery.error)}
|
||||
message={getErrorMessage(wafQuery.error, t('requestFailed'))}
|
||||
onRetry={() => void wafQuery.refetch()}
|
||||
/>
|
||||
) : (
|
||||
@@ -220,16 +226,14 @@ export function WafSection({ route, onSavingChange }: WafSectionProps) {
|
||||
{wafQuery.data.global_rule_group.name}
|
||||
</p>
|
||||
</div>
|
||||
<Badge variant='outline'>始终生效</Badge>
|
||||
<Badge variant='outline'>{t('alwaysOn')}</Badge>
|
||||
</div>
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
<FieldSet>
|
||||
<FieldLegend variant='label'>选择自定义规则</FieldLegend>
|
||||
<FieldDescription>
|
||||
选中的规则会按下方执行顺序依次运行。
|
||||
</FieldDescription>
|
||||
<FieldLegend variant='label'>{t('selectCustomRules')}</FieldLegend>
|
||||
<FieldDescription>{t('selectCustomRulesDesc')}</FieldDescription>
|
||||
<FieldGroup
|
||||
data-slot='checkbox-group'
|
||||
className='grid gap-3 md:grid-cols-2'
|
||||
@@ -244,7 +248,7 @@ export function WafSection({ route, onSavingChange }: WafSectionProps) {
|
||||
>
|
||||
<Checkbox
|
||||
id={checkboxID}
|
||||
aria-label={`选择${group.name}`}
|
||||
aria-label={t('selectRule', { name: group.name })}
|
||||
checked={selectedSet.has(group.id)}
|
||||
onCheckedChange={(checked) => {
|
||||
setSelectedIDs((current) =>
|
||||
@@ -262,8 +266,8 @@ export function WafSection({ route, onSavingChange }: WafSectionProps) {
|
||||
{group.name}
|
||||
</span>
|
||||
<span className='text-xs text-muted-foreground'>
|
||||
{group.enabled ? '启用中' : '已停用'} ·{' '}
|
||||
{group.graph.nodes.length} 个节点
|
||||
{group.enabled ? t('ruleEnabled') : t('ruleDisabled')} ·{' '}
|
||||
{t('nodeCount', { count: group.graph.nodes.length })}
|
||||
</span>
|
||||
</FieldLabel>
|
||||
</Field>
|
||||
@@ -274,9 +278,9 @@ export function WafSection({ route, onSavingChange }: WafSectionProps) {
|
||||
|
||||
{selectedIDs.length > 0 ? (
|
||||
<div className='flex flex-col gap-2'>
|
||||
<p className='text-sm font-medium'>执行顺序</p>
|
||||
<p className='text-sm font-medium'>{t('executionOrder')}</p>
|
||||
<p className='text-xs text-muted-foreground'>
|
||||
自定义规则按此顺序执行,可拖动或使用上下移动按钮调整。
|
||||
{t('executionOrderDesc')}
|
||||
</p>
|
||||
<DndContext
|
||||
sensors={sensors}
|
||||
@@ -308,7 +312,7 @@ export function WafSection({ route, onSavingChange }: WafSectionProps) {
|
||||
) : null}
|
||||
|
||||
{(wafQuery.data?.rule_groups ?? []).length === 0 ? (
|
||||
<EmptyStateWithBorder description='暂无自定义 WAF 规则组' />
|
||||
<EmptyStateWithBorder description={t('noCustomWaf')} />
|
||||
) : null}
|
||||
</form>
|
||||
)}
|
||||
|
||||
@@ -3,6 +3,8 @@
|
||||
import { useCallback, useState } from 'react';
|
||||
import { toast } from 'sonner';
|
||||
|
||||
import { useTranslations } from 'next-intl';
|
||||
|
||||
import type {
|
||||
ProxyRouteItem,
|
||||
ProxyRouteMutationPayload,
|
||||
@@ -19,6 +21,7 @@ export function useRouteSectionSave(
|
||||
onRouteUpdate: (route: ProxyRouteItem) => void,
|
||||
onSavingChange?: (saving: boolean) => void,
|
||||
) {
|
||||
const t = useTranslations('proxyRoutes');
|
||||
const [saving, setSaving] = useState(false);
|
||||
|
||||
const save = useCallback(
|
||||
@@ -33,13 +36,15 @@ export function useRouteSectionSave(
|
||||
onRouteUpdate(updated);
|
||||
toast.success(message);
|
||||
} catch (error) {
|
||||
toast.error('保存失败', { description: getErrorMessage(error) });
|
||||
toast.error(t('saveFailed'), {
|
||||
description: getErrorMessage(error, t('requestFailed')),
|
||||
});
|
||||
} finally {
|
||||
setSaving(false);
|
||||
onSavingChange?.(false);
|
||||
}
|
||||
},
|
||||
[onRouteUpdate, onSavingChange, route],
|
||||
[onRouteUpdate, onSavingChange, route, t],
|
||||
);
|
||||
|
||||
return { saving, save };
|
||||
|
||||
@@ -13,9 +13,11 @@ import type {
|
||||
} from '@/lib/services/openflare';
|
||||
import { ProxyRouteService } from '@/lib/services/openflare';
|
||||
|
||||
import { useTranslations } from 'next-intl';
|
||||
|
||||
import {
|
||||
getProxyRouteConfigSection,
|
||||
proxyRouteConfigSections,
|
||||
getProxyRouteConfigSections,
|
||||
} from '../components/helpers';
|
||||
import { AuthSection } from './components/auth-section';
|
||||
import { CacheSection } from './components/cache-section';
|
||||
@@ -26,6 +28,7 @@ import { RouteHeader } from './components/route-header';
|
||||
import { WafSection } from './components/waf-section';
|
||||
|
||||
export function ProxyRouteDetailPageClient() {
|
||||
const t = useTranslations('proxyRoutes');
|
||||
const router = useRouter();
|
||||
const searchParams = useSearchParams();
|
||||
const routeId = Number(searchParams.get('id'));
|
||||
@@ -65,11 +68,11 @@ export function ProxyRouteDetailPageClient() {
|
||||
setRoute(data);
|
||||
} catch (error) {
|
||||
setRoute(null);
|
||||
setLoadError(error instanceof Error ? error.message : '未知错误');
|
||||
setLoadError(error instanceof Error ? error.message : t('unknownError'));
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}, [hasValidId, routeId]);
|
||||
}, [hasValidId, routeId, t]);
|
||||
|
||||
useEffect(() => {
|
||||
void fetchRoute();
|
||||
@@ -78,7 +81,7 @@ export function ProxyRouteDetailPageClient() {
|
||||
if (!hasValidId) {
|
||||
return (
|
||||
<div className='py-6 px-1'>
|
||||
<ErrorInline message='缺少有效的规则 ID。' />
|
||||
<ErrorInline message={t('missingId')} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -105,8 +108,8 @@ export function ProxyRouteDetailPageClient() {
|
||||
return (
|
||||
<div className='py-6 px-1'>
|
||||
<EmptyStateWithBorder
|
||||
title='未找到对应规则'
|
||||
description='请返回列表重试。'
|
||||
title={t('notFound')}
|
||||
description={t('notFoundDesc')}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
@@ -129,7 +132,7 @@ export function ProxyRouteDetailPageClient() {
|
||||
className='w-full'
|
||||
>
|
||||
<TabsList variant='line' className='w-fit inline-flex gap-8 mb-6'>
|
||||
{proxyRouteConfigSections.map((section) => (
|
||||
{getProxyRouteConfigSections(t).map((section) => (
|
||||
<TabsTrigger
|
||||
key={section.key}
|
||||
value={section.key}
|
||||
|
||||
@@ -32,6 +32,8 @@ import {
|
||||
import type { ProxyRouteItem } from '@/lib/services/openflare';
|
||||
import { ProxyRouteService } from '@/lib/services/openflare';
|
||||
|
||||
import { useTranslations } from 'next-intl';
|
||||
|
||||
import {
|
||||
getRouteDomainNames,
|
||||
getRouteDomainsLabel,
|
||||
@@ -41,6 +43,8 @@ import {
|
||||
import { ProxyRouteCreateSheet } from './components/proxy-route-create-sheet';
|
||||
|
||||
export function ProxyRoutesPageClient() {
|
||||
const t = useTranslations('proxyRoutes');
|
||||
const tc = useTranslations('common');
|
||||
const router = useRouter();
|
||||
const [routes, setRoutes] = useState<ProxyRouteItem[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
@@ -55,13 +59,13 @@ export function ProxyRoutesPageClient() {
|
||||
const data = await ProxyRouteService.list();
|
||||
setRoutes(data);
|
||||
} catch (error) {
|
||||
toast.error('规则列表加载失败', {
|
||||
description: error instanceof Error ? error.message : '未知错误',
|
||||
toast.error(t('loadListFailed'), {
|
||||
description: error instanceof Error ? error.message : t('unknownError'),
|
||||
});
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}, []);
|
||||
}, [t]);
|
||||
|
||||
useEffect(() => {
|
||||
void fetchRoutes();
|
||||
@@ -95,12 +99,12 @@ export function ProxyRoutesPageClient() {
|
||||
setDeleting(true);
|
||||
try {
|
||||
await ProxyRouteService.deleteById(deleteTarget.id);
|
||||
toast.success('网站已删除');
|
||||
toast.success(t('siteDeleted'));
|
||||
setDeleteTarget(null);
|
||||
await fetchRoutes();
|
||||
} catch (error) {
|
||||
toast.error('删除失败', {
|
||||
description: error instanceof Error ? error.message : '未知错误',
|
||||
toast.error(t('deleteFailed'), {
|
||||
description: error instanceof Error ? error.message : t('unknownError'),
|
||||
});
|
||||
} finally {
|
||||
setDeleting(false);
|
||||
@@ -112,7 +116,7 @@ export function ProxyRoutesPageClient() {
|
||||
<div className='flex flex-col gap-4 sm:flex-row sm:items-center sm:justify-between'>
|
||||
<div className='flex items-center gap-2'>
|
||||
<Route className='size-5 text-primary' />
|
||||
<h1 className='text-2xl font-semibold tracking-tight'>规则配置</h1>
|
||||
<h1 className='text-2xl font-semibold tracking-tight'>{t('title')}</h1>
|
||||
</div>
|
||||
<div className='flex flex-wrap gap-2'>
|
||||
<Button
|
||||
@@ -123,7 +127,7 @@ export function ProxyRoutesPageClient() {
|
||||
disabled={loading}
|
||||
>
|
||||
<RefreshCw className={`size-3 ${loading ? 'animate-spin' : ''}`} />
|
||||
刷新
|
||||
{t('refresh')}
|
||||
</Button>
|
||||
<Button
|
||||
size='sm'
|
||||
@@ -131,7 +135,7 @@ export function ProxyRoutesPageClient() {
|
||||
onClick={() => setIsCreateOpen(true)}
|
||||
>
|
||||
<Plus className='size-3.5' />
|
||||
新建规则
|
||||
{t('createRule')}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
@@ -139,11 +143,11 @@ export function ProxyRoutesPageClient() {
|
||||
<Card className='border-border/40 shadow-sm'>
|
||||
<CardHeader className='pb-3'>
|
||||
<div className='flex flex-col gap-3 sm:flex-row sm:items-center sm:justify-between'>
|
||||
<CardTitle className='text-sm font-semibold'>规则列表</CardTitle>
|
||||
<CardTitle className='text-sm font-semibold'>{t('listTitle')}</CardTitle>
|
||||
<Input
|
||||
value={keyword}
|
||||
onChange={(event) => setKeyword(event.target.value)}
|
||||
placeholder='搜索站点、域名或上游...'
|
||||
placeholder={t('searchPlaceholder')}
|
||||
className='h-8 max-w-sm text-xs'
|
||||
/>
|
||||
</div>
|
||||
@@ -158,23 +162,21 @@ export function ProxyRoutesPageClient() {
|
||||
) : filteredRoutes.length === 0 ? (
|
||||
<div className='flex flex-col items-center justify-center gap-2 py-12 text-center'>
|
||||
<p className='text-sm font-medium'>
|
||||
{routes.length === 0 ? '暂无网站' : '没有匹配结果'}
|
||||
{routes.length === 0 ? t('emptyTitle') : t('noMatches')}
|
||||
</p>
|
||||
<p className='text-xs text-muted-foreground'>
|
||||
{routes.length === 0
|
||||
? '先创建一个网站,再进入配置子页面继续补齐 HTTPS、缓存和限流。'
|
||||
: '试试调整搜索词,或者清空筛选条件。'}
|
||||
{routes.length === 0 ? t('emptyDesc') : t('noMatchesDesc')}
|
||||
</p>
|
||||
</div>
|
||||
) : (
|
||||
<Table>
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
<TableHead>站点名称</TableHead>
|
||||
<TableHead>域名</TableHead>
|
||||
<TableHead>状态</TableHead>
|
||||
<TableHead>上游</TableHead>
|
||||
<TableHead className='text-right'>操作</TableHead>
|
||||
<TableHead>{t('siteName')}</TableHead>
|
||||
<TableHead>{t('domain')}</TableHead>
|
||||
<TableHead>{t('status')}</TableHead>
|
||||
<TableHead>{t('upstream')}</TableHead>
|
||||
<TableHead className='text-right'>{t('actions')}</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
@@ -185,21 +187,21 @@ export function ProxyRoutesPageClient() {
|
||||
</TableCell>
|
||||
<TableCell
|
||||
className='max-w-[220px] truncate'
|
||||
title={getRouteDomainsLabel(route)}
|
||||
title={getRouteDomainsLabel(route, t)}
|
||||
>
|
||||
{getRoutePrimaryDomain(route) ||
|
||||
getRouteDomainsLabel(route)}
|
||||
getRouteDomainsLabel(route, t)}
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<Badge variant={route.enabled ? 'default' : 'secondary'}>
|
||||
{route.enabled ? '已启用' : '已停用'}
|
||||
{route.enabled ? t('enabled') : t('disabled')}
|
||||
</Badge>
|
||||
</TableCell>
|
||||
<TableCell
|
||||
className='max-w-[280px] truncate'
|
||||
title={getUpstreamSummary(route)}
|
||||
title={getUpstreamSummary(route, t)}
|
||||
>
|
||||
{getUpstreamSummary(route)}
|
||||
{getUpstreamSummary(route, t)}
|
||||
</TableCell>
|
||||
<TableCell className='text-right'>
|
||||
<div className='flex justify-end gap-2'>
|
||||
@@ -210,7 +212,7 @@ export function ProxyRoutesPageClient() {
|
||||
asChild
|
||||
>
|
||||
<Link href={`/proxy-routes/detail?id=${route.id}`}>
|
||||
配置
|
||||
{t('configure')}
|
||||
</Link>
|
||||
</Button>
|
||||
<Button
|
||||
@@ -235,7 +237,7 @@ export function ProxyRoutesPageClient() {
|
||||
open={isCreateOpen}
|
||||
onOpenChange={setIsCreateOpen}
|
||||
onCreated={(route) => {
|
||||
toast.success('网站已创建');
|
||||
toast.success(t('siteCreated'));
|
||||
void fetchRoutes();
|
||||
router.push(`/proxy-routes/detail?id=${route.id}§ion=domains`);
|
||||
}}
|
||||
@@ -251,15 +253,15 @@ export function ProxyRoutesPageClient() {
|
||||
>
|
||||
<AlertDialogContent>
|
||||
<AlertDialogHeader>
|
||||
<AlertDialogTitle>确认删除网站</AlertDialogTitle>
|
||||
<AlertDialogTitle>{t('deleteSiteTitle')}</AlertDialogTitle>
|
||||
<AlertDialogDescription>
|
||||
{deleteTarget
|
||||
? `确认删除网站 ${deleteTarget.site_name} 吗?这会删除该站点下的全部域名与配置。`
|
||||
? t('deleteSiteDesc', { name: deleteTarget.site_name })
|
||||
: null}
|
||||
</AlertDialogDescription>
|
||||
</AlertDialogHeader>
|
||||
<AlertDialogFooter>
|
||||
<AlertDialogCancel disabled={deleting}>取消</AlertDialogCancel>
|
||||
<AlertDialogCancel disabled={deleting}>{tc('cancel')}</AlertDialogCancel>
|
||||
<AlertDialogAction
|
||||
className='bg-destructive text-white hover:bg-destructive/90'
|
||||
disabled={deleting}
|
||||
@@ -268,7 +270,7 @@ export function ProxyRoutesPageClient() {
|
||||
void handleDelete();
|
||||
}}
|
||||
>
|
||||
{deleting ? '删除中...' : '删除'}
|
||||
{deleting ? t('deleting') : tc('delete')}
|
||||
</AlertDialogAction>
|
||||
</AlertDialogFooter>
|
||||
</AlertDialogContent>
|
||||
|
||||
Reference in New Issue
Block a user