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
@@ -1,5 +1,6 @@
'use client';
import { useTranslations } from 'next-intl';
import { useEffect, useMemo, useState } from 'react';
import { Button } from '@/components/ui/button';
@@ -43,6 +44,8 @@ export function GroupDialog({
pending: boolean;
onSubmit: (payload: CloudflareGroupPayload) => void;
}) {
const t = useTranslations('cloudflare.groupDialog');
const tCommon = useTranslations('common');
const edgeNodes = useMemo(
() => nodes.filter((node) => node.node_type === 'edge_node'),
[nodes],
@@ -66,14 +69,14 @@ export function GroupDialog({
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent>
<DialogHeader>
<DialogTitle>{group ? '编辑指向分组' : '新增指向分组'}</DialogTitle>
<DialogDescription>
一期使用主节点作为生效节点;备用节点仅保存,不会自动切换。
</DialogDescription>
<DialogTitle>
{group ? t('editTitle') : t('createTitle')}
</DialogTitle>
<DialogDescription>{t('description')}</DialogDescription>
</DialogHeader>
<FieldGroup>
<Field>
<FieldLabel htmlFor='cf-group-name'>分组名称</FieldLabel>
<FieldLabel htmlFor='cf-group-name'>{t('name')}</FieldLabel>
<Input
id='cf-group-name'
value={name}
@@ -81,10 +84,10 @@ export function GroupDialog({
/>
</Field>
<Field>
<FieldLabel htmlFor='cf-primary-node'>主节点</FieldLabel>
<FieldLabel htmlFor='cf-primary-node'>{t('primary')}</FieldLabel>
<Select value={primaryNodeID} onValueChange={setPrimaryNodeID}>
<SelectTrigger id='cf-primary-node' className='w-full'>
<SelectValue placeholder='选择带 IPv4 的边缘节点' />
<SelectValue placeholder={t('primaryPlaceholder')} />
</SelectTrigger>
<SelectContent>
<SelectGroup>
@@ -94,7 +97,7 @@ export function GroupDialog({
value={String(node.id)}
disabled={!node.ip}
>
{node.name} · {node.ip || '未配置 IP'}
{node.name} · {node.ip || t('noIp')}
</SelectItem>
))}
</SelectGroup>
@@ -102,19 +105,19 @@ export function GroupDialog({
</Select>
</Field>
<Field>
<FieldLabel htmlFor='cf-backup-node'>备用节点</FieldLabel>
<FieldLabel htmlFor='cf-backup-node'>{t('backup')}</FieldLabel>
<Select value={backupNodeID} onValueChange={setBackupNodeID}>
<SelectTrigger id='cf-backup-node' className='w-full'>
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectGroup>
<SelectItem value='none'>不设置</SelectItem>
<SelectItem value='none'>{t('none')}</SelectItem>
{edgeNodes
.filter((node) => String(node.id) !== primaryNodeID)
.map((node) => (
<SelectItem key={node.id} value={String(node.id)}>
{node.name} · {node.ip || '未配置 IP'}
{node.name} · {node.ip || t('noIp')}
</SelectItem>
))}
</SelectGroup>
@@ -123,7 +126,7 @@ export function GroupDialog({
</Field>
<Field orientation='horizontal'>
<FieldLabel htmlFor='cf-default-proxied'>
新成员默认开启橙云
{t('defaultProxied')}
</FieldLabel>
<Switch
id='cf-default-proxied'
@@ -132,7 +135,7 @@ export function GroupDialog({
/>
</Field>
<Field orientation='horizontal'>
<FieldLabel htmlFor='cf-enabled'>启用同步</FieldLabel>
<FieldLabel htmlFor='cf-enabled'>{t('enableSync')}</FieldLabel>
<Switch
id='cf-enabled'
checked={enabled}
@@ -142,7 +145,7 @@ export function GroupDialog({
</FieldGroup>
<DialogFooter>
<Button variant='outline' onClick={() => onOpenChange(false)}>
取消
{tCommon('cancel')}
</Button>
<Button
disabled={pending || !name.trim() || !primaryNodeID}
@@ -157,7 +160,7 @@ export function GroupDialog({
})
}
>
保存
{t('save')}
</Button>
</DialogFooter>
</DialogContent>
@@ -1,5 +1,6 @@
'use client';
import { useTranslations } from 'next-intl';
import { useEffect, useMemo, useState } from 'react';
import { Check, ChevronDown, Search } from 'lucide-react';
@@ -72,6 +73,8 @@ export function MemberAddDialog({
pending: boolean;
onSubmit: (zoneDomainIDs: number[], proxied: boolean) => void;
}) {
const t = useTranslations('cloudflare.memberDialog');
const tCommon = useTranslations('common');
const [keyword, setKeyword] = useState('');
const [selectedIDs, setSelectedIDs] = useState<number[]>([]);
const [proxied, setProxied] = useState(defaultProxied);
@@ -157,15 +160,14 @@ export function MemberAddDialog({
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent className='sm:max-w-lg'>
<DialogHeader>
<DialogTitle>添加域名成员</DialogTitle>
<DialogDescription>
支持搜索筛选与批量勾选;加入后会创建或接管唯一同名 A 记录,多条同名
A 会拒绝同步。
</DialogDescription>
<DialogTitle>{t('title')}</DialogTitle>
<DialogDescription>{t('description')}</DialogDescription>
</DialogHeader>
<FieldGroup>
<Field>
<FieldLabel htmlFor='cf-domain-search'>Zone 域名</FieldLabel>
<FieldLabel htmlFor='cf-domain-search'>
{t('zoneDomain')}
</FieldLabel>
<div className='space-y-2'>
<div className='relative'>
<Search className='pointer-events-none absolute top-1/2 left-2.5 size-4 -translate-y-1/2 text-muted-foreground' />
@@ -173,7 +175,7 @@ export function MemberAddDialog({
id='cf-domain-search'
value={keyword}
onChange={(event) => setKeyword(event.target.value)}
placeholder='搜索域名或顶级域…'
placeholder={t('searchPlaceholder')}
className='pl-8'
disabled={pending}
/>
@@ -182,9 +184,9 @@ export function MemberAddDialog({
<div className='flex flex-wrap items-center justify-between gap-2'>
<div className='flex items-center gap-2 text-xs text-muted-foreground'>
<Badge variant='secondary' className='font-normal'>
已选 {selectedIDs.length}
{t('selected', { count: selectedIDs.length })}
</Badge>
<span>可见 {visibleIDs.length}</span>
<span>{t('visible', { count: visibleIDs.length })}</span>
</div>
<div className='flex items-center gap-1'>
<Button
@@ -195,7 +197,7 @@ export function MemberAddDialog({
disabled={pending || visibleIDs.length === 0}
onClick={toggleAllVisible}
>
{allVisibleSelected ? '取消全选' : '全选可见'}
{allVisibleSelected ? t('unselectAll') : t('selectVisible')}
</Button>
<Button
type='button'
@@ -205,18 +207,18 @@ export function MemberAddDialog({
disabled={pending || selectedIDs.length === 0}
onClick={() => setSelectedIDs([])}
>
清空
{t('clear')}
</Button>
</div>
</div>
{domains.length === 0 ? (
<div className='rounded-lg border border-dashed px-4 py-8 text-center text-sm text-muted-foreground'>
暂无可用域名(均已加入分组,或尚未在网站管理中添加)。
{t('noAvailable')}
</div>
) : filteredGroups.length === 0 ? (
<div className='rounded-lg border border-dashed px-4 py-8 text-center text-sm text-muted-foreground'>
没有匹配的域名
{t('noMatch')}
</div>
) : (
<div className='max-h-72 space-y-1 overflow-y-auto rounded-lg border p-2'>
@@ -233,7 +235,7 @@ export function MemberAddDialog({
disabled={pending}
onCheckedChange={toggleAllVisible}
/>
<span>按顶级域分组</span>
<span>{t('groupByZone')}</span>
</label>
{filteredGroups.map((group) => {
@@ -270,7 +272,9 @@ export function MemberAddDialog({
}
disabled={pending}
onCheckedChange={() => toggleGroup(group)}
aria-label={`选择顶级域 ${group.zoneDomain}`}
aria-label={t('selectZone', {
name: group.zoneDomain,
})}
className='ml-1'
/>
<CollapsibleTrigger asChild>
@@ -330,7 +334,7 @@ export function MemberAddDialog({
variant='secondary'
className='shrink-0 text-[10px] font-normal'
>
顶级域
{t('zone')}
</Badge>
) : null}
{checked ? (
@@ -350,7 +354,7 @@ export function MemberAddDialog({
</div>
</Field>
<Field orientation='horizontal'>
<FieldLabel htmlFor='cf-member-proxied'>开启橙云代理</FieldLabel>
<FieldLabel htmlFor='cf-member-proxied'>{t('proxied')}</FieldLabel>
<Switch
id='cf-member-proxied'
checked={proxied}
@@ -365,7 +369,7 @@ export function MemberAddDialog({
disabled={pending}
onClick={() => onOpenChange(false)}
>
取消
{tCommon('cancel')}
</Button>
<Button
disabled={pending || selectedIDs.length === 0}
@@ -377,8 +381,8 @@ export function MemberAddDialog({
}
>
{selectedIDs.length > 1
? `添加并同步(${selectedIDs.length})`
: '添加并同步'}
? t('submitCount', { count: selectedIDs.length })
: t('submit')}
</Button>
</DialogFooter>
</DialogContent>
@@ -9,6 +9,7 @@ import {
RefreshCw,
RotateCcw,
} from 'lucide-react';
import { useTranslations } from 'next-intl';
import { useMemo, useState } from 'react';
import { toast } from 'sonner';
@@ -109,20 +110,6 @@ async function listCloudflareSyncExecutions(options: {
};
}
const STATUS_LABELS: Record<TaskExecutionStatus, string> = {
pending: '等待中',
running: '执行中',
succeeded: '成功',
failed: '失败',
};
const TRIGGER_LABELS: Record<string, string> = {
system: '系统',
manual: '手动',
retry: '重试',
schedule: '定时',
};
function formatDateTime(value?: string | null) {
if (!value) return '-';
const date = new Date(value);
@@ -143,6 +130,7 @@ function statusVariant(status: TaskExecutionStatus) {
}
export function SyncTasksPanel() {
const t = useTranslations('cloudflare.tasks');
const queryClient = useQueryClient();
const [page, setPage] = useState(1);
const [status, setStatus] = useState<TaskExecutionStatus | 'all'>('all');
@@ -175,16 +163,16 @@ export function SyncTasksPanel() {
const retryMutation = useMutation({
mutationFn: (id: string) => AdminTaskService.retryTaskExecution(id),
onSuccess: (taskID) => {
toast.success('同步任务已重新下发', {
description: `新任务 ID:${taskID}`,
toast.success(t('retried'), {
description: t('newTaskId', { id: taskID }),
});
void queryClient.invalidateQueries({
queryKey: [...cloudflareQueryKey, 'sync-executions'],
});
},
onError: (err: Error) => {
toast.error('任务重试失败', {
description: err.message || '未知错误',
toast.error(t('retryFailed'), {
description: err.message || t('unknownError'),
});
},
});
@@ -197,6 +185,18 @@ export function SyncTasksPanel() {
() => Math.max(1, Math.ceil(total / PAGE_SIZE)),
[total],
);
const statusLabels: Record<TaskExecutionStatus, string> = {
pending: t('pending'),
running: t('running'),
succeeded: t('succeeded'),
failed: t('failed'),
};
const triggerLabels: Record<string, string> = {
system: t('system'),
manual: t('manual'),
retry: t('retry'),
schedule: t('schedule'),
};
const openDetail = (execution: TaskExecution) => {
setPreview(execution);
@@ -210,12 +210,9 @@ export function SyncTasksPanel() {
<div className='space-y-1'>
<CardTitle className='flex items-center gap-2 text-base'>
<Activity className='size-4 text-primary' />
同步任务
{t('title')}
</CardTitle>
<CardDescription>
仅展示 Cloudflare
域名同步(sync_member)与分组同步(sync_group)的执行记录。
</CardDescription>
<CardDescription>{t('description')}</CardDescription>
</div>
<div className='flex flex-wrap items-center gap-2'>
<Select
@@ -226,14 +223,14 @@ export function SyncTasksPanel() {
}}
>
<SelectTrigger size='sm' className='w-[120px]'>
<SelectValue placeholder='状态' />
<SelectValue placeholder={t('statusPlaceholder')} />
</SelectTrigger>
<SelectContent>
<SelectItem value='all'>全部状态</SelectItem>
<SelectItem value='pending'>等待中</SelectItem>
<SelectItem value='running'>执行中</SelectItem>
<SelectItem value='succeeded'>成功</SelectItem>
<SelectItem value='failed'>失败</SelectItem>
<SelectItem value='all'>{t('allStatus')}</SelectItem>
<SelectItem value='pending'>{t('pending')}</SelectItem>
<SelectItem value='running'>{t('running')}</SelectItem>
<SelectItem value='succeeded'>{t('succeeded')}</SelectItem>
<SelectItem value='failed'>{t('failed')}</SelectItem>
</SelectContent>
</Select>
<Button
@@ -247,7 +244,7 @@ export function SyncTasksPanel() {
) : (
<RefreshCw data-icon='inline-start' />
)}
刷新
{t('refresh')}
</Button>
</div>
</CardHeader>
@@ -257,32 +254,44 @@ export function SyncTasksPanel() {
message={
executionsQuery.error instanceof Error
? executionsQuery.error.message
: '加载同步任务失败'
: t('loadFailed')
}
onRetry={() => void executionsQuery.refetch()}
/>
) : loading && executions.length === 0 ? (
<LoadingStateWithBorder
icon={Activity}
description='加载同步任务中...'
description={t('loading')}
/>
) : executions.length === 0 ? (
<EmptyStateWithBorder
icon={Activity}
title='暂无同步任务'
description='添加域名或同步分组后,这里会显示 Cloudflare 域名 / 分组同步执行记录。'
title={t('emptyTitle')}
description={t('emptyDesc')}
/>
) : (
<div className='rounded-lg border'>
<Table className='min-w-[720px]'>
<TableHeader>
<TableRow className='hover:bg-transparent'>
<TableHead className='w-[200px]'>任务</TableHead>
<TableHead className='w-[100px]'>状态</TableHead>
<TableHead className='w-[100px]'>触发</TableHead>
<TableHead className='w-[100px]'>耗时</TableHead>
<TableHead className='min-w-[180px]'>结果/错误</TableHead>
<TableHead className='w-[160px]'>创建时间</TableHead>
<TableHead className='w-[200px]'>
{t('columns.task')}
</TableHead>
<TableHead className='w-[100px]'>
{t('columns.status')}
</TableHead>
<TableHead className='w-[100px]'>
{t('columns.trigger')}
</TableHead>
<TableHead className='w-[100px]'>
{t('columns.duration')}
</TableHead>
<TableHead className='min-w-[180px]'>
{t('columns.result')}
</TableHead>
<TableHead className='w-[160px]'>
{t('columns.createdAt')}
</TableHead>
</TableRow>
</TableHeader>
<TableBody>
@@ -304,12 +313,12 @@ export function SyncTasksPanel() {
</TableCell>
<TableCell>
<Badge variant={statusVariant(execution.status)}>
{STATUS_LABELS[execution.status] || execution.status}
{statusLabels[execution.status] || execution.status}
</Badge>
</TableCell>
<TableCell>
<Badge variant='outline'>
{TRIGGER_LABELS[execution.triggered_by] ||
{triggerLabels[execution.triggered_by] ||
execution.triggered_by}
</Badge>
</TableCell>
@@ -331,7 +340,7 @@ export function SyncTasksPanel() {
<div className='flex items-center justify-between gap-2'>
<div className='text-xs text-muted-foreground'>
共 {total} 条,第 {page}/{totalPages} 页
{t('pageSummary', { total, page, pages: totalPages })}
</div>
<div className='flex items-center gap-2'>
<Button
@@ -341,7 +350,7 @@ export function SyncTasksPanel() {
disabled={page <= 1 || loading}
>
<ChevronLeft className='size-4' />
上一页
{t('prev')}
</Button>
<Button
variant='outline'
@@ -349,7 +358,7 @@ export function SyncTasksPanel() {
onClick={() => setPage((p) => Math.min(totalPages, p + 1))}
disabled={page >= totalPages || loading}
>
下一页
{t('next')}
<ChevronRight className='size-4' />
</Button>
</div>
@@ -359,43 +368,51 @@ export function SyncTasksPanel() {
<Sheet open={detailOpen} onOpenChange={setDetailOpen}>
<SheetContent className='w-full p-0 sm:max-w-[560px]'>
<SheetHeader className='border-b'>
<SheetTitle>同步任务详情</SheetTitle>
<SheetTitle>{t('detailTitle')}</SheetTitle>
<SheetDescription>
{selected?.task_name || selected?.task_type || 'Cloudflare 同步'}
{selected?.task_name || selected?.task_type || t('defaultName')}
</SheetDescription>
</SheetHeader>
<div className='flex-1 space-y-4 overflow-y-auto px-4 py-4'>
{detailQuery.isFetching && !selected ? (
<LoadingStateWithBorder
icon={Activity}
description='加载任务详情中...'
description={t('loadingDetail')}
/>
) : selected ? (
<>
<div className='grid grid-cols-2 gap-3'>
<div className='rounded-lg border p-3'>
<div className='text-xs text-muted-foreground'>状态</div>
<div className='text-xs text-muted-foreground'>
{t('status')}
</div>
<div className='mt-2'>
<Badge variant={statusVariant(selected.status)}>
{STATUS_LABELS[selected.status] || selected.status}
{statusLabels[selected.status] || selected.status}
</Badge>
</div>
</div>
<div className='rounded-lg border p-3'>
<div className='text-xs text-muted-foreground'>触发</div>
<div className='text-xs text-muted-foreground'>
{t('trigger')}
</div>
<div className='mt-2 text-sm font-medium'>
{TRIGGER_LABELS[selected.triggered_by] ||
{triggerLabels[selected.triggered_by] ||
selected.triggered_by}
</div>
</div>
<div className='rounded-lg border p-3'>
<div className='text-xs text-muted-foreground'>重试</div>
<div className='text-xs text-muted-foreground'>
{t('retries')}
</div>
<div className='mt-2 font-mono text-sm'>
{selected.retry_count}/{selected.max_retry}
</div>
</div>
<div className='rounded-lg border p-3'>
<div className='text-xs text-muted-foreground'>耗时</div>
<div className='text-xs text-muted-foreground'>
{t('duration')}
</div>
<div className='mt-2 font-mono text-sm'>
{formatDuration(selected.duration)}
</div>
@@ -403,7 +420,9 @@ export function SyncTasksPanel() {
</div>
<div className='space-y-1'>
<div className='text-xs text-muted-foreground'>任务 ID</div>
<div className='text-xs text-muted-foreground'>
{t('taskId')}
</div>
<div className='rounded-md border bg-muted/40 px-3 py-2 font-mono text-xs break-all'>
{selected.task_id}
</div>
@@ -412,7 +431,7 @@ export function SyncTasksPanel() {
<div className='grid gap-3 sm:grid-cols-2'>
<div className='space-y-1'>
<div className='text-xs text-muted-foreground'>
创建时间
{t('createdAt')}
</div>
<div className='font-mono text-xs'>
{formatDateTime(selected.created_at)}
@@ -420,7 +439,7 @@ export function SyncTasksPanel() {
</div>
<div className='space-y-1'>
<div className='text-xs text-muted-foreground'>
结束时间
{t('finishedAt')}
</div>
<div className='font-mono text-xs'>
{formatDateTime(selected.finished_at)}
@@ -429,7 +448,9 @@ export function SyncTasksPanel() {
</div>
<div className='space-y-1'>
<div className='text-xs text-muted-foreground'>执行结果</div>
<div className='text-xs text-muted-foreground'>
{t('result')}
</div>
<div className='min-h-10 rounded-md border bg-muted/30 px-3 py-2 text-sm whitespace-pre-wrap break-all'>
{selected.result || '-'}
</div>
@@ -438,7 +459,7 @@ export function SyncTasksPanel() {
{selected.error_message ? (
<div className='space-y-1'>
<div className='text-xs text-muted-foreground'>
错误信息
{t('error')}
</div>
<div className='rounded-md border border-destructive/30 bg-destructive/5 px-3 py-2 text-sm text-destructive whitespace-pre-wrap break-all'>
{selected.error_message}
@@ -456,7 +477,7 @@ export function SyncTasksPanel() {
{selected.log ? (
<div className='space-y-1'>
<div className='text-xs text-muted-foreground'>
执行日志
{t('logs')}
</div>
<pre className='max-h-48 overflow-auto rounded-md border bg-muted/40 p-3 text-xs leading-relaxed'>
{selected.log}
@@ -478,11 +499,11 @@ export function SyncTasksPanel() {
) : (
<RotateCcw data-icon='inline-start' />
)}
重试
{t('retry')}
</Button>
) : null}
<Button variant='outline' onClick={() => setDetailOpen(false)}>
关闭
{t('close')}
</Button>
</SheetFooter>
</SheetContent>