mirror of
https://github.com/Rain-kl/OpenFlare.git
synced 2026-10-13 10:26:38 +08:00
feat(frontend): 控制台 next-intl 中英双语
接入无 URL 前缀的 zh-CN/en,顶栏与外观设置可切换语言;选择写入 cookie 后刷新生效。
This commit is contained in:
@@ -1,6 +1,7 @@
|
||||
'use client';
|
||||
|
||||
import { Gauge } from 'lucide-react';
|
||||
import { useTranslations } from 'next-intl';
|
||||
|
||||
import { RankCard } from '@/components/data/rank-card';
|
||||
import { EmptyStateWithBorder } from '@/components/layout/empty';
|
||||
@@ -57,7 +58,15 @@ export function AnalysisTab({
|
||||
onHostsChange: (hosts: string[]) => void;
|
||||
onRetry: () => void;
|
||||
}) {
|
||||
const rangeHint = formatOverviewRangeHint(hours);
|
||||
const t = useTranslations('rateLimits');
|
||||
const tLogs = useTranslations('accessLogs');
|
||||
const rangeHint = formatOverviewRangeHint(hours, (key, values) =>
|
||||
tLogs(key, values),
|
||||
);
|
||||
const rangeOptions = RATE_LIMIT_RANGE_OPTIONS.map((option) => ({
|
||||
value: option.value,
|
||||
label: t(option.labelKey),
|
||||
}));
|
||||
const hostItems = toAvgRpsItems(data?.top_hosts, hours);
|
||||
const ipItems = toAvgRpsItems(data?.top_ips, hours);
|
||||
|
||||
@@ -68,33 +77,39 @@ export function AnalysisTab({
|
||||
hosts={hosts}
|
||||
onHoursChange={(next) => onHoursChange(next as RateLimitRangeHours)}
|
||||
onHostsChange={onHostsChange}
|
||||
rangeOptions={RATE_LIMIT_RANGE_OPTIONS}
|
||||
rangeOptions={rangeOptions}
|
||||
/>
|
||||
|
||||
{loading ? (
|
||||
<LoadingStateWithBorder icon={Gauge} description='加载请求压力...' />
|
||||
<LoadingStateWithBorder
|
||||
icon={Gauge}
|
||||
description={t('loadingPressure')}
|
||||
/>
|
||||
) : error ? (
|
||||
<ErrorInline message={error.message || '加载失败'} onRetry={onRetry} />
|
||||
<ErrorInline
|
||||
message={error.message || t('loadFailed')}
|
||||
onRetry={onRetry}
|
||||
/>
|
||||
) : !data ? (
|
||||
<EmptyStateWithBorder
|
||||
icon={Gauge}
|
||||
title='暂无数据'
|
||||
description='当前时间范围内没有可用的访问日志。'
|
||||
title={t('emptyTitle')}
|
||||
description={t('emptyDesc')}
|
||||
/>
|
||||
) : (
|
||||
<>
|
||||
<RatePressureChart data={data} hours={hours} />
|
||||
<div className='grid gap-4 lg:grid-cols-2'>
|
||||
<RankCard
|
||||
title='平均 RPS 最高域名'
|
||||
description={`${rangeHint}平均请求速率`}
|
||||
title={t('topHost')}
|
||||
description={t('avgRate', { range: rangeHint })}
|
||||
items={hostItems}
|
||||
color='#38bdf8'
|
||||
valueFormatter={(value) => `${formatRps(value)} req/s`}
|
||||
/>
|
||||
<RankCard
|
||||
title='平均 RPS 最高 IP'
|
||||
description={`${rangeHint}平均请求速率`}
|
||||
title={t('topIp')}
|
||||
description={t('avgRate', { range: rangeHint })}
|
||||
items={ipItems}
|
||||
color='#a78bfa'
|
||||
valueFormatter={(value) => `${formatRps(value)} req/s`}
|
||||
|
||||
@@ -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 { ErrorInline } from '@/components/layout/error';
|
||||
@@ -62,25 +63,26 @@ function mapOptionsToFields(
|
||||
};
|
||||
}
|
||||
|
||||
function validateFields(fields: RateLimitFields) {
|
||||
function validateFields(
|
||||
fields: RateLimitFields,
|
||||
t: (key: 'config.invalidConn' | 'config.invalidRate' | 'config.invalidReq') => string,
|
||||
) {
|
||||
for (const key of [KEY_CONN_PER_SERVER, KEY_CONN_PER_IP] as const) {
|
||||
const raw = fields[key].trim();
|
||||
if (!raw) continue;
|
||||
if (!/^\d+$/.test(raw)) {
|
||||
throw new Error('并发限制请输入非负整数,或留空表示关闭');
|
||||
throw new Error(t('config.invalidConn'));
|
||||
}
|
||||
}
|
||||
|
||||
const rate = fields.openresty_default_limit_rate.trim();
|
||||
if (rate && rate !== '0' && !limitRatePattern.test(rate)) {
|
||||
throw new Error('限速格式不合法,请使用 512k、1m、纯数字,或留空关闭');
|
||||
throw new Error(t('config.invalidRate'));
|
||||
}
|
||||
|
||||
const reqRate = fields.openresty_default_limit_req_per_ip.trim();
|
||||
if (reqRate && reqRate !== '0' && !/^\d+r\/[sm]$/i.test(reqRate)) {
|
||||
throw new Error(
|
||||
'请求频率限制格式不合法,请使用类似 10r/s、100r/m,或留空关闭',
|
||||
);
|
||||
throw new Error(t('config.invalidReq'));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -97,6 +99,8 @@ function normalizeRateValue(value: string) {
|
||||
}
|
||||
|
||||
export function ConfigTab() {
|
||||
const t = useTranslations('rateLimits');
|
||||
const tCommon = useTranslations('common');
|
||||
const queryClient = useQueryClient();
|
||||
const [fields, setFields] = useState<RateLimitFields>(defaultFields);
|
||||
const [saving, setSaving] = useState(false);
|
||||
@@ -113,7 +117,7 @@ export function ConfigTab() {
|
||||
|
||||
const saveMutation = useMutation({
|
||||
mutationFn: async () => {
|
||||
validateFields(fields);
|
||||
validateFields(fields, (key) => t(key));
|
||||
setSaving(true);
|
||||
await OptionService.updateBatch([
|
||||
{
|
||||
@@ -137,7 +141,7 @@ export function ConfigTab() {
|
||||
]);
|
||||
},
|
||||
onSuccess: async () => {
|
||||
toast.success('限流参数已保存');
|
||||
toast.success(t('config.saved'));
|
||||
await Promise.all([
|
||||
queryClient.invalidateQueries({ queryKey: optionsQueryKey }),
|
||||
queryClient.invalidateQueries({
|
||||
@@ -151,7 +155,7 @@ export function ConfigTab() {
|
||||
},
|
||||
onError: (error) => {
|
||||
setSaving(false);
|
||||
toast.error(error instanceof Error ? error.message : '保存失败');
|
||||
toast.error(error instanceof Error ? error.message : t('config.saveFailed'));
|
||||
},
|
||||
});
|
||||
|
||||
@@ -164,7 +168,7 @@ export function ConfigTab() {
|
||||
|
||||
if (optionsQuery.isLoading) {
|
||||
return (
|
||||
<LoadingStateWithBorder icon={Gauge} description='加载限流参数...' />
|
||||
<LoadingStateWithBorder icon={Gauge} description={t('config.loading')} />
|
||||
);
|
||||
}
|
||||
|
||||
@@ -174,7 +178,7 @@ export function ConfigTab() {
|
||||
message={
|
||||
optionsQuery.error instanceof Error
|
||||
? optionsQuery.error.message
|
||||
: '加载失败'
|
||||
: t('loadFailed')
|
||||
}
|
||||
onRetry={() => void optionsQuery.refetch()}
|
||||
/>
|
||||
@@ -187,7 +191,7 @@ export function ConfigTab() {
|
||||
<Button variant='outline' size='sm' asChild>
|
||||
<Link href='/config-versions'>
|
||||
<ExternalLink className='size-3.5 mr-1' />
|
||||
查看配置预览
|
||||
{t('config.preview')}
|
||||
</Link>
|
||||
</Button>
|
||||
</div>
|
||||
@@ -195,11 +199,8 @@ export function ConfigTab() {
|
||||
<Card className='border-dashed shadow-none'>
|
||||
<CardHeader className='flex flex-row items-center justify-between'>
|
||||
<div>
|
||||
<CardTitle className='text-base'>全局默认限流</CardTitle>
|
||||
<CardDescription>
|
||||
0
|
||||
或空表示默认关闭;站点未单独配置时继承此处设置。修改后需在版本发布中生效。
|
||||
</CardDescription>
|
||||
<CardTitle className='text-base'>{t('config.title')}</CardTitle>
|
||||
<CardDescription>{t('config.description')}</CardDescription>
|
||||
</div>
|
||||
<Button
|
||||
size='sm'
|
||||
@@ -211,13 +212,13 @@ export function ConfigTab() {
|
||||
) : (
|
||||
<Save className='size-3.5 mr-1' />
|
||||
)}
|
||||
保存
|
||||
{tCommon('save')}
|
||||
</Button>
|
||||
</CardHeader>
|
||||
<CardContent className='grid gap-4 md:grid-cols-2 xl:grid-cols-3'>
|
||||
<div className='space-y-1.5'>
|
||||
<Label className='text-xs text-muted-foreground'>
|
||||
默认并发限制(每站点)
|
||||
{t('config.connPerServer')}
|
||||
</Label>
|
||||
<Input
|
||||
type='number'
|
||||
@@ -233,12 +234,12 @@ export function ConfigTab() {
|
||||
className='h-9 text-xs'
|
||||
/>
|
||||
<p className='text-xs text-muted-foreground'>
|
||||
站点最大并发连接数默认值
|
||||
{t('config.connPerServerHint')}
|
||||
</p>
|
||||
</div>
|
||||
<div className='space-y-1.5'>
|
||||
<Label className='text-xs text-muted-foreground'>
|
||||
默认单 IP 并发
|
||||
{t('config.connPerIp')}
|
||||
</Label>
|
||||
<Input
|
||||
type='number'
|
||||
@@ -254,11 +255,13 @@ export function ConfigTab() {
|
||||
className='h-9 text-xs'
|
||||
/>
|
||||
<p className='text-xs text-muted-foreground'>
|
||||
单个 IP 最大并发连接数默认值
|
||||
{t('config.connPerIpHint')}
|
||||
</p>
|
||||
</div>
|
||||
<div className='space-y-1.5'>
|
||||
<Label className='text-xs text-muted-foreground'>默认限速</Label>
|
||||
<Label className='text-xs text-muted-foreground'>
|
||||
{t('config.limitRate')}
|
||||
</Label>
|
||||
<Input
|
||||
value={fields.openresty_default_limit_rate}
|
||||
placeholder='512k / 1m'
|
||||
@@ -268,12 +271,12 @@ export function ConfigTab() {
|
||||
className='h-9 text-xs'
|
||||
/>
|
||||
<p className='text-xs text-muted-foreground'>
|
||||
单请求带宽默认值,例如 512k 或 1m
|
||||
{t('config.limitRateHint')}
|
||||
</p>
|
||||
</div>
|
||||
<div className='space-y-1.5'>
|
||||
<Label className='text-xs text-muted-foreground'>
|
||||
默认单 IP 请求频率限制
|
||||
{t('config.reqPerIp')}
|
||||
</Label>
|
||||
<Input
|
||||
value={fields.openresty_default_limit_req_per_ip}
|
||||
@@ -287,7 +290,7 @@ export function ConfigTab() {
|
||||
className='h-9 text-xs'
|
||||
/>
|
||||
<p className='text-xs text-muted-foreground'>
|
||||
单个 IP 请求频率默认值,例如 10r/s 或 100r/m,留空关闭
|
||||
{t('config.reqPerIpHint')}
|
||||
</p>
|
||||
</div>
|
||||
</CardContent>
|
||||
|
||||
@@ -5,6 +5,7 @@ import type { EChartsOption } from 'echarts';
|
||||
import type { EChartsType } from 'echarts/core';
|
||||
import ReactECharts from 'echarts-for-react';
|
||||
import { Clock } from 'lucide-react';
|
||||
import { useTranslations } from 'next-intl';
|
||||
|
||||
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
|
||||
import type { AccessLogOverview } from '@/lib/services/openflare';
|
||||
@@ -165,6 +166,9 @@ type RatePressureChartProps = {
|
||||
};
|
||||
|
||||
export function RatePressureChart({ data, hours }: RatePressureChartProps) {
|
||||
const t = useTranslations('rateLimits.chart');
|
||||
const requestRateLabel = t('requestRate');
|
||||
const uniqueVisitorsLabel = t('uniqueVisitors');
|
||||
const chartRef = useRef<InstanceType<typeof ReactECharts> | null>(null);
|
||||
|
||||
const bucketSeconds =
|
||||
@@ -301,7 +305,7 @@ export function RatePressureChart({ data, hours }: RatePressureChartProps) {
|
||||
typeof row.value === 'number'
|
||||
? row.value
|
||||
: Number(row.value ?? 0);
|
||||
const isRps = row.seriesName === '请求速率';
|
||||
const isRps = row.seriesName === requestRateLabel;
|
||||
const formatted = isRps
|
||||
? `${formatRps(numeric)} req/s`
|
||||
: formatCompactNumber(numeric);
|
||||
@@ -327,7 +331,7 @@ export function RatePressureChart({ data, hours }: RatePressureChartProps) {
|
||||
color: '#94a3b8',
|
||||
fontSize: 12,
|
||||
},
|
||||
data: ['请求速率', '独立访客'],
|
||||
data: [requestRateLabel, uniqueVisitorsLabel],
|
||||
},
|
||||
graphic: [
|
||||
{
|
||||
@@ -345,7 +349,7 @@ export function RatePressureChart({ data, hours }: RatePressureChartProps) {
|
||||
right: 16,
|
||||
top: 12,
|
||||
style: {
|
||||
text: '访客 / 桶',
|
||||
text: t('visitorsPerBucket'),
|
||||
fill: '#94a3b8',
|
||||
fontSize: 12,
|
||||
},
|
||||
@@ -433,7 +437,7 @@ export function RatePressureChart({ data, hours }: RatePressureChartProps) {
|
||||
],
|
||||
series: [
|
||||
{
|
||||
name: '请求速率',
|
||||
name: requestRateLabel,
|
||||
type: 'line',
|
||||
yAxisIndex: 0,
|
||||
smooth: true,
|
||||
@@ -453,7 +457,7 @@ export function RatePressureChart({ data, hours }: RatePressureChartProps) {
|
||||
data: chartModel.rpsValues,
|
||||
},
|
||||
{
|
||||
name: '独立访客',
|
||||
name: uniqueVisitorsLabel,
|
||||
type: 'line',
|
||||
yAxisIndex: 1,
|
||||
smooth: true,
|
||||
@@ -474,13 +478,13 @@ export function RatePressureChart({ data, hours }: RatePressureChartProps) {
|
||||
},
|
||||
],
|
||||
};
|
||||
}, [chartModel, hours]);
|
||||
}, [chartModel, hours, requestRateLabel, uniqueVisitorsLabel, t]);
|
||||
|
||||
return (
|
||||
<Card className='border-dashed shadow-none'>
|
||||
<CardHeader className='flex flex-col gap-3 sm:flex-row sm:items-start sm:justify-between pb-2'>
|
||||
<CardTitle className='text-base font-semibold tracking-tight'>
|
||||
请求压力
|
||||
{t('title')}
|
||||
</CardTitle>
|
||||
<div className='flex items-center gap-1.5 text-xs text-muted-foreground font-mono'>
|
||||
<Clock className='size-3.5 shrink-0' />
|
||||
@@ -490,7 +494,7 @@ export function RatePressureChart({ data, hours }: RatePressureChartProps) {
|
||||
<CardContent className='pt-0'>
|
||||
{chartModel.labels.length === 0 ? (
|
||||
<div className='flex h-[360px] items-center justify-center rounded-md border border-dashed bg-muted/20 text-sm text-muted-foreground'>
|
||||
暂无请求压力数据
|
||||
{t('empty')}
|
||||
</div>
|
||||
) : (
|
||||
<ReactECharts
|
||||
|
||||
@@ -5,6 +5,8 @@ import { useRouter, useSearchParams } from 'next/navigation';
|
||||
import { useQuery } from '@tanstack/react-query';
|
||||
import { Gauge } from 'lucide-react';
|
||||
|
||||
import { useTranslations } from 'next-intl';
|
||||
|
||||
import { useAuth } from '@/components/providers/auth-provider';
|
||||
import { EmptyStateWithBorder } from '@/components/layout/empty';
|
||||
import { LoadingStateWithBorder } from '@/components/layout/loading';
|
||||
@@ -23,6 +25,7 @@ function resolveTab(value: string | null): RateLimitTab {
|
||||
}
|
||||
|
||||
function RateLimitsPageContent() {
|
||||
const t = useTranslations('rateLimits');
|
||||
const { user, loading: authLoading } = useAuth();
|
||||
const router = useRouter();
|
||||
const searchParams = useSearchParams();
|
||||
@@ -51,7 +54,7 @@ function RateLimitsPageContent() {
|
||||
if (authLoading) {
|
||||
return (
|
||||
<div className='py-6 px-1'>
|
||||
<LoadingStateWithBorder icon={Gauge} description='加载权限信息...' />
|
||||
<LoadingStateWithBorder icon={Gauge} description={t('loadingAuth')} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -61,8 +64,8 @@ function RateLimitsPageContent() {
|
||||
<div className='py-6 px-1'>
|
||||
<EmptyStateWithBorder
|
||||
icon={Gauge}
|
||||
title='权限不足'
|
||||
description='只有管理员可以访问限流设置。'
|
||||
title={t('forbiddenTitle')}
|
||||
description={t('forbiddenDesc')}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
@@ -73,17 +76,15 @@ function RateLimitsPageContent() {
|
||||
<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'>
|
||||
查看边缘请求压力,并配置站点默认并发与带宽限流。
|
||||
</p>
|
||||
<h1 className='text-2xl font-semibold tracking-tight'>{t('title')}</h1>
|
||||
<p className='text-sm text-muted-foreground'>{t('subtitle')}</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<Tabs value={tab} onValueChange={handleTabChange}>
|
||||
<TabsList className='grid w-full max-w-md grid-cols-2'>
|
||||
<TabsTrigger value='analysis'>分析</TabsTrigger>
|
||||
<TabsTrigger value='config'>配置</TabsTrigger>
|
||||
<TabsTrigger value='analysis'>{t('tabAnalysis')}</TabsTrigger>
|
||||
<TabsTrigger value='config'>{t('tabConfig')}</TabsTrigger>
|
||||
</TabsList>
|
||||
|
||||
<TabsContent value='analysis' className='mt-4'>
|
||||
@@ -110,11 +111,15 @@ function RateLimitsPageContent() {
|
||||
}
|
||||
|
||||
export default function RateLimitsPage() {
|
||||
const t = useTranslations('rateLimits');
|
||||
return (
|
||||
<Suspense
|
||||
fallback={
|
||||
<div className='py-6 px-1'>
|
||||
<LoadingStateWithBorder icon={Gauge} description='加载限流页面...' />
|
||||
<LoadingStateWithBorder
|
||||
icon={Gauge}
|
||||
description={t('loadingPage')}
|
||||
/>
|
||||
</div>
|
||||
}
|
||||
>
|
||||
|
||||
Reference in New Issue
Block a user