fix(frontend): keep browser API on same-origin rewrites in dev

Document that NEXT_PUBLIC_WAVELET_BACKEND_URL must stay unset for local
next dev so axios hits /api/* and Next rewrites proxy to the backend.
This commit is contained in:
ryan
2026-07-24 16:37:24 +08:00
parent a4588b05e0
commit 9f15f42b47
75 changed files with 5971 additions and 2450 deletions
@@ -57,6 +57,8 @@ import {
PopoverTrigger,
} from '@/components/ui/popover';
import { useTranslations } from 'next-intl';
import { ErrorInline } from '@/components/layout/error';
import { LoadingStateWithBorder } from '@/components/layout/loading';
import { EmptyStateWithBorder } from '@/components/layout/empty';
@@ -70,6 +72,7 @@ import type {
import { PushService } from '@/lib/services/push';
export function EventsTab() {
const t = useTranslations('admin.push.events');
const queryClient = useQueryClient();
// --- 获取所有自定义消息通道 ---
@@ -107,12 +110,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);
},
});
@@ -122,14 +125,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('');
@@ -139,18 +142,18 @@ export function EventsTab() {
setNewEventEnabled(true);
},
onError: (err: unknown) => {
toast.error('事件创建失败: ' + (err as Error).message);
toast.error(t('eventCreateFailed') + ': ' + (err as Error).message);
},
});
const deleteEventMutation = useMutation({
mutationFn: (id: number) => PushService.deleteEvent(id),
onSuccess: () => {
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);
},
});
@@ -198,7 +201,7 @@ export function EventsTab() {
try {
JSON.parse(eventTemplate);
} catch {
toast.error('消息模板不是合法的 JSON 格式');
toast.error(t('templateInvalidJson'));
return;
}
@@ -258,11 +261,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;
}
@@ -270,7 +273,7 @@ export function EventsTab() {
try {
JSON.parse(newEventTemplate);
} catch {
toast.error('内容渲染模板不是合法的 JSON 格式');
toast.error(t('templateInvalidJson'));
return;
}
}
@@ -295,11 +298,14 @@ 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
@@ -309,10 +315,7 @@ export function EventsTab() {
/>
</div>
) : (eventsQuery.data ?? []).length === 0 ? (
<EmptyStateWithBorder
icon={Layers}
description='暂无通知配置事件,请点击右上角新增'
/>
<EmptyStateWithBorder icon={Layers} description={t('noEvents')} />
) : (
<div className='border border-dashed shadow-none rounded-lg overflow-hidden'>
<Table className='w-full caption-bottom text-sm min-w-full'>
@@ -322,19 +325,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>
@@ -362,7 +365,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>
@@ -375,7 +378,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) => (
@@ -384,7 +387,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>
))
)}
@@ -439,7 +442,7 @@ export function EventsTab() {
</Button>
</TooltipTrigger>
<TooltipContent side='top' className='text-xs'>
配置
{t('configure')}
</TooltipContent>
</Tooltip>
</TooltipProvider>
@@ -453,7 +456,7 @@ export function EventsTab() {
className='h-6 w-6 text-muted-foreground hover:text-destructive hover:bg-destructive/10'
disabled={deleteEventMutation.isPending}
onClick={() => {
if (confirm('确定要删除该通知事件配置吗?')) {
if (confirm(t('deleteEventConfirm'))) {
deleteEventMutation.mutate(event.id);
}
}}
@@ -462,7 +465,7 @@ export function EventsTab() {
</Button>
</TooltipTrigger>
<TooltipContent side='top' className='text-xs'>
删除
{t('delete')}
</TooltipContent>
</Tooltip>
</TooltipProvider>
@@ -479,15 +482,13 @@ export function EventsTab() {
<Dialog open={createEventOpen} onOpenChange={setCreateEventOpen}>
<DialogContent className='sm:max-w-[550px] max-h-[85vh] overflow-y-auto'>
<DialogHeader>
<DialogTitle>新增通知事件</DialogTitle>
<DialogDescription>
选择系统内置的事件并绑定推送渠道,创建后即可开始生效
</DialogDescription>
<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
@@ -501,7 +502,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
@@ -515,22 +516,24 @@ 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
@@ -538,7 +541,9 @@ export function EventsTab() {
onValueChange={handleNewEventKeyChange}
>
<SelectTrigger className='text-xs h-9'>
<SelectValue placeholder='请选择系统事件' />
<SelectValue
placeholder={t('selectSystemEventPlaceholder')}
/>
</SelectTrigger>
<SelectContent>
{availableBuiltInEvents.map((ev) => (
@@ -556,15 +561,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
@@ -572,7 +579,9 @@ export function EventsTab() {
onValueChange={handleNewEventTaskTypeChange}
>
<SelectTrigger className='text-xs h-9'>
<SelectValue placeholder='请选择异步任务' />
<SelectValue
placeholder={t('selectAsyncTaskPlaceholder')}
/>
</SelectTrigger>
<SelectContent>
{(taskTypesQuery.data ?? []).map((taskMeta) => (
@@ -593,11 +602,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>
)}
@@ -605,18 +614,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
@@ -661,7 +672,7 @@ export function EventsTab() {
))}
{availableChannels.length === 0 && (
<div className='text-[11px] text-muted-foreground italic p-1'>
暂无可用渠道,请先在“通道管理与设置”中创建或启用。
{t('noAvailableChannels')}
</div>
)}
</div>
@@ -671,7 +682,7 @@ export function EventsTab() {
<div className='space-y-1.5'>
<Label className='text-xs font-semibold'>
推送目标 (仅邮件等渠道需要)
{t('pushTargets')}
</Label>
<Input
type='text'
@@ -685,12 +696,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
@@ -704,9 +715,11 @@ 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
@@ -723,7 +736,7 @@ export function EventsTab() {
onClick={() => setCreateEventOpen(false)}
className='h-9 text-xs'
>
取消
{t('cancel')}
</Button>
<Button
variant='default'
@@ -739,7 +752,7 @@ export function EventsTab() {
{createEventMutation.isPending && (
<Loader2 className='size-3 animate-spin mr-1' />
)}
启用保存
{t('enableSave')}
</Button>
</DialogFooter>
</DialogContent>
@@ -749,10 +762,8 @@ export function EventsTab() {
<Dialog open={editEventOpen} onOpenChange={setEditEventOpen}>
<DialogContent className='sm:max-w-[550px] max-h-[85vh] overflow-y-auto'>
<DialogHeader>
<DialogTitle>事件通知渠道配置</DialogTitle>
<DialogDescription>
自定义本事件触发时,需要异步推送的渠道以及接收人邮箱等配置
</DialogDescription>
<DialogTitle>{t('editEventDialogTitle')}</DialogTitle>
<DialogDescription>{t('editEventDialogDesc')}</DialogDescription>
</DialogHeader>
{selectedEvent && (
@@ -760,7 +771,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}
@@ -770,7 +781,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}
@@ -783,7 +794,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})`}
@@ -795,7 +806,7 @@ export function EventsTab() {
<div className='space-y-1.5'>
<Label className='text-xs font-semibold'>
推送渠道 (可多选)
{t('pushChannelsMulti')}
</Label>
<Popover>
<PopoverTrigger asChild>
@@ -806,11 +817,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>
@@ -836,12 +847,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>
@@ -851,11 +862,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'
@@ -865,12 +876,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
@@ -891,7 +902,7 @@ export function EventsTab() {
onClick={() => setEditEventOpen(false)}
className='h-9 text-xs'
>
取消
{t('cancel')}
</Button>
<Button
variant='default'
@@ -903,7 +914,7 @@ export function EventsTab() {
{updateEventMutation.isPending && (
<Loader2 className='size-3 animate-spin mr-1' />
)}
保存修改
{t('saveChanges')}
</Button>
</DialogFooter>
</DialogContent>
@@ -35,6 +35,7 @@ import {
import { ErrorInline } from '@/components/layout/error';
import { LoadingStateWithBorder } from '@/components/layout/loading';
import { cn } from '@/lib/utils';
import { useTranslations } from 'next-intl';
import {
Dialog,
@@ -62,6 +63,7 @@ function getLevelBadgeVariant(
}
export function HistoriesTab() {
const t = useTranslations('admin.push.histories');
const [historyPage, setHistoryPage] = React.useState(1);
const [historySearch, setHistorySearch] = React.useState('');
const [historyStatus, setHistoryStatus] = React.useState('all');
@@ -99,7 +101,7 @@ export function HistoriesTab() {
<Search className='absolute left-2.5 top-2.5 size-4 text-muted-foreground' />
<Input
type='text'
placeholder='输入事件键过滤...'
placeholder={t('searchPlaceholder')}
className='pl-8 text-xs h-9'
value={historySearch}
onChange={(e) => {
@@ -117,17 +119,17 @@ export function HistoriesTab() {
}}
>
<SelectTrigger className='text-xs h-9'>
<SelectValue placeholder='发送状态' />
<SelectValue placeholder={t('sendStatus')} />
</SelectTrigger>
<SelectContent>
<SelectItem value='all' className='text-xs'>
全部状态
{t('allStatus')}
</SelectItem>
<SelectItem value='success' className='text-xs'>
发送成功
{t('sendSuccess')}
</SelectItem>
<SelectItem value='failed' className='text-xs'>
发送失败
{t('sendFailed')}
</SelectItem>
</SelectContent>
</Select>
@@ -151,13 +153,27 @@ export function HistoriesTab() {
<Table>
<TableHeader>
<TableRow className='bg-muted/30'>
<TableHead className='text-xs font-semibold'>事件</TableHead>
<TableHead className='text-xs font-semibold'>渠道</TableHead>
<TableHead className='text-xs font-semibold'>目标</TableHead>
<TableHead className='text-xs font-semibold'>标题</TableHead>
<TableHead className='text-xs font-semibold'>等级</TableHead>
<TableHead className='text-xs font-semibold'>状态</TableHead>
<TableHead className='text-xs font-semibold'>时间</TableHead>
<TableHead className='text-xs font-semibold'>
{t('colEvent')}
</TableHead>
<TableHead className='text-xs font-semibold'>
{t('colChannel')}
</TableHead>
<TableHead className='text-xs font-semibold'>
{t('colTarget')}
</TableHead>
<TableHead className='text-xs font-semibold'>
{t('colTitle')}
</TableHead>
<TableHead className='text-xs font-semibold'>
{t('colLevel')}
</TableHead>
<TableHead className='text-xs font-semibold'>
{t('colStatus')}
</TableHead>
<TableHead className='text-xs font-semibold'>
{t('colTime')}
</TableHead>
</TableRow>
</TableHeader>
<TableBody>
@@ -166,7 +182,7 @@ export function HistoriesTab() {
<TableCell colSpan={7} className='h-32'>
<LoadingStateWithBorder
icon={History}
description='加载推送历史记录中...'
description={t('loadingHistories')}
className='border-0 shadow-none'
/>
</TableCell>
@@ -187,7 +203,7 @@ export function HistoriesTab() {
colSpan={7}
className='h-32 text-center text-xs text-muted-foreground'
>
无推送历史数据
{t('noHistoryData')}
</TableCell>
</TableRow>
) : (
@@ -201,7 +217,9 @@ export function HistoriesTab() {
{hist.event_key}
</TableCell>
<TableCell className='text-xs uppercase font-semibold text-muted-foreground'>
{hist.channel === 'email' ? '邮件' : hist.channel}
{hist.channel === 'email'
? t('emailChannel')
: hist.channel}
</TableCell>
<TableCell
className='text-xs font-mono text-muted-foreground max-w-[120px] truncate'
@@ -230,7 +248,7 @@ export function HistoriesTab() {
}
className='text-[10px] font-semibold'
>
{hist.status === 'success' ? '成功' : '失败'}
{hist.status === 'success' ? t('success') : t('failed')}
</Badge>
{hist.status !== 'success' && hist.error_msg && (
<div
@@ -253,7 +271,7 @@ export function HistoriesTab() {
{historiesQuery.data && historiesQuery.data.total > 0 && (
<div className='flex justify-between items-center text-xs text-muted-foreground'>
<span>共 {historiesQuery.data.total} 条历史记录</span>
<span>{t('totalRecords', { total: historiesQuery.data.total })}</span>
<div className='flex items-center gap-1.5'>
<Button
variant='outline'
@@ -284,10 +302,10 @@ export function HistoriesTab() {
<DialogHeader>
<DialogTitle className='flex items-center gap-2'>
<History className='size-5 text-primary' />
推送通知详情
{t('pushNotificationDetail')}
</DialogTitle>
<DialogDescription>
查看该条通知发送的详细审计信息与内容
{t('pushNotificationDetailDesc')}
</DialogDescription>
</DialogHeader>
@@ -296,7 +314,7 @@ export function HistoriesTab() {
<div className='grid grid-cols-2 gap-4'>
<div className='space-y-1.5'>
<span className='font-semibold text-muted-foreground block'>
事件标识
{t('eventIdentifier')}
</span>
<div className='font-mono bg-muted/40 p-2 rounded border'>
{selectedHistory.event_key}
@@ -304,11 +322,11 @@ export function HistoriesTab() {
</div>
<div className='space-y-1.5'>
<span className='font-semibold text-muted-foreground block'>
发送渠道
{t('sendChannel')}
</span>
<div className='bg-muted/40 p-2 rounded border uppercase font-medium'>
{selectedHistory.channel === 'email'
? '邮件推送'
? t('emailPush')
: selectedHistory.channel}
</div>
</div>
@@ -317,7 +335,7 @@ export function HistoriesTab() {
<div className='grid grid-cols-2 gap-4'>
<div className='space-y-1.5'>
<span className='font-semibold text-muted-foreground block'>
推送目标
{t('pushTarget')}
</span>
<div
className='font-mono bg-muted/40 p-2 rounded border truncate'
@@ -328,7 +346,7 @@ export function HistoriesTab() {
</div>
<div className='space-y-1.5'>
<span className='font-semibold text-muted-foreground block'>
发送时间
{t('sendTime')}
</span>
<div className='bg-muted/40 p-2 rounded border'>
{new Date(selectedHistory.created_at).toLocaleString()}
@@ -339,7 +357,7 @@ export function HistoriesTab() {
<div className='grid grid-cols-2 gap-4'>
<div className='space-y-1.5'>
<span className='font-semibold text-muted-foreground block'>
通知等级
{t('notificationLevel')}
</span>
<div>
<Badge
@@ -352,7 +370,7 @@ export function HistoriesTab() {
</div>
<div className='space-y-1.5'>
<span className='font-semibold text-muted-foreground block'>
发送状态
{t('sendStatus')}
</span>
<div>
<Badge
@@ -363,7 +381,9 @@ export function HistoriesTab() {
}
className='text-[10px] font-semibold py-0.5 px-2'
>
{selectedHistory.status === 'success' ? '成功' : '失败'}
{selectedHistory.status === 'success'
? t('success')
: t('failed')}
</Badge>
</div>
</div>
@@ -373,7 +393,7 @@ export function HistoriesTab() {
selectedHistory.error_msg && (
<div className='space-y-1.5'>
<span className='font-semibold text-destructive block'>
失败原因
{t('failureReason')}
</span>
<div className='font-mono text-destructive bg-destructive/10 p-2.5 rounded border border-destructive/20 whitespace-pre-wrap break-all'>
{selectedHistory.error_msg}
@@ -383,7 +403,7 @@ export function HistoriesTab() {
<div className='space-y-1.5'>
<span className='font-semibold text-muted-foreground block'>
通知标题
{t('notificationTitle')}
</span>
<div className='bg-muted/30 p-2.5 rounded border font-medium text-[13px]'>
{selectedHistory.title}
@@ -392,7 +412,7 @@ export function HistoriesTab() {
<div className='space-y-1.5'>
<span className='font-semibold text-muted-foreground block'>
通知内容
{t('notificationContent')}
</span>
<div className='bg-muted/30 p-3 rounded border whitespace-pre-wrap break-all leading-relaxed font-sans text-xs'>
{selectedHistory.content}
@@ -408,7 +428,7 @@ export function HistoriesTab() {
onClick={() => setDetailOpen(false)}
className='h-9 text-xs'
>
关闭
{t('close')}
</Button>
</DialogFooter>
</DialogContent>
@@ -8,6 +8,7 @@ import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
import { toast } from 'sonner';
import { Edit2, Loader2, Play, Plus, Settings, Trash2 } from 'lucide-react';
import { useTranslations } from 'next-intl';
import { Button } from '@/components/ui/button';
import { Input } from '@/components/ui/input';
@@ -51,6 +52,7 @@ import type {
import { PushService } from '@/lib/services/push';
export function SettingsTab() {
const t = useTranslations('admin.push.settings');
const queryClient = useQueryClient();
// --- 获取所有自定义消息通道 ---
@@ -69,12 +71,12 @@ export function SettingsTab() {
const createChannelMutation = useMutation({
mutationFn: (data: CreateChannelRequest) => PushService.createChannel(data),
onSuccess: () => {
toast.success('通道创建成功');
toast.success(t('channelCreateSuccess'));
queryClient.invalidateQueries({ queryKey: ['admin', 'push-channels'] });
setChannelDialogOpen(false);
},
onError: (err: unknown) => {
toast.error('通道创建失败: ' + (err as Error).message);
toast.error(t('channelCreateFailed') + ': ' + (err as Error).message);
},
});
@@ -82,23 +84,23 @@ export function SettingsTab() {
mutationFn: ({ id, data }: { id: number; data: UpdateChannelRequest }) =>
PushService.updateChannel(id, data),
onSuccess: () => {
toast.success('通道更新成功');
toast.success(t('channelUpdateSuccess'));
queryClient.invalidateQueries({ queryKey: ['admin', 'push-channels'] });
setChannelDialogOpen(false);
},
onError: (err: unknown) => {
toast.error('通道更新失败: ' + (err as Error).message);
toast.error(t('channelUpdateFailed') + ': ' + (err as Error).message);
},
});
const deleteChannelMutation = useMutation({
mutationFn: (id: number) => PushService.deleteChannel(id),
onSuccess: () => {
toast.success('通道删除成功');
toast.success(t('channelDeleteSuccess'));
queryClient.invalidateQueries({ queryKey: ['admin', 'push-channels'] });
},
onError: (err: unknown) => {
toast.error('通道删除失败: ' + (err as Error).message);
toast.error(t('channelDeleteFailed') + ': ' + (err as Error).message);
},
});
@@ -181,16 +183,16 @@ export function SettingsTab() {
const handleSaveChannel = () => {
if (!channelName && !editingChannel) {
toast.error('通道名称不能为空');
toast.error(t('channelNameRequired'));
return;
}
if (!/^[a-zA-Z_0-9]+$/.test(channelName)) {
toast.error('通道名称只能使用英文字母、数字和下划线');
toast.error(t('channelNameFormat'));
return;
}
if (!activeDef) {
toast.error('无效的通道类型');
toast.error(t('invalidChannelType'));
return;
}
@@ -211,7 +213,7 @@ export function SettingsTab() {
// 协议安全校验(非邮件服务且配置了地址时,强制 HTTPS 协议)
if (channelType !== 'email') {
if (channelUrl && !channelUrl.startsWith('https://')) {
toast.error('地址必须以 https:// 开头以确保安全性');
toast.error(t('httpsRequired'));
return;
}
}
@@ -221,14 +223,14 @@ export function SettingsTab() {
try {
JSON.parse(channelOther);
} catch {
toast.error('请求体必须是合法的 JSON 格式');
toast.error(t('jsonFormatRequired'));
return;
}
} else if (channelType === 'lark' && channelOther) {
try {
JSON.parse(channelOther);
} catch {
toast.error('自定义卡片模版必须是合法的 JSON 格式');
toast.error(t('larkTemplateFormat'));
return;
}
}
@@ -267,15 +269,15 @@ export function SettingsTab() {
const handleSendChannelTest = async () => {
try {
setIsTestingChannel(true);
toast.info('正在发送测试推送...');
toast.info(t('sendingTestPush'));
await PushService.testChannel({
name: testChannelName,
target: testChannelTarget || undefined,
});
toast.success('测试推送发送成功,请前往对应平台确认。');
toast.success(t('testPushSuccess'));
setTestChannelOpen(false);
} catch (err: unknown) {
toast.error('连通性测试失败: ' + (err as Error).message);
toast.error(t('connectivityTestFailed') + ': ' + (err as Error).message);
} finally {
setIsTestingChannel(false);
}
@@ -285,9 +287,9 @@ export function SettingsTab() {
<div className='pt-4 space-y-4'>
<div className='flex items-center justify-between'>
<div>
<h2 className='text-sm font-semibold'>自定义推送通道</h2>
<h2 className='text-sm font-semibold'>{t('customPushChannels')}</h2>
<p className='text-[11px] text-muted-foreground mt-0.5'>
添加、配置及管理用于第三方 Webhook 对接的自定义数据推送通道
{t('customPushChannelsDesc')}
</p>
</div>
<Button
@@ -296,14 +298,14 @@ export function SettingsTab() {
className='text-xs'
>
<Plus className='size-3.5 mr-1' />
新建消息通道
{t('createChannel')}
</Button>
</div>
{channelsQuery.isLoading ? (
<LoadingStateWithBorder
icon={Settings}
description='加载消息通道中...'
description={t('loadingChannels')}
/>
) : channelsQuery.isError ? (
<div className='p-8 border border-dashed rounded-xl bg-card'>
@@ -319,9 +321,7 @@ export function SettingsTab() {
className='size-8 mb-2 opacity-30 animate-spin'
style={{ animationDuration: '3s' }}
/>
<span className='text-xs font-medium'>
暂无自定义通道配置,请点击右上角新建
</span>
<span className='text-xs font-medium'>{t('noCustomChannels')}</span>
</div>
) : (
<div className='border border-dashed shadow-none rounded-lg overflow-hidden'>
@@ -329,19 +329,19 @@ export function SettingsTab() {
<TableHeader className='sticky top-0 z-20 bg-background'>
<TableRow className='border-b border-dashed hover:bg-transparent'>
<TableHead className='w-[120px] whitespace-nowrap py-2 h-8'>
名称
{t('colName')}
</TableHead>
<TableHead className='w-[100px] whitespace-nowrap py-2 h-8'>
类型
{t('colType')}
</TableHead>
<TableHead className='whitespace-nowrap py-2 h-8'>
备注
{t('colRemark')}
</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-[180px] py-2 h-8'>
操作
{t('colActions')}
</TableHead>
</TableRow>
</TableHeader>
@@ -366,7 +366,9 @@ export function SettingsTab() {
</Badge>
</TableCell>
<TableCell className='text-xs text-muted-foreground max-w-[200px] truncate py-1'>
{ch.description || <span className='italic'>无备注</span>}
{ch.description || (
<span className='italic'>{t('noRemark')}</span>
)}
</TableCell>
<TableCell
className='text-center py-1'
@@ -402,7 +404,7 @@ export function SettingsTab() {
className='h-6 px-2 text-[10px] text-primary hover:text-primary hover:bg-primary/10'
>
<Play className='size-2.5 mr-1' />
测试
{t('test')}
</Button>
<Button
variant='ghost'
@@ -411,21 +413,25 @@ export function SettingsTab() {
className='h-6 px-2 text-[10px] text-muted-foreground hover:text-foreground'
>
<Edit2 className='size-2.5 mr-1' />
编辑
{t('edit')}
</Button>
<Button
variant='ghost'
size='sm'
disabled={deleteChannelMutation.isPending}
onClick={() => {
if (confirm(`确定要删除通道 "${ch.name}" 吗?`)) {
if (
confirm(
t('deleteChannelConfirm', { name: ch.name }),
)
) {
deleteChannelMutation.mutate(ch.id);
}
}}
className='h-6 px-2 text-[10px] text-destructive hover:text-destructive hover:bg-destructive/10'
>
<Trash2 className='size-2.5 mr-1' />
删除
{t('delete')}
</Button>
</div>
</TableCell>
@@ -441,35 +447,38 @@ export function SettingsTab() {
<DialogContent className='sm:max-w-[600px] max-h-[85vh] overflow-y-auto'>
<DialogHeader>
<DialogTitle>
{editingChannel ? '编辑消息通道' : '新建消息通道'}
{editingChannel
? t('editChannelDialogTitle')
: t('createChannelDialogTitle')}
</DialogTitle>
<DialogDescription>
配置自定义通知推送通道,支持以 POST 请求方式向第三方 Webhook
或推送服务投递数据
</DialogDescription>
<DialogDescription>{t('channelDialogDesc')}</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('channelName')}
</Label>
<Input
type='text'
placeholder='请输入通道名称,请仅使用英文字母和下划线,该名称必须唯一'
placeholder={t('channelNamePlaceholder')}
value={channelName}
onChange={(e) => setChannelName(e.target.value)}
disabled={!!editingChannel}
className='text-xs h-9 font-mono'
/>
<p className='text-[10px] text-muted-foreground'>
通道唯一标识,创建后不可修改
{t('channelNameHint')}
</p>
</div>
<div className='space-y-1.5'>
<Label className='text-xs font-semibold'>备注</Label>
<Label className='text-xs font-semibold'>
{t('channelRemark')}
</Label>
<Input
type='text'
placeholder='请输入备注信息'
placeholder={t('channelRemarkPlaceholder')}
value={channelDescription}
onChange={(e) => setChannelDescription(e.target.value)}
className='text-xs h-9'
@@ -477,13 +486,15 @@ export function SettingsTab() {
</div>
<div className='space-y-1.5'>
<Label className='text-xs font-semibold'>通道类型</Label>
<Label className='text-xs font-semibold'>
{t('channelType')}
</Label>
<Select
value={channelType}
onValueChange={handleChannelTypeChange}
>
<SelectTrigger className='text-xs h-9'>
<SelectValue placeholder='选择通道类型' />
<SelectValue placeholder={t('selectChannelType')} />
</SelectTrigger>
<SelectContent>
{(definitionsQuery.data ?? []).map((d) => (
@@ -562,7 +573,7 @@ export function SettingsTab() {
{channelType === 'custom' && (
<div className='p-3.5 border rounded-lg bg-muted/20 space-y-2.5'>
<Label className='text-[11px] font-semibold'>
快捷加载常用模版实例:
{t('quickLoadTemplates')}
</Label>
<div className='flex flex-wrap gap-1.5'>
<Button
@@ -586,7 +597,7 @@ export function SettingsTab() {
2,
),
);
toast.success('已加载飞书 Webhook 模版');
toast.success(t('loadedFeishuTemplate'));
}}
>
飞书 Webhook
@@ -613,7 +624,7 @@ export function SettingsTab() {
2,
),
);
toast.success('已加载钉钉机器人模版');
toast.success(t('loadedDingTalkTemplate'));
}}
>
钉钉群机器人
@@ -640,7 +651,7 @@ export function SettingsTab() {
2,
),
);
toast.success('已加载企业微信机器人模版');
toast.success(t('loadedWeChatTemplate'));
}}
>
企业微信群机器人
@@ -664,7 +675,7 @@ export function SettingsTab() {
2,
),
);
toast.success('已加载 Bark App 模版');
toast.success(t('loadedBarkTemplate'));
}}
>
Bark App
@@ -683,7 +694,7 @@ export function SettingsTab() {
onClick={() => setChannelDialogOpen(false)}
className='h-9 text-xs'
>
取消
{t('cancel')}
</Button>
<Button
variant='default'
@@ -699,7 +710,7 @@ export function SettingsTab() {
updateChannelMutation.isPending) && (
<Loader2 className='size-3 animate-spin mr-1' />
)}
确定
{t('confirm')}
</Button>
</DialogFooter>
</DialogContent>
@@ -709,15 +720,15 @@ export function SettingsTab() {
<Dialog open={testChannelOpen} onOpenChange={setTestChannelOpen}>
<DialogContent className='sm:max-w-[450px]'>
<DialogHeader>
<DialogTitle>发送测试通知</DialogTitle>
<DialogTitle>{t('sendTestNotification')}</DialogTitle>
<DialogDescription>
请输入消息测试的接收目标,点击发送后系统将通过此通道执行连通性推送测试
{t('sendTestNotificationDesc')}
</DialogDescription>
</DialogHeader>
<div className='space-y-4 py-3'>
<div className='space-y-1.5'>
<Label className='text-xs'>推送通道名称</Label>
<Label className='text-xs'>{t('pushChannelName')}</Label>
<Input
type='text'
value={testChannelName}
@@ -726,10 +737,10 @@ export function SettingsTab() {
/>
</div>
<div className='space-y-1.5'>
<Label className='text-xs'>测试推送目标 (对应模板变量 $to)</Label>
<Label className='text-xs'>{t('testPushTarget')}</Label>
<Input
type='text'
placeholder='请输入测试推送接收人/目标标识,如 Bark Token、邮箱等'
placeholder={t('testPushTargetPlaceholder')}
value={testChannelTarget}
onChange={(e) => setTestChannelTarget(e.target.value)}
className='text-xs h-9'
@@ -744,7 +755,7 @@ export function SettingsTab() {
onClick={() => setTestChannelOpen(false)}
className='h-9 text-xs'
>
取消
{t('cancel')}
</Button>
<Button
variant='default'
@@ -756,7 +767,7 @@ export function SettingsTab() {
{isTestingChannel && (
<Loader2 className='size-3 animate-spin mr-1' />
)}
发送测试
{t('sendTest')}
</Button>
</DialogFooter>
</DialogContent>