'use client'; import { useCallback, useEffect, useMemo, useState } from 'react'; import { useTranslations } from 'next-intl'; import { toast } from 'sonner'; import { Button } from '@/components/ui/button'; import { Input } from '@/components/ui/input'; import { Label } from '@/components/ui/label'; import { Textarea } from '@/components/ui/textarea'; import { Switch } from '@/components/ui/switch'; import { Spinner } from '@/components/ui/spinner'; import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue, } from '@/components/ui/select'; import { Dialog, DialogContent, DialogDescription, DialogFooter, DialogHeader, DialogTitle, } from '@/components/ui/dialog'; import { Calendar as CalendarIcon, Clock, Database, Info, Layers, Play, } from 'lucide-react'; import type { DispatchTaskRequest, LogDatabaseStatus, TaskMeta, } from '@/lib/services/admin'; import services from '@/lib/services'; import { buildTaskPayload } from '@/lib/task-param-utils'; import { ErrorInline } from '@/components/layout/error'; import { LoadingStateWithBorder } from '@/components/layout/loading'; import { EmptyStateWithBorder } from '@/components/layout/empty'; import { Badge } from '@/components/ui/badge'; import { cn } from '@/lib/utils'; import { format } from 'date-fns'; import { zhCN } from 'date-fns/locale'; import { Calendar } from '@/components/ui/calendar'; import { Popover, PopoverContent, PopoverTrigger, } from '@/components/ui/popover'; const TASK_CONFIGS: Record< string, { icon: React.ComponentType<{ className?: string }>; color: string; gradient: string; } > = { order_sync: { icon: Layers, color: 'text-blue-600 dark:text-blue-400', gradient: 'from-blue-500/10 via-blue-500/5 to-transparent border-blue-200/50 dark:border-blue-800/50 hover:border-blue-400 dark:hover:border-blue-500', }, user_gamification: { icon: Play, color: 'text-amber-600 dark:text-amber-400', gradient: 'from-amber-500/10 via-amber-500/5 to-transparent border-amber-200/50 dark:border-amber-800/50 hover:border-amber-400 dark:hover:border-amber-500', }, dispute_auto_refund: { icon: Clock, color: 'text-rose-600 dark:text-rose-400', gradient: 'from-rose-500/10 via-rose-500/5 to-transparent border-rose-200/50 dark:border-rose-800/50 hover:border-rose-400 dark:hover:border-rose-500', }, of_log_db_switch: { icon: Database, color: 'text-teal-600 dark:text-teal-400', gradient: 'from-teal-500/10 via-teal-500/5 to-transparent border-teal-200/50 dark:border-teal-800/50 hover:border-teal-400 dark:hover:border-teal-500', }, }; const LOG_DATABASE_LABEL_KEYS: Record = { postgres: 'dbPostgresPrimary', sqlite: 'dbSqlitePrimary', clickhouse: 'dbClickhouse', }; const DEFAULT_TASK_CONFIG = { icon: Layers, color: 'text-zinc-600 dark:text-zinc-400', gradient: 'from-zinc-500/10 via-zinc-500/5 to-transparent border-zinc-200/50 dark:border-zinc-800/50 hover:border-zinc-400 dark:hover:border-zinc-500', }; function DatePickerWithTime({ date, setDate, }: { date: Date | undefined; setDate: (date: Date | undefined) => void; }) { const t = useTranslations('admin.tasks'); const timeString = date ? format(date, 'HH:mm:ss') : ''; const handleDateSelect = (newDate: Date | undefined) => { if (!newDate) { setDate(undefined); return; } const updated = new Date(newDate); if (date) { updated.setHours(date.getHours()); updated.setMinutes(date.getMinutes()); updated.setSeconds(date.getSeconds()); } else { updated.setHours(0, 0, 0, 0); } setDate(updated); }; const handleTimeChange = (e: React.ChangeEvent) => { const newTime = e.target.value; if (!newTime) return; if (date) { const [hours, minutes, seconds] = newTime.split(':').map(Number); const updated = new Date(date); updated.setHours(hours || 0); updated.setMinutes(minutes || 0); updated.setSeconds(seconds || 0); setDate(updated); } else { const today = new Date(); const [hours, minutes, seconds] = newTime.split(':').map(Number); today.setHours(hours || 0); today.setMinutes(minutes || 0); today.setSeconds(seconds || 0); setDate(today); } }; return (
); } export function TaskManager() { const t = useTranslations('admin.tasks'); const [loading, setLoading] = useState(false); const [error, setError] = useState(null); const [taskTypes, setTaskTypes] = useState([]); const [dispatching, setDispatching] = useState(false); const [selectedTaskType, setSelectedTaskType] = useState(null); const [dialogOpen, setDialogOpen] = useState(false); const [startTime, setStartTime] = useState(undefined); const [endTime, setEndTime] = useState(undefined); const [userId, setUserId] = useState(''); const [paramValues, setParamValues] = useState>({}); const fetchTaskTypes = useCallback(async () => { try { setLoading(true); setError(null); const data = await services.adminTask.getTaskTypes(); setTaskTypes(data); } catch (err) { setError(err instanceof Error ? err : new Error(t('loadTaskTypesFailed'))); } finally { setLoading(false); } }, [t]); const [logDbStatus, setLogDbStatus] = useState( null, ); // 日志库状态用于「切换日志数据库」卡片与 target 下拉;获取失败不阻塞任务列表。 const fetchLogDbStatus = useCallback(async () => { try { const data = await services.adminStatus.getLogDatabaseStatus(); setLogDbStatus(data); } catch { setLogDbStatus(null); } }, []); useEffect(() => { fetchTaskTypes(); fetchLogDbStatus(); }, [fetchTaskTypes, fetchLogDbStatus]); const availableLogDbTargets = useMemo( () => logDbStatus?.available_targets ?? [], [logDbStatus], ); const retentionSummary = useMemo(() => { const days = logDbStatus?.retention_days ?? {}; const parts: string[] = []; if (days.postgres != null) parts.push(`PG ${days.postgres}`); if (days.sqlite != null) parts.push(`SQLite ${days.sqlite}`); if (days.clickhouse != null) parts.push(`CH ${days.clickhouse}`); return parts.join(' / '); }, [logDbStatus]); useEffect(() => { if (selectedTaskType) { const targetTask = taskTypes.find((t) => t.type === selectedTaskType); if (targetTask?.params) { const initialValues: Record = {}; targetTask.params.forEach((p) => { initialValues[p.name] = ''; }); setParamValues(initialValues); } else { setParamValues({}); } } }, [selectedTaskType, taskTypes]); const handleDispatch = async () => { if (!selectedTaskType) return; try { setDispatching(true); const targetTask = taskTypes.find((t) => t.type === selectedTaskType); const params: DispatchTaskRequest = { task_type: selectedTaskType, }; if (targetTask?.supports_time) { if (startTime) params.start_time = startTime.toISOString(); if (endTime) params.end_time = endTime.toISOString(); } if (targetTask?.type === 'user_gamification') { if (userId) params.user_id = userId; } // Handle dynamic parameters — type coercion (number vs string) is // centralised in buildTaskPayload; do not inline it here. if (targetTask?.params && targetTask.params.length > 0) { const { payload, error } = buildTaskPayload( targetTask.params, paramValues, ); if (error) { toast.error(error); setDispatching(false); return; } params.payload = payload ?? undefined; } const taskID = await services.adminTask.dispatchTask(params); toast.success(t('dispatchSuccess'), { description: t('dispatchSuccessDesc', { name: targetTask?.name || selectedTaskType, taskId: taskID, }), }); setDialogOpen(false); setStartTime(undefined); setEndTime(undefined); setUserId(''); setParamValues({}); } catch (err) { toast.error(t('dispatchFailed'), { description: err instanceof Error ? err.message : t('unknownError'), }); } finally { setDispatching(false); } }; const openDispatchDialog = (type: string) => { setSelectedTaskType(type); setDialogOpen(true); }; const getSelectedTaskMeta = () => { return taskTypes.find((t) => t.type === selectedTaskType); }; return (

{error ? (
) : loading && taskTypes.length === 0 ? ( ) : taskTypes.length === 0 ? ( ) : (
{taskTypes.map((task, index) => { const config = TASK_CONFIGS[task.type] || DEFAULT_TASK_CONFIG; return (

{task.name}

{task.description}

{task.queue}
{t('taskType')} {task.type} {t('taskRetry')} {task.max_retry}
{task.type === 'of_log_db_switch' && logDbStatus && (
{t('logPrimaryDb')} {LOG_DATABASE_LABEL_KEYS[ logDbStatus.active_database ] ? t( LOG_DATABASE_LABEL_KEYS[ logDbStatus.active_database ], ) : logDbStatus.active_database}
{t('retentionDays')} {retentionSummary || '-'}
{t('migrationStatus')} {logDbStatus.migration === 'migrating' ? t('migrating') : t('idle')}
)}
); })}
)}
{t('dispatchTask')} {t('dispatchTaskDesc')}
{(() => { const meta = getSelectedTaskMeta(); if (!meta) return null; const config = TASK_CONFIGS[meta.type] || DEFAULT_TASK_CONFIG; const Icon = config.icon; return ( <>
{meta.name} {meta.type}
); })()}
{(() => { const targetTask = getSelectedTaskMeta(); if (targetTask?.supports_time) { return ( <>

{t('startTimeDesc')}

{t('endTimeDesc')}

); } return null; })()} {(() => { const targetTask = getSelectedTaskMeta(); if ( !targetTask || !targetTask.params || targetTask.params.length === 0 ) { return null; } return (
{targetTask.params.map((param) => { const isSwitchTarget = param.name === 'target' && getSelectedTaskMeta()?.type === 'of_log_db_switch'; return (
{param.type === 'text' ? (