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
@@ -190,12 +190,17 @@ function isCacheLevelsValue(value: string) {
return /^\d{1,2}(?::\d{1,2}){0,2}$/.test(value.trim());
}
export function validateRuntimeFields(fields: PerformanceFields) {
type PerformanceMessageT = (key: string) => string;
export function validateRuntimeFields(
fields: PerformanceFields,
t: PerformanceMessageT,
) {
if (
fields.openresty_worker_processes !== 'auto' &&
!isPositiveInteger(fields.openresty_worker_processes)
) {
throw new Error('worker_processes 必须为 auto 或大于 0 的整数');
throw new Error(t('errors.workerProcesses'));
}
const integers = [
fields.openresty_worker_connections,
@@ -207,57 +212,66 @@ export function validateRuntimeFields(fields: PerformanceFields) {
fields.openresty_send_timeout,
];
if (integers.some((value) => !isPositiveInteger(value))) {
throw new Error('超时与连接参数必须为大于 0 的整数');
throw new Error(t('errors.timeouts'));
}
const status = Number.parseInt(
fields.openresty_default_server_return_status,
10,
);
if (Number.isNaN(status) || status < 100 || status > 999) {
throw new Error('空白页面返回状态码必须在 100 到 999 之间');
throw new Error(t('errors.statusCode'));
}
if (!isSizeValue(fields.openresty_client_max_body_size)) {
throw new Error('client_max_body_size 格式不合法');
throw new Error(t('errors.bodySize'));
}
if (!isProxyBuffersValue(fields.openresty_large_client_header_buffers)) {
throw new Error('large_client_header_buffers 格式必须类似 "4 16k"');
throw new Error(t('errors.headerBuffers'));
}
}
export function validateProxyFields(fields: PerformanceFields) {
export function validateProxyFields(
fields: PerformanceFields,
t: PerformanceMessageT,
) {
const timeouts = [
fields.openresty_proxy_connect_timeout,
fields.openresty_proxy_send_timeout,
fields.openresty_proxy_read_timeout,
];
if (timeouts.some((value) => !isPositiveInteger(value))) {
throw new Error('代理超时参数必须为大于 0 的整数秒');
throw new Error(t('errors.proxyTimeouts'));
}
if (!isProxyBuffersValue(fields.openresty_proxy_buffers)) {
throw new Error('proxy_buffers 格式必须类似 "16 16k"');
throw new Error(t('errors.proxyBuffers'));
}
if (
!isSizeValue(fields.openresty_proxy_buffer_size) ||
!isSizeValue(fields.openresty_proxy_busy_buffers_size)
) {
throw new Error('缓冲大小必须为整数或带 k/m/g 单位的值');
throw new Error(t('errors.bufferSize'));
}
}
export function validateGzipFields(fields: PerformanceFields) {
export function validateGzipFields(
fields: PerformanceFields,
t: PerformanceMessageT,
) {
if (!isPositiveInteger(fields.openresty_gzip_min_length)) {
throw new Error('gzip_min_length 必须为大于 0 的整数');
throw new Error(t('errors.gzipMinLength'));
}
const level = Number.parseInt(fields.openresty_gzip_comp_level, 10);
if (Number.isNaN(level) || level < 1 || level > 9) {
throw new Error('gzip_comp_level 必须在 1 到 9 之间');
throw new Error(t('errors.gzipLevel'));
}
}
export function validateCacheFields(fields: PerformanceFields) {
export function validateCacheFields(
fields: PerformanceFields,
t: PerformanceMessageT,
) {
if (!fields.openresty_cache_enabled) return;
if (!fields.openresty_cache_path.trim()) {
throw new Error('启用缓存时必须填写 proxy_cache_path 目录');
throw new Error(t('errors.cachePath'));
}
if (
!isCacheLevelsValue(fields.openresty_cache_levels) ||
@@ -265,12 +279,10 @@ export function validateCacheFields(fields: PerformanceFields) {
!isSizeValue(fields.openresty_cache_max_size) ||
!isDurationToken(fields.openresty_cache_lock_timeout)
) {
throw new Error(
'缓存 levels、inactive、max_size 或 lock_timeout 格式不合法',
);
throw new Error(t('errors.cacheFormat'));
}
if (!fields.openresty_cache_key_template.trim()) {
throw new Error('启用缓存时必须填写缓存 Key 模板');
throw new Error(t('errors.cacheKey'));
}
}
+30 -31
View File
@@ -4,6 +4,7 @@ import Link from 'next/link';
import { useEffect, useState } from 'react';
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
import { ExternalLink, Gauge, Loader2, Save } from 'lucide-react';
import { useTranslations } from 'next-intl';
import { toast } from 'sonner';
import { useAuth } from '@/components/providers/auth-provider';
@@ -45,6 +46,8 @@ import {
const optionsQueryKey = ['openflare', 'options'] as const;
export default function PerformancePage() {
const t = useTranslations('performance');
const tc = useTranslations('common');
const { user, loading: authLoading } = useAuth();
const queryClient = useQueryClient();
const [fields, setFields] = useState<PerformanceFields>(
@@ -78,7 +81,7 @@ export default function PerformancePage() {
await OptionService.updateBatch(entries);
},
onSuccess: async () => {
toast.success('性能参数已保存');
toast.success(t('saved'));
await Promise.all([
queryClient.invalidateQueries({ queryKey: optionsQueryKey }),
queryClient.invalidateQueries({
@@ -92,7 +95,7 @@ export default function PerformancePage() {
},
onError: (error) => {
setSavingSection(null);
toast.error(error instanceof Error ? error.message : '保存失败');
toast.error(error instanceof Error ? error.message : t('saveFailed'));
},
});
@@ -106,7 +109,7 @@ export default function PerformancePage() {
if (authLoading) {
return (
<div className='py-6 px-1'>
<LoadingStateWithBorder icon={Gauge} description='加载权限信息...' />
<LoadingStateWithBorder icon={Gauge} description={t('loadingAuth')} />
</div>
);
}
@@ -116,8 +119,8 @@ export default function PerformancePage() {
<div className='py-6 px-1'>
<EmptyStateWithBorder
icon={Gauge}
title='权限不足'
description='只有管理员可以访问性能设置。'
title={t('forbiddenTitle')}
description={t('forbiddenDesc')}
/>
</div>
);
@@ -126,7 +129,7 @@ export default function PerformancePage() {
if (optionsQuery.isLoading) {
return (
<div className='py-6 px-1'>
<LoadingStateWithBorder icon={Gauge} description='加载性能参数...' />
<LoadingStateWithBorder icon={Gauge} description={t('loading')} />
</div>
);
}
@@ -138,7 +141,7 @@ export default function PerformancePage() {
message={
optionsQuery.error instanceof Error
? optionsQuery.error.message
: '加载失败'
: tc('loadFailed')
}
onRetry={() => void optionsQuery.refetch()}
/>
@@ -152,16 +155,16 @@ export default function PerformancePage() {
<div className='flex items-center gap-2'>
<Gauge className='size-5 text-primary' />
<div>
<h1 className='text-2xl font-semibold tracking-tight'>性能</h1>
<p className='text-sm text-muted-foreground'>
集中管理 OpenResty 全局性能参数,保存后进入统一配置发布链路。
</p>
<h1 className='text-2xl font-semibold tracking-tight'>
{t('title')}
</h1>
<p className='text-sm text-muted-foreground'>{t('description')}</p>
</div>
</div>
<Button variant='outline' size='sm' asChild>
<Link href='/config-versions'>
<ExternalLink className='size-3.5 mr-1' />
查看配置预览
{t('preview')}
</Link>
</Button>
</div>
@@ -169,10 +172,8 @@ export default function PerformancePage() {
<Card className='border-dashed shadow-none'>
<CardHeader className='flex flex-row items-center justify-between'>
<div>
<CardTitle className='text-base'>连接与事件</CardTitle>
<CardDescription>
worker、超时、请求体大小等运行参数
</CardDescription>
<CardTitle className='text-base'>{t('runtimeTitle')}</CardTitle>
<CardDescription>{t('runtimeDesc')}</CardDescription>
</div>
<Button
size='sm'
@@ -180,7 +181,7 @@ export default function PerformancePage() {
onClick={() =>
saveMutation.mutate({
section: 'runtime',
validator: validateRuntimeFields,
validator: (fields) => validateRuntimeFields(fields, t),
entries: entriesFromKeys(fields, [
'openresty_default_server_return_status',
'openresty_worker_processes',
@@ -205,7 +206,7 @@ export default function PerformancePage() {
) : (
<Save className='size-3.5 mr-1' />
)}
保存
{tc('save')}
</Button>
</CardHeader>
<CardContent className='grid gap-4 md:grid-cols-2 xl:grid-cols-3'>
@@ -281,10 +282,8 @@ export default function PerformancePage() {
<Card className='border-dashed shadow-none'>
<CardHeader className='flex flex-row items-center justify-between'>
<div>
<CardTitle className='text-base'>反代缓冲与超时</CardTitle>
<CardDescription>
upstream 连接、缓冲与 WebSocket/HTTP3 开关
</CardDescription>
<CardTitle className='text-base'>{t('proxyTitle')}</CardTitle>
<CardDescription>{t('proxyDesc')}</CardDescription>
</div>
<Button
size='sm'
@@ -292,7 +291,7 @@ export default function PerformancePage() {
onClick={() =>
saveMutation.mutate({
section: 'proxy',
validator: validateProxyFields,
validator: (fields) => validateProxyFields(fields, t),
entries: entriesFromKeys(fields, [
'openresty_proxy_connect_timeout',
'openresty_proxy_send_timeout',
@@ -313,7 +312,7 @@ export default function PerformancePage() {
) : (
<Save className='size-3.5 mr-1' />
)}
保存
{tc('save')}
</Button>
</CardHeader>
<CardContent className='grid gap-4 md:grid-cols-2'>
@@ -357,14 +356,14 @@ export default function PerformancePage() {
<div className='grid gap-6 xl:grid-cols-2'>
<Card className='border-dashed shadow-none'>
<CardHeader className='flex flex-row items-center justify-between'>
<CardTitle className='text-base'>压缩</CardTitle>
<CardTitle className='text-base'>{t('gzipTitle')}</CardTitle>
<Button
size='sm'
disabled={savingSection === 'gzip'}
onClick={() =>
saveMutation.mutate({
section: 'gzip',
validator: validateGzipFields,
validator: (fields) => validateGzipFields(fields, t),
entries: entriesFromKeys(fields, [
'openresty_gzip_enabled',
'openresty_gzip_min_length',
@@ -373,7 +372,7 @@ export default function PerformancePage() {
})
}
>
保存
{tc('save')}
</Button>
</CardHeader>
<CardContent className='space-y-4'>
@@ -400,8 +399,8 @@ export default function PerformancePage() {
<Card className='border-dashed shadow-none'>
<CardHeader className='flex flex-row items-center justify-between'>
<div>
<CardTitle className='text-base'>缓存</CardTitle>
<CardDescription>单节点反代缓存优化场景</CardDescription>
<CardTitle className='text-base'>{t('cacheTitle')}</CardTitle>
<CardDescription>{t('cacheDesc')}</CardDescription>
</div>
<Button
size='sm'
@@ -409,7 +408,7 @@ export default function PerformancePage() {
onClick={() =>
saveMutation.mutate({
section: 'cache',
validator: validateCacheFields,
validator: (fields) => validateCacheFields(fields, t),
entries: entriesFromKeys(fields, [
'openresty_cache_enabled',
'openresty_cache_path',
@@ -424,7 +423,7 @@ export default function PerformancePage() {
})
}
>
保存
{tc('save')}
</Button>
</CardHeader>
<CardContent className='space-y-4'>