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
@@ -4,6 +4,7 @@
'use client';
import * as React from 'react';
import { useTranslations } from 'next-intl';
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
import { toast } from 'sonner';
import {
@@ -80,6 +81,7 @@ import type {
import { PushService } from '@/lib/services/push';
export function EventsTab() {
const t = useTranslations('admin.push.events');
const queryClient = useQueryClient();
const [deleteTarget, setDeleteTarget] = React.useState<PushEvent | null>(
null,
@@ -120,12 +122,12 @@ export function EventsTab() {
mutationFn: ({ id, data }: { id: number; data: UpdatePushEventRequest }) =>
PushService.updateEvent(id, data),
onSuccess: () => {
toast.success('事件更新成功');
toast.success(t('eventUpdateSuccess'));
queryClient.invalidateQueries({ queryKey: ['admin', 'push-events'] });
setEditEventOpen(false);
},
onError: (err: unknown) => {
toast.error('事件更新失败: ' + (err as Error).message);
toast.error(t('eventUpdateFailed') + ': ' + (err as Error).message);
},
});
@@ -135,14 +137,14 @@ export function EventsTab() {
queryClient.invalidateQueries({ queryKey: ['admin', 'push-events'] });
},
onError: (err: unknown) => {
toast.error('操作失败: ' + (err as Error).message);
toast.error(t('operationFailed') + ': ' + (err as Error).message);
},
});
const createEventMutation = useMutation({
mutationFn: (data: CreatePushEventRequest) => PushService.createEvent(data),
onSuccess: () => {
toast.success('事件创建成功');
toast.success(t('eventCreateSuccess'));
queryClient.invalidateQueries({ queryKey: ['admin', 'push-events'] });
setCreateEventOpen(false);
setNewEventKey('');
@@ -152,7 +154,7 @@ export function EventsTab() {
setNewEventEnabled(true);
},
onError: (err: unknown) => {
toast.error('事件创建失败: ' + (err as Error).message);
toast.error(t('eventCreateFailed') + ': ' + (err as Error).message);
},
});
@@ -160,11 +162,11 @@ export function EventsTab() {
mutationFn: (id: number) => PushService.deleteEvent(id),
onSuccess: () => {
setDeleteTarget(null);
toast.success('配置删除成功');
toast.success(t('configDeleteSuccess'));
queryClient.invalidateQueries({ queryKey: ['admin', 'push-events'] });
},
onError: (err: unknown) => {
toast.error('配置删除失败: ' + (err as Error).message);
toast.error(t('configDeleteFailed') + ': ' + (err as Error).message);
},
});
@@ -212,7 +214,7 @@ export function EventsTab() {
try {
JSON.parse(eventTemplate);
} catch {
toast.error('消息模板不是合法的 JSON 格式');
toast.error(t('templateInvalidJson'));
return;
}
@@ -260,8 +262,8 @@ export function EventsTab() {
);
if (taskMeta) {
const defaultTemplate = {
title: `任务完成: ${taskMeta.name}`,
content: `异步任务 {{task_name}} 已完成。状态: {{task_status}},耗时: {{task_duration}} ms。`,
title: t('taskCompleteTitle', { name: taskMeta.name }),
content: t('taskCompleteContent'),
level: 'INFO',
};
setNewEventTemplate(JSON.stringify(defaultTemplate, null, 2));
@@ -272,11 +274,11 @@ export function EventsTab() {
const handleCreateEvent = () => {
if (newEventType === 'builtin' && !newEventKey) {
toast.error('请选择系统事件');
toast.error(t('selectSystemEvent'));
return;
}
if (newEventType === 'task' && !newEventTaskType) {
toast.error('请选择异步任务');
toast.error(t('selectAsyncTask'));
return;
}
@@ -284,7 +286,7 @@ export function EventsTab() {
try {
JSON.parse(newEventTemplate);
} catch {
toast.error('内容渲染模板不是合法的 JSON 格式');
toast.error(t('templateInvalidJson'));
return;
}
}
@@ -309,11 +311,11 @@ export function EventsTab() {
<div className='flex justify-end'>
<Button size='sm' onClick={handleCreateEventClick} className='text-xs'>
<Plus className='size-3.5 mr-1' />
新增通知事件
{t('addEvent')}
</Button>
</div>
{eventsQuery.isLoading ? (
<LoadingStateWithBorder icon={Layers} description='加载通知事件中...' />
<LoadingStateWithBorder icon={Layers} description={t('loadingEvents')} />
) : eventsQuery.isError ? (
<div className='p-8 border border-dashed rounded-xl bg-card'>
<ErrorInline
@@ -325,7 +327,7 @@ export function EventsTab() {
) : (eventsQuery.data ?? []).length === 0 ? (
<EmptyStateWithBorder
icon={Layers}
description='暂无通知配置事件,请点击右上角新增'
description={t('noEvents')}
/>
) : (
<div className='border border-dashed shadow-none rounded-lg overflow-hidden'>
@@ -336,19 +338,19 @@ export function EventsTab() {
ID
</TableHead>
<TableHead className='w-[180px] whitespace-nowrap py-2 h-8'>
通知事件
{t('colEvent')}
</TableHead>
<TableHead className='w-[200px] whitespace-nowrap py-2 h-8'>
关联渠道
{t('colChannels')}
</TableHead>
<TableHead className='whitespace-nowrap py-2 h-8'>
推送目标
{t('colTargets')}
</TableHead>
<TableHead className='w-[80px] text-center whitespace-nowrap py-2 h-8'>
状态
{t('colStatus')}
</TableHead>
<TableHead className='sticky right-0 text-center bg-background z-10 w-[110px] py-2 h-8'>
操作
{t('colActions')}
</TableHead>
</TableRow>
</TableHeader>
@@ -376,7 +378,7 @@ export function EventsTab() {
variant='outline'
className='text-[8px] h-3.5 px-1 bg-blue-50/50 text-blue-600 border-blue-200'
>
任务
{t('taskBadge')}
</Badge>
)}
</div>
@@ -389,7 +391,7 @@ export function EventsTab() {
<div className='flex flex-wrap gap-1'>
{(event.channels ?? []).length === 0 ? (
<span className='text-xs text-muted-foreground italic'>
未指定渠道
{t('noChannelSpecified')}
</span>
) : (
event.channels.map((ch) => (
@@ -398,7 +400,7 @@ export function EventsTab() {
variant='secondary'
className='text-[10px] py-0 px-1.5 h-4.5'
>
{ch === 'email' ? '邮件' : ch}
{ch === 'email' ? t('emailChannel') : ch}
</Badge>
))
)}
@@ -453,7 +455,7 @@ export function EventsTab() {
</Button>
</TooltipTrigger>
<TooltipContent side='top' className='text-xs'>
配置
{t('configure')}
</TooltipContent>
</Tooltip>
</TooltipProvider>
@@ -472,7 +474,7 @@ export function EventsTab() {
</Button>
</TooltipTrigger>
<TooltipContent side='top' className='text-xs'>
删除
{t('delete')}
</TooltipContent>
</Tooltip>
</TooltipProvider>
@@ -489,15 +491,15 @@ export function EventsTab() {
<Dialog open={createEventOpen} onOpenChange={setCreateEventOpen}>
<DialogContent className='sm:max-w-[550px] max-h-[85vh] overflow-y-auto'>
<DialogHeader>
<DialogTitle>新增通知事件</DialogTitle>
<DialogTitle>{t('addEventDialogTitle')}</DialogTitle>
<DialogDescription>
选择系统内置的事件并绑定推送渠道,创建后即可开始生效
{t('addEventDialogDesc')}
</DialogDescription>
</DialogHeader>
<div className='space-y-4 py-4'>
<div className='space-y-1.5'>
<Label className='text-xs font-semibold'>事件类型</Label>
<Label className='text-xs font-semibold'>{t('eventType')}</Label>
<div className='flex gap-4 p-1.5 border rounded-md bg-muted/20'>
<label className='flex items-center gap-1.5 text-xs cursor-pointer font-medium'>
<input
@@ -511,7 +513,7 @@ export function EventsTab() {
}}
className='scale-90'
/>
<span>系统内置事件</span>
<span>{t('builtinEventType')}</span>
</label>
<label className='flex items-center gap-1.5 text-xs cursor-pointer font-medium'>
<input
@@ -525,22 +527,22 @@ export function EventsTab() {
}}
className='scale-90'
/>
<span>任务完成事件</span>
<span>{t('taskEventType')}</span>
</label>
</div>
</div>
{newEventType === 'builtin' ? (
<div className='space-y-1.5'>
<Label className='text-xs font-semibold'>系统事件</Label>
<Label className='text-xs font-semibold'>{t('systemEvent')}</Label>
{builtInEventsQuery.isLoading ? (
<div className='flex items-center gap-2 text-xs text-muted-foreground'>
<Loader2 className='size-3.5 animate-spin' />
<span>加载系统事件中...</span>
<span>{t('loadingSystemEvents')}</span>
</div>
) : availableBuiltInEvents.length === 0 ? (
<div className='text-xs text-muted-foreground italic border p-2.5 rounded bg-muted/20'>
所有内置事件都已配置,没有可新增的事件。
{t('allBuiltinEventsConfigured')}
</div>
) : (
<Select
@@ -548,7 +550,9 @@ export function EventsTab() {
onValueChange={handleNewEventKeyChange}
>
<SelectTrigger className='text-xs h-9'>
<SelectValue placeholder='请选择系统事件' />
<SelectValue
placeholder={t('selectSystemEventPlaceholder')}
/>
</SelectTrigger>
<SelectContent>
{availableBuiltInEvents.map((ev) => (
@@ -566,15 +570,17 @@ export function EventsTab() {
</div>
) : (
<div className='space-y-1.5'>
<Label className='text-xs font-semibold'>系统异步任务</Label>
<Label className='text-xs font-semibold'>
{t('systemAsyncTask')}
</Label>
{taskTypesQuery.isLoading ? (
<div className='flex items-center gap-2 text-xs text-muted-foreground'>
<Loader2 className='size-3.5 animate-spin' />
<span>加载异步任务中...</span>
<span>{t('loadingAsyncTasks')}</span>
</div>
) : (taskTypesQuery.data ?? []).length === 0 ? (
<div className='text-xs text-muted-foreground italic border p-2.5 rounded bg-muted/20'>
暂无可用的系统任务。
{t('noAvailableTasks')}
</div>
) : (
<Select
@@ -582,7 +588,9 @@ export function EventsTab() {
onValueChange={handleNewEventTaskTypeChange}
>
<SelectTrigger className='text-xs h-9'>
<SelectValue placeholder='请选择异步任务' />
<SelectValue
placeholder={t('selectAsyncTaskPlaceholder')}
/>
</SelectTrigger>
<SelectContent>
{(taskTypesQuery.data ?? []).map((taskMeta) => (
@@ -603,11 +611,11 @@ export function EventsTab() {
{newEventType === 'builtin' && newEventKey && (
<div className='text-[11px] bg-muted/30 p-2.5 rounded border text-muted-foreground space-y-1'>
<span className='font-semibold text-foreground'>
事件说明:
{t('eventDescription')}
</span>
<span>
{availableBuiltInEvents.find((e) => e.key === newEventKey)
?.description || '无描述信息'}
?.description || t('noDescription')}
</span>
</div>
)}
@@ -615,18 +623,20 @@ export function EventsTab() {
{newEventType === 'task' && newEventTaskType && (
<div className='text-[11px] bg-muted/30 p-2.5 rounded border text-muted-foreground space-y-1'>
<span className='font-semibold text-foreground'>
任务说明:
{t('taskDescription')}
</span>
<span>
{(taskTypesQuery.data ?? []).find(
(t) => t.asynq_task === newEventTaskType,
)?.description || '无描述信息'}
)?.description || t('noDescription')}
</span>
</div>
)}
<div className='space-y-1.5'>
<Label className='text-xs font-semibold'>推送渠道 (可多选)</Label>
<Label className='text-xs font-semibold'>
{t('pushChannelsMulti')}
</Label>
<Popover>
<PopoverTrigger asChild>
<Button
@@ -636,11 +646,11 @@ export function EventsTab() {
{newEventChannels.length > 0
? newEventChannels
.map((ch) => {
if (ch === 'email') return '邮件';
if (ch === 'email') return t('emailChannel');
return ch;
})
.join(', ')
: '选择已配置推送渠道'}
: t('selectConfiguredChannels')}
<ChevronDown className='ml-2 size-4 shrink-0 opacity-50' />
</Button>
</PopoverTrigger>
@@ -666,12 +676,12 @@ export function EventsTab() {
}
}}
/>
<span>{ch === 'email' ? '邮件推送' : ch}</span>
<span>{ch === 'email' ? t('emailPush') : ch}</span>
</label>
))}
{availableChannels.length === 0 && (
<div className='text-[11px] text-muted-foreground italic p-1'>
暂无可用渠道,请先在“通道管理与设置”中创建或启用。
{t('noAvailableChannels')}
</div>
)}
</div>
@@ -681,11 +691,11 @@ export function EventsTab() {
<div className='space-y-1.5'>
<Label className='text-xs font-semibold'>
推送目标 (仅邮件等渠道需要)
{t('pushTargets')}
</Label>
<Input
type='text'
placeholder='多个目标用英文逗号分隔,例如:user1@test.com, user2@test.com'
placeholder={t('pushTargetsPlaceholder')}
value={newEventTargets}
onChange={(e) => setNewEventTargets(e.target.value)}
className='text-xs h-9'
@@ -695,12 +705,12 @@ export function EventsTab() {
<div className='space-y-1.5'>
<div className='flex justify-between items-center'>
<Label className='text-xs font-semibold'>
内容渲染模板 (JSON 格式)
{t('contentTemplate')}
</Label>
<span className='text-[10px] text-muted-foreground font-mono flex items-center'>
{newEventType === 'task'
? '支持变量:{{task_name}}, {{task_status}}, {{task_duration}}, {{user.username}}'
: '支持变量:{{user.username}}, {{ip}}, {{time}}'}
? t('taskTemplateVars')
: t('eventTemplateVars')}
</span>
</div>
<Textarea
@@ -714,9 +724,9 @@ export function EventsTab() {
<div className='flex items-center justify-between p-3 border rounded-lg bg-muted/10'>
<div className='space-y-0.5'>
<Label className='text-xs font-semibold'>启用状态</Label>
<Label className='text-xs font-semibold'>{t('enableStatus')}</Label>
<div className='text-[10px] text-muted-foreground'>
创建后是否立即开始接收此事件的通知
{t('enableStatusDesc')}
</div>
</div>
<Switch
@@ -733,7 +743,7 @@ export function EventsTab() {
onClick={() => setCreateEventOpen(false)}
className='h-9 text-xs'
>
取消
{t('cancel')}
</Button>
<Button
variant='default'
@@ -749,7 +759,7 @@ export function EventsTab() {
{createEventMutation.isPending && (
<Loader2 className='size-3 animate-spin mr-1' />
)}
启用保存
{t('enableSave')}
</Button>
</DialogFooter>
</DialogContent>
@@ -759,9 +769,9 @@ export function EventsTab() {
<Dialog open={editEventOpen} onOpenChange={setEditEventOpen}>
<DialogContent className='sm:max-w-[550px] max-h-[85vh] overflow-y-auto'>
<DialogHeader>
<DialogTitle>事件通知渠道配置</DialogTitle>
<DialogTitle>{t('editEventDialogTitle')}</DialogTitle>
<DialogDescription>
自定义本事件触发时,需要异步推送的渠道以及接收人邮箱等配置
{t('editEventDialogDesc')}
</DialogDescription>
</DialogHeader>
@@ -770,7 +780,7 @@ export function EventsTab() {
<div className='grid grid-cols-2 gap-4'>
<div className='space-y-1.5'>
<Label className='text-xs font-semibold text-muted-foreground'>
事件名称
{t('eventName')}
</Label>
<Input
value={selectedEvent.name}
@@ -780,7 +790,7 @@ export function EventsTab() {
</div>
<div className='space-y-1.5'>
<Label className='text-xs font-semibold text-muted-foreground'>
事件键 (Key)
{t('eventKey')}
</Label>
<Input
value={selectedEvent.event_key}
@@ -793,7 +803,7 @@ export function EventsTab() {
{selectedEvent.task_type && (
<div className='space-y-1.5'>
<Label className='text-xs font-semibold text-muted-foreground'>
关联异步任务
{t('relatedAsyncTask')}
</Label>
<Input
value={`${(taskTypesQuery.data ?? []).find((t) => t.asynq_task === selectedEvent.task_type)?.name || selectedEvent.task_type} (${selectedEvent.task_type})`}
@@ -805,7 +815,7 @@ export function EventsTab() {
<div className='space-y-1.5'>
<Label className='text-xs font-semibold'>
推送渠道 (可多选)
{t('pushChannelsMulti')}
</Label>
<Popover>
<PopoverTrigger asChild>
@@ -816,11 +826,11 @@ export function EventsTab() {
{eventChannels.length > 0
? eventChannels
.map((ch) => {
if (ch === 'email') return '邮件';
if (ch === 'email') return t('emailChannel');
return ch;
})
.join(', ')
: '选择已配置推送渠道'}
: t('selectConfiguredChannels')}
<ChevronDown className='ml-2 size-4 shrink-0 opacity-50' />
</Button>
</PopoverTrigger>
@@ -846,12 +856,12 @@ export function EventsTab() {
}
}}
/>
<span>{ch === 'email' ? '邮件推送' : ch}</span>
<span>{ch === 'email' ? t('emailPush') : ch}</span>
</label>
))}
{availableChannels.length === 0 && (
<div className='text-[11px] text-muted-foreground italic p-1'>
暂无可用渠道,请先在“通道管理与设置”中创建并启用。
{t('noAvailableChannelsEdit')}
</div>
)}
</div>
@@ -861,11 +871,11 @@ export function EventsTab() {
<div className='space-y-1.5'>
<Label className='text-xs font-semibold'>
推送目标 (仅邮件等渠道需要)
{t('pushTargets')}
</Label>
<Input
type='text'
placeholder='多个目标用英文逗号分隔,例如:user1@test.com, user2@test.com'
placeholder={t('pushTargetsPlaceholder')}
value={eventTargets}
onChange={(e) => setEventTargets(e.target.value)}
className='text-xs h-9'
@@ -875,12 +885,12 @@ export function EventsTab() {
<div className='space-y-1.5'>
<div className='flex justify-between items-center'>
<Label className='text-xs font-semibold'>
内容渲染模板 (JSON 格式)
{t('contentTemplate')}
</Label>
<span className='text-[10px] text-muted-foreground font-mono flex items-center'>
{selectedEvent.task_type
? '支持变量:{{task_name}}, {{task_status}}, {{task_duration}}, {{user.username}}'
: '支持变量:{{user.username}}, {{ip}}, {{time}}'}
? t('taskTemplateVars')
: t('eventTemplateVars')}
</span>
</div>
<Textarea
@@ -901,7 +911,7 @@ export function EventsTab() {
onClick={() => setEditEventOpen(false)}
className='h-9 text-xs'
>
取消
{t('cancel')}
</Button>
<Button
variant='default'
@@ -913,7 +923,7 @@ export function EventsTab() {
{updateEventMutation.isPending && (
<Loader2 className='size-3 animate-spin mr-1' />
)}
保存修改
{t('saveChanges')}
</Button>
</DialogFooter>
</DialogContent>
@@ -925,14 +935,14 @@ export function EventsTab() {
>
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle>确认删除通知事件</AlertDialogTitle>
<AlertDialogTitle>{t('deleteEventTitle')}</AlertDialogTitle>
<AlertDialogDescription>
确定要删除该通知事件配置吗?删除后无法恢复。
{t('deleteEventConfirm')}
</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel disabled={deleteEventMutation.isPending}>
取消
{t('cancel')}
</AlertDialogCancel>
<AlertDialogAction
disabled={deleteEventMutation.isPending}
@@ -940,7 +950,9 @@ export function EventsTab() {
deleteTarget && deleteEventMutation.mutate(deleteTarget.id)
}
>
{deleteEventMutation.isPending ? '删除中...' : '确认删除'}
{deleteEventMutation.isPending
? t('deleting')
: t('confirmDelete')}
</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>