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:
@@ -28,6 +28,8 @@ import type {
|
||||
} from '@/lib/services/openflare';
|
||||
import { formatDateTime } from '@/lib/utils';
|
||||
|
||||
import { useTranslations } from 'next-intl';
|
||||
|
||||
import { getCertificateStatus } from '../../components/website-utils';
|
||||
|
||||
export function ZoneCertificatesPanel({
|
||||
@@ -37,6 +39,8 @@ export function ZoneCertificatesPanel({
|
||||
domains: ZoneDomainItem[];
|
||||
certificates: TlsCertificateItem[];
|
||||
}) {
|
||||
const t = useTranslations('websites');
|
||||
const tc = useTranslations('certificates');
|
||||
const certificateMap = useMemo(
|
||||
() =>
|
||||
new Map(certificates.map((certificate) => [certificate.id, certificate])),
|
||||
@@ -79,14 +83,12 @@ export function ZoneCertificatesPanel({
|
||||
<Card className='shadow-none'>
|
||||
<CardHeader className='flex flex-row items-start justify-between gap-3 space-y-0'>
|
||||
<div>
|
||||
<CardTitle className='text-base'>本 Zone 使用的证书</CardTitle>
|
||||
<CardDescription>
|
||||
证书在全局证书库管理;此处仅展示已绑定到本 Zone 域名的证书。
|
||||
</CardDescription>
|
||||
<CardTitle className='text-base'>{t('zoneCertsTitle')}</CardTitle>
|
||||
<CardDescription>{t('zoneCertsDesc')}</CardDescription>
|
||||
</div>
|
||||
<Button variant='outline' size='sm' className='h-7 text-xs' asChild>
|
||||
<Link href='/certificates'>
|
||||
证书库
|
||||
{t('certLibrary')}
|
||||
<ExternalLink className='ml-1 size-3.5' />
|
||||
</Link>
|
||||
</Button>
|
||||
@@ -94,37 +96,37 @@ export function ZoneCertificatesPanel({
|
||||
<CardContent className='space-y-3'>
|
||||
{unboundCount > 0 ? (
|
||||
<p className='text-xs text-muted-foreground'>
|
||||
还有 {unboundCount} 个域名未绑定证书,可在「域名」Tab 中选择证书。
|
||||
{t('unboundCertHint', { count: unboundCount })}
|
||||
</p>
|
||||
) : null}
|
||||
|
||||
{boundCertificates.length === 0 ? (
|
||||
<EmptyStateWithBorder
|
||||
icon={FileKey}
|
||||
description='暂无绑定证书。请先在域名行选择证书,或前往证书库导入/申请。'
|
||||
description={t('emptyBoundCerts')}
|
||||
/>
|
||||
) : (
|
||||
<div className='rounded-lg border'>
|
||||
<Table>
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
<TableHead>证书</TableHead>
|
||||
<TableHead>状态</TableHead>
|
||||
<TableHead>覆盖域名</TableHead>
|
||||
<TableHead>到期时间</TableHead>
|
||||
<TableHead>{t('certificate')}</TableHead>
|
||||
<TableHead>{t('status')}</TableHead>
|
||||
<TableHead>{t('coverage')}</TableHead>
|
||||
<TableHead>{t('expiresAt')}</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{boundCertificates.map(
|
||||
({ certificate, domains: boundDomains }) => {
|
||||
const status = getCertificateStatus(certificate);
|
||||
const status = getCertificateStatus(certificate, tc);
|
||||
return (
|
||||
<TableRow key={certificate.id}>
|
||||
<TableCell className='font-medium'>
|
||||
{certificate.name}
|
||||
<p className='mt-0.5 text-xs text-muted-foreground'>
|
||||
{certificate.primary_domain ||
|
||||
`证书 #${certificate.id}`}
|
||||
t('certNumber', { id: certificate.id })}
|
||||
</p>
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
|
||||
@@ -39,6 +39,8 @@ import {
|
||||
type ZoneDomainItem,
|
||||
} from '@/lib/services/openflare';
|
||||
|
||||
import { useTranslations } from 'next-intl';
|
||||
|
||||
import { getUpstreamLabels } from '../../../proxy-routes/components/helpers';
|
||||
import { ZoneDomainDialog } from './zone-domain-dialog';
|
||||
|
||||
@@ -59,18 +61,21 @@ export function ZoneDomainsTable({
|
||||
routesLoading?: boolean;
|
||||
onChanged(): Promise<unknown> | void;
|
||||
}) {
|
||||
const t = useTranslations('websites');
|
||||
const tc = useTranslations('common');
|
||||
const tp = useTranslations('proxyRoutes');
|
||||
const [createOpen, setCreateOpen] = useState(false);
|
||||
const [deleting, setDeleting] = useState<ZoneDomainItem | null>(null);
|
||||
|
||||
const remove = useMutation({
|
||||
mutationFn: (id: number) => ZoneDomainService.deleteById(zoneId, id),
|
||||
onSuccess: async () => {
|
||||
toast.success('域名已删除');
|
||||
toast.success(t('domainDeleted'));
|
||||
setDeleting(null);
|
||||
await onChanged();
|
||||
},
|
||||
onError: (error) =>
|
||||
toast.error(error instanceof Error ? error.message : '删除失败'),
|
||||
toast.error(error instanceof Error ? error.message : t('deleteFailed')),
|
||||
});
|
||||
|
||||
const certificateMap = useMemo(
|
||||
@@ -93,12 +98,12 @@ export function ZoneDomainsTable({
|
||||
onClick={() => setCreateOpen(true)}
|
||||
>
|
||||
<Plus className='mr-1 size-3.5' />
|
||||
添加域名
|
||||
{t('addDomain')}
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{domains.length === 0 ? (
|
||||
<EmptyStateWithBorder description='暂无已添加域名' />
|
||||
<EmptyStateWithBorder description={t('emptyDomains')} />
|
||||
) : (
|
||||
<div className='overflow-hidden rounded-lg border border-dashed shadow-none'>
|
||||
<TooltipProvider delayDuration={0}>
|
||||
@@ -109,13 +114,13 @@ export function ZoneDomainsTable({
|
||||
FQDN
|
||||
</TableHead>
|
||||
<TableHead className='h-8 whitespace-nowrap py-2 min-w-[120px]'>
|
||||
证书
|
||||
{t('certificate')}
|
||||
</TableHead>
|
||||
<TableHead className='h-8 whitespace-nowrap py-2 min-w-[200px]'>
|
||||
上游
|
||||
{t('upstream')}
|
||||
</TableHead>
|
||||
<TableHead className='sticky right-0 z-10 h-8 w-[90px] bg-background py-2 text-center'>
|
||||
操作
|
||||
{t('actions')}
|
||||
</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
@@ -125,11 +130,11 @@ export function ZoneDomainsTable({
|
||||
domain.proxy_route_id != null
|
||||
? routeMap.get(domain.proxy_route_id)
|
||||
: undefined;
|
||||
const upstreams = route ? getUpstreamLabels(route) : [];
|
||||
const upstreams = route ? getUpstreamLabels(route, tp) : [];
|
||||
const certLabel = domain.cert_id
|
||||
? (certificateMap.get(domain.cert_id)?.name ??
|
||||
`证书 #${domain.cert_id}`)
|
||||
: '未绑定';
|
||||
t('certNumber', { id: domain.cert_id }))
|
||||
: t('unbound');
|
||||
|
||||
return (
|
||||
<TableRow
|
||||
@@ -150,15 +155,15 @@ export function ZoneDomainsTable({
|
||||
<TableCell className='max-w-[280px] py-1'>
|
||||
{!domain.proxy_route_id ? (
|
||||
<span className='font-mono text-[10px] text-muted-foreground'>
|
||||
未关联路由
|
||||
{t('unlinkedRoute')}
|
||||
</span>
|
||||
) : routesLoading && !route ? (
|
||||
<span className='font-mono text-[10px] text-muted-foreground'>
|
||||
加载中…
|
||||
{t('loadingEllipsis')}
|
||||
</span>
|
||||
) : upstreams.length === 0 ? (
|
||||
<span className='font-mono text-[10px] text-muted-foreground'>
|
||||
未配置上游
|
||||
{t('upstreamUnset')}
|
||||
</span>
|
||||
) : (
|
||||
<div className='flex flex-col gap-0'>
|
||||
@@ -196,7 +201,7 @@ export function ZoneDomainsTable({
|
||||
</Button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent side='top' className='text-xs'>
|
||||
查看路由详情
|
||||
{t('viewRoute')}
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
) : null}
|
||||
@@ -213,7 +218,7 @@ export function ZoneDomainsTable({
|
||||
</Button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent side='top' className='text-xs'>
|
||||
删除域名
|
||||
{t('deleteDomain')}
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
</div>
|
||||
@@ -245,14 +250,14 @@ export function ZoneDomainsTable({
|
||||
>
|
||||
<AlertDialogContent>
|
||||
<AlertDialogHeader>
|
||||
<AlertDialogTitle>确认删除域名</AlertDialogTitle>
|
||||
<AlertDialogTitle>{t('deleteDomainTitle')}</AlertDialogTitle>
|
||||
<AlertDialogDescription>
|
||||
确认删除 {deleting?.domain} 吗?此操作不可撤销。
|
||||
{t('deleteDomainDesc', { domain: deleting?.domain ?? '' })}
|
||||
</AlertDialogDescription>
|
||||
</AlertDialogHeader>
|
||||
<AlertDialogFooter>
|
||||
<AlertDialogCancel disabled={remove.isPending}>
|
||||
取消
|
||||
{tc('cancel')}
|
||||
</AlertDialogCancel>
|
||||
<AlertDialogAction
|
||||
className='bg-destructive text-white hover:bg-destructive/90'
|
||||
@@ -264,7 +269,7 @@ export function ZoneDomainsTable({
|
||||
}
|
||||
}}
|
||||
>
|
||||
{remove.isPending ? '删除中…' : '确认删除'}
|
||||
{remove.isPending ? t('deleting') : t('confirmDelete')}
|
||||
</AlertDialogAction>
|
||||
</AlertDialogFooter>
|
||||
</AlertDialogContent>
|
||||
|
||||
@@ -23,18 +23,9 @@ import {
|
||||
zoneQueryKey,
|
||||
type ZoneItem,
|
||||
} from '@/lib/services/openflare';
|
||||
import { useTranslations } from 'next-intl';
|
||||
|
||||
const schema = z.object({
|
||||
domain: z
|
||||
.string()
|
||||
.trim()
|
||||
.min(1, '请输入 Zone 根域')
|
||||
.refine(
|
||||
(value) => !/[*/?#@]|:\/\//.test(value),
|
||||
'请输入不含协议或通配符的根域',
|
||||
),
|
||||
});
|
||||
type Values = z.infer<typeof schema>;
|
||||
type Values = { domain: string };
|
||||
|
||||
export function ZoneEditorDialog({
|
||||
open,
|
||||
@@ -45,7 +36,19 @@ export function ZoneEditorDialog({
|
||||
onOpenChange(open: boolean): void;
|
||||
zone?: ZoneItem | null;
|
||||
}) {
|
||||
const t = useTranslations('websites');
|
||||
const tc = useTranslations('common');
|
||||
const queryClient = useQueryClient();
|
||||
const schema = z.object({
|
||||
domain: z
|
||||
.string()
|
||||
.trim()
|
||||
.min(1, t('rootRequired'))
|
||||
.refine(
|
||||
(value) => !/[*/?#@]|:\/\//.test(value),
|
||||
t('rootInvalid'),
|
||||
),
|
||||
});
|
||||
const form = useForm<Values>({
|
||||
resolver: zodResolver(schema),
|
||||
defaultValues: { domain: '' },
|
||||
@@ -59,21 +62,19 @@ export function ZoneEditorDialog({
|
||||
? ZoneService.update(zone.id, { domain: values.domain.toLowerCase() })
|
||||
: ZoneService.create({ domain: values.domain.toLowerCase() }),
|
||||
onSuccess: async () => {
|
||||
toast.success(zone ? 'Zone 已更新' : 'Zone 已创建');
|
||||
toast.success(zone ? t('zoneUpdated') : t('zoneCreated'));
|
||||
await queryClient.invalidateQueries({ queryKey: zoneQueryKey });
|
||||
onOpenChange(false);
|
||||
},
|
||||
onError: (error) =>
|
||||
toast.error(error instanceof Error ? error.message : '保存失败'),
|
||||
toast.error(error instanceof Error ? error.message : t('saveFailed')),
|
||||
});
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={onOpenChange}>
|
||||
<DialogContent>
|
||||
<DialogHeader>
|
||||
<DialogTitle>{zone ? '编辑 Zone' : '新增 Zone'}</DialogTitle>
|
||||
<DialogDescription>
|
||||
Zone 仅接受可注册根域,例如 example.com。
|
||||
</DialogDescription>
|
||||
<DialogTitle>{zone ? t('editZone') : t('createZone')}</DialogTitle>
|
||||
<DialogDescription>{t('editorDesc')}</DialogDescription>
|
||||
</DialogHeader>
|
||||
<form
|
||||
id='zone-editor'
|
||||
@@ -83,7 +84,7 @@ export function ZoneEditorDialog({
|
||||
)}
|
||||
>
|
||||
<div className='space-y-1.5'>
|
||||
<Label htmlFor='zone-domain'>根域</Label>
|
||||
<Label htmlFor='zone-domain'>{t('rootDomain')}</Label>
|
||||
<Input
|
||||
id='zone-domain'
|
||||
placeholder='example.com'
|
||||
@@ -98,7 +99,7 @@ export function ZoneEditorDialog({
|
||||
</form>
|
||||
<DialogFooter>
|
||||
<Button variant='outline' onClick={() => onOpenChange(false)}>
|
||||
取消
|
||||
{tc('cancel')}
|
||||
</Button>
|
||||
<Button
|
||||
form='zone-editor'
|
||||
@@ -108,7 +109,7 @@ export function ZoneEditorDialog({
|
||||
{mutation.isPending && (
|
||||
<Loader2 className='mr-1 size-4 animate-spin' />
|
||||
)}
|
||||
{zone ? '保存修改' : '新增 Zone'}
|
||||
{zone ? t('saveChanges') : t('createZone')}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
|
||||
@@ -23,24 +23,7 @@ import {
|
||||
import { formatDateTime } from '@/lib/utils';
|
||||
import { formatBytes, formatCompactNumber } from '@/lib/utils/metrics';
|
||||
import { cn } from '@/lib/utils';
|
||||
|
||||
const rangeOptions: Array<{ value: ZoneStatsRange; label: string }> = [
|
||||
{ value: '24h', label: '24 小时' },
|
||||
{ value: '7d', label: '7 天' },
|
||||
{ value: '30d', label: '30 天' },
|
||||
];
|
||||
|
||||
const visitorsChartConfig = {
|
||||
value: { label: '分桶 UV', color: 'hsl(217 91% 60%)' },
|
||||
} satisfies ChartConfig;
|
||||
|
||||
const requestsChartConfig = {
|
||||
value: { label: '请求总数', color: 'hsl(217 91% 60%)' },
|
||||
} satisfies ChartConfig;
|
||||
|
||||
const bytesChartConfig = {
|
||||
value: { label: '已提供数据', color: 'hsl(217 91% 60%)' },
|
||||
} satisfies ChartConfig;
|
||||
import { useTranslations } from 'next-intl';
|
||||
|
||||
function formatAxisLabel(iso: string, range: ZoneStatsRange) {
|
||||
const date = new Date(iso);
|
||||
@@ -83,7 +66,23 @@ export function ZoneOverviewPanel({
|
||||
overview: ZoneOverview;
|
||||
zoneId: number;
|
||||
}) {
|
||||
const t = useTranslations('websites');
|
||||
const tc = useTranslations('common');
|
||||
const [range, setRange] = useState<ZoneStatsRange>('24h');
|
||||
const rangeOptions: Array<{ value: ZoneStatsRange; label: string }> = [
|
||||
{ value: '24h', label: t('range24h') },
|
||||
{ value: '7d', label: t('range7d') },
|
||||
{ value: '30d', label: t('range30d') },
|
||||
];
|
||||
const visitorsChartConfig = {
|
||||
value: { label: t('chartUv'), color: 'hsl(217 91% 60%)' },
|
||||
} satisfies ChartConfig;
|
||||
const requestsChartConfig = {
|
||||
value: { label: t('totalRequests'), color: 'hsl(217 91% 60%)' },
|
||||
} satisfies ChartConfig;
|
||||
const bytesChartConfig = {
|
||||
value: { label: t('chartBytes'), color: 'hsl(217 91% 60%)' },
|
||||
} satisfies ChartConfig;
|
||||
|
||||
const statsQuery = useQuery({
|
||||
queryKey: [...zoneQueryKey, zoneId, 'stats', range],
|
||||
@@ -143,7 +142,7 @@ export function ZoneOverviewPanel({
|
||||
</div>
|
||||
) : statsQuery.isError ? (
|
||||
<div className='rounded-xl border border-dashed p-6 text-center text-sm text-muted-foreground'>
|
||||
加载流量统计失败
|
||||
{t('statsFailed')}
|
||||
<div className='mt-3'>
|
||||
<Button
|
||||
size='sm'
|
||||
@@ -151,7 +150,7 @@ export function ZoneOverviewPanel({
|
||||
className='h-7 text-xs'
|
||||
onClick={() => void statsQuery.refetch()}
|
||||
>
|
||||
重试
|
||||
{tc('retry')}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
@@ -159,14 +158,14 @@ export function ZoneOverviewPanel({
|
||||
<div className='space-y-3'>
|
||||
{!stats?.available ? (
|
||||
<p className='text-xs text-muted-foreground'>
|
||||
分析存储暂不可用,图表可能为空。请确认 ClickHouse 已启用。
|
||||
{t('analyticsUnavailable')}
|
||||
</p>
|
||||
) : null}
|
||||
|
||||
<MetricTrendCard
|
||||
icon={Users}
|
||||
label='查询窗口独立访客'
|
||||
description='顶部合计为整窗去重;曲线为各时间桶内 UV,桶间不可相加'
|
||||
label={t('uniqueVisitors')}
|
||||
description={t('uniqueVisitorsDesc')}
|
||||
value={formatCompactNumber(stats?.unique_visitors ?? 0)}
|
||||
data={chartData}
|
||||
dataKey='visitors'
|
||||
@@ -175,7 +174,7 @@ export function ZoneOverviewPanel({
|
||||
/>
|
||||
<MetricTrendCard
|
||||
icon={Activity}
|
||||
label='请求总数'
|
||||
label={t('totalRequests')}
|
||||
value={formatCompactNumber(stats?.request_count ?? 0)}
|
||||
data={chartData}
|
||||
dataKey='requests'
|
||||
@@ -184,7 +183,7 @@ export function ZoneOverviewPanel({
|
||||
/>
|
||||
<MetricTrendCard
|
||||
icon={HardDrive}
|
||||
label='已提供的数据总计'
|
||||
label={t('totalBytes')}
|
||||
value={formatBytes(stats?.bytes_sent ?? 0, { zeroText: '0 B' })}
|
||||
data={chartData}
|
||||
dataKey='bytes'
|
||||
@@ -201,15 +200,15 @@ export function ZoneOverviewPanel({
|
||||
<div className='grid gap-4 md:grid-cols-3'>
|
||||
<Card className='border-dashed shadow-none md:col-span-3'>
|
||||
<CardHeader className='pb-2'>
|
||||
<CardTitle className='text-sm'>Zone 信息</CardTitle>
|
||||
<CardTitle className='text-sm'>{t('zoneInfo')}</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className='grid gap-3 text-sm sm:grid-cols-2'>
|
||||
<div>
|
||||
<p className='text-xs text-muted-foreground'>根域</p>
|
||||
<p className='text-xs text-muted-foreground'>{t('rootDomain')}</p>
|
||||
<p className='mt-1 font-medium'>{overview.zone.domain}</p>
|
||||
</div>
|
||||
<div>
|
||||
<p className='text-xs text-muted-foreground'>创建时间</p>
|
||||
<p className='text-xs text-muted-foreground'>{t('createdAt')}</p>
|
||||
<p className='mt-1'>{formatDateTime(overview.zone.created_at)}</p>
|
||||
</div>
|
||||
</CardContent>
|
||||
|
||||
Reference in New Issue
Block a user