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 });
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user