feat(frontend): 控制台 next-intl 中英双语

接入无 URL 前缀的 zh-CN/en,顶栏与外观设置可切换语言;选择写入 cookie 后刷新生效。
This commit is contained in:
ryan
2026-08-16 16:49:16 +08:00
parent 64fbaa7ef1
commit 5a8722ff07
219 changed files with 21437 additions and 5212 deletions
@@ -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>
)}