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
@@ -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>
@@ -26,6 +26,7 @@ import {
ZoneService,
zoneQueryKey,
} from '@/lib/services/openflare';
import { useTranslations } from 'next-intl';
import { getErrorMessage } from '../components/website-utils';
import { ZoneCertificatesPanel } from './components/zone-certificates';
@@ -48,6 +49,8 @@ function getZoneIdFromPathname(pathname: string | null): number {
}
export function ZonePageClient() {
const t = useTranslations('websites');
const tc = useTranslations('common');
const [mounted, setMounted] = useState(false);
useEffect(() => {
setMounted(true);
@@ -103,11 +106,12 @@ export function ZonePageClient() {
const deleteZone = useMutation({
mutationFn: () => ZoneService.deleteById(zoneId),
onSuccess: async () => {
toast.success('Zone 已删除');
toast.success(t('zoneDeleted'));
await queryClient.invalidateQueries({ queryKey: zoneQueryKey });
window.location.assign('/websites');
},
onError: (error) => toast.error(getErrorMessage(error)),
onError: (error) =>
toast.error(getErrorMessage(error, t('requestFailed'))),
});
if (!mounted) {
@@ -115,7 +119,7 @@ export function ZonePageClient() {
<div className='py-6 px-1'>
<LoadingStateWithBorder
icon={Globe}
description='加载 Zone 详情中...'
description={t('loadingDetail')}
/>
</div>
);
@@ -126,7 +130,7 @@ export function ZonePageClient() {
<div className='py-6 px-1'>
<EmptyStateWithBorder
icon={Globe}
description='无效的网站 ID,请从网站列表进入详情页。'
description={t('invalidId')}
/>
</div>
);
@@ -137,7 +141,7 @@ export function ZonePageClient() {
<div className='py-6 px-1'>
<LoadingStateWithBorder
icon={Globe}
description='加载 Zone 详情中...'
description={t('loadingDetail')}
/>
</div>
);
@@ -153,11 +157,11 @@ export function ZonePageClient() {
onClick={() => router.back()}
>
<ArrowLeft className='size-3.5' />
返回
{t('back')}
</Button>
<EmptyStateWithBorder
icon={Globe}
description='网站不存在,可能已被删除或 ID 无效。'
description={t('notFound')}
/>
</div>
);
@@ -182,7 +186,7 @@ export function ZonePageClient() {
onClick={() => router.back()}
>
<ArrowLeft className='size-3.5' />
返回
{t('back')}
</Button>
<div className='flex items-center gap-2'>
@@ -191,7 +195,7 @@ export function ZonePageClient() {
<h1 className='text-2xl font-semibold tracking-tight'>
{overview.zone.domain}
</h1>
<p className='text-sm text-muted-foreground'>Zone 网站管理</p>
<p className='text-sm text-muted-foreground'>{t('detailSubtitle')}</p>
</div>
</div>
</div>
@@ -202,25 +206,25 @@ export function ZonePageClient() {
value='overview'
className='px-0 pb-2 text-xs font-semibold'
>
概览
{t('tabOverview')}
</TabsTrigger>
<TabsTrigger
value='domains'
className='px-0 pb-2 text-xs font-semibold'
>
域名 ({overview.domains.length})
{t('tabDomains', { count: overview.domains.length })}
</TabsTrigger>
<TabsTrigger
value='certificates'
className='px-0 pb-2 text-xs font-semibold'
>
证书 ({boundCertCount})
{t('tabCertificates', { count: boundCertCount })}
</TabsTrigger>
<TabsTrigger
value='settings'
className='px-0 pb-2 text-xs font-semibold'
>
设置
{tc('settings')}
</TabsTrigger>
</TabsList>
@@ -247,19 +251,19 @@ export function ZonePageClient() {
<TabsContent value='settings'>
<div className='space-y-4'>
<div className='rounded-lg border p-4'>
<p className='text-sm font-semibold'>基本信息</p>
<p className='text-sm font-semibold'>{t('basicInfo')}</p>
<p className='mt-1 text-sm text-muted-foreground'>
维护 Zone 的可注册根域。根域变更后,请确认其下域名仍归属该根域。
{t('basicInfoDesc')}
</p>
<div className='mt-3 grid gap-2 text-sm sm:grid-cols-2'>
<div>
<p className='text-xs text-muted-foreground'>当前根域</p>
<p className='text-xs text-muted-foreground'>{t('currentRoot')}</p>
<p className='mt-1 font-mono text-[13px] font-medium'>
{overview.zone.domain}
</p>
</div>
<div>
<p className='text-xs text-muted-foreground'>域名数量</p>
<p className='text-xs text-muted-foreground'>{t('domainCount')}</p>
<p className='mt-1 font-mono text-[13px] font-medium'>
{overview.domains.length}
</p>
@@ -272,14 +276,14 @@ export function ZonePageClient() {
onClick={() => setEditZone(true)}
>
<Pencil className='mr-1 size-3.5' />
编辑根域
{t('editRoot')}
</Button>
</div>
<div className='rounded-lg border border-destructive/30 p-4'>
<p className='text-sm font-semibold text-destructive'>危险操作</p>
<p className='text-sm font-semibold text-destructive'>{t('dangerZone')}</p>
<p className='mt-1 text-sm text-muted-foreground'>
删除 Zone 前须清空其下全部域名;删除后不可恢复。
{t('dangerZoneDesc')}
</p>
<Button
variant='destructive'
@@ -288,7 +292,7 @@ export function ZonePageClient() {
onClick={() => setConfirmDelete(true)}
>
<Trash2 className='mr-1 size-3.5' />
删除 Zone
{t('deleteZone')}
</Button>
</div>
</div>
@@ -304,15 +308,14 @@ export function ZonePageClient() {
<AlertDialog open={confirmDelete} onOpenChange={setConfirmDelete}>
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle>确认删除 Zone</AlertDialogTitle>
<AlertDialogTitle>{t('deleteZoneTitle')}</AlertDialogTitle>
<AlertDialogDescription>
确认删除 {overview.zone.domain}{' '}
吗?其下域名须先解绑路由后才能删除,此操作不可撤销。
{t('deleteZoneDesc', { domain: overview.zone.domain })}
</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel disabled={deleteZone.isPending}>
取消
{tc('cancel')}
</AlertDialogCancel>
<AlertDialogAction
className='bg-destructive text-white hover:bg-destructive/90'
@@ -322,7 +325,7 @@ export function ZonePageClient() {
deleteZone.mutate();
}}
>
{deleteZone.isPending ? '删除中…' : '确认删除'}
{deleteZone.isPending ? t('deleting') : t('confirmDelete')}
</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
@@ -1,5 +1,6 @@
import { Suspense } from 'react';
import { Globe } from 'lucide-react';
import { getTranslations } from 'next-intl/server';
import { LoadingStateWithBorder } from '@/components/layout/loading';
import { ZonePageClient } from './page-client';
@@ -8,13 +9,14 @@ export async function generateStaticParams() {
}
export default async function ZonePage() {
const t = await getTranslations('websites');
return (
<Suspense
fallback={
<div className='py-6 px-1'>
<LoadingStateWithBorder
icon={Globe}
description='加载 Zone 详情中...'
description={t('loadingDetail')}
/>
</div>
}