Files
kami_apple_exchage/frontend/src/components/dashboard/task-list.tsx
danial 8bc8e1c664 feat(links): 实现基于权重的轮询算法和链接管理功能
- 新增链接权重字段,支持1-100范围设置
- 修改轮询算法为基于权重的选择机制
- 更新链接API接口返回统一使用LinkInfo模型
- 添加更新链接权重的PATCH端点
- 调整链接仓库查询逻辑,只包含激活状态链接
- 迁移链接相关Pydantic模型到task模块统一管理
- 修改分页响应格式为通用PaginatedResponse包装
- 禁用OpenTelemetry监控配置
2025-09-30 17:02:02 +08:00

446 lines
25 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

"use client";
import { useState, useMemo } from "react";
import {
RefreshCw,
User,
Link,
Clock,
CheckCircle,
XCircle,
AlertCircle,
Loader2,
Gift,
ChevronLeft,
ChevronRight
} from "lucide-react";
import { Tooltip, TooltipContent, TooltipTrigger } from "@/components/animate-ui/base/tooltip";
import { useGetTaskListApiV1TasksListGet } from "@/lib/api/generated/task-management.gen";
import { AppleButton } from "@/components/ui/apple-button";
import { Badge } from "@/components/ui/badge";
import { AnimatedNumber } from "@/components/ui/animated-number";
import { Progress } from "@/components/animate-ui/radix/progress";
import { GiftCardInput } from "./gift-card-input";
import { cn } from "@/lib/utils";
import { OrderTaskStatus } from "@/lib/api/generated/schemas";
interface TaskListProps {
refreshEnabled?: boolean;
refreshInterval?: number;
className?: string;
}
export function TaskList({ refreshEnabled = false, refreshInterval = 5000, className }: TaskListProps) {
const [isLocalRefreshing, setIsLocalRefreshing] = useState(false);
const [currentPage, setCurrentPage] = useState(1);
const ITEMS_PER_PAGE = 12;
// 获取任务列表
const {
data: taskListData,
isLoading,
error,
refetch
} = useGetTaskListApiV1TasksListGet(
{
query: {
enabled: true,
refetchInterval: refreshEnabled ? refreshInterval : false,
retry: 2,
staleTime: 30000,
},
}
);
const handleRefresh = () => {
setIsLocalRefreshing(true);
refetch();
setTimeout(() => setIsLocalRefreshing(false), 1000);
};
const tasks = taskListData?.tasks || [];
// 排序waiting_gift_card 状态的任务优先
const sortedTasks = useMemo(() => {
return [...tasks].sort((a, b) => {
if (a.status === "waiting_gift_card" && b.status !== "waiting_gift_card") {
return -1; // a 排在前面
}
if (a.status !== "waiting_gift_card" && b.status === "waiting_gift_card") {
return 1; // b 排在前面
}
return 0; // 保持原有顺序
});
}, [tasks]);
// 分页逻辑
const totalPages = Math.ceil(sortedTasks.length / ITEMS_PER_PAGE);
const startIndex = (currentPage - 1) * ITEMS_PER_PAGE;
const endIndex = startIndex + ITEMS_PER_PAGE;
const paginatedTasks = sortedTasks.slice(startIndex, endIndex);
// 截断错误信息
const truncateErrorMessage = (message: string, maxLength: number = 50) => {
if (message.length <= maxLength) return message;
return message.substring(0, maxLength) + "...";
};
// 格式化时间
const formatTime = (timeString: string) => {
try {
return new Date(timeString).toLocaleString('zh-CN', {
month: '2-digit',
day: '2-digit',
hour: '2-digit',
minute: '2-digit'
});
} catch {
return '未知时间';
}
};
// 获取状态颜色
const getStatusColor = (status: OrderTaskStatus) => {
const colorMap: Record<OrderTaskStatus, string> = {
pending: "bg-gray-100 text-gray-800 dark:bg-gray-900/20 dark:text-gray-300",
running: "bg-blue-100 text-blue-800 dark:bg-blue-900/20 dark:text-blue-300",
success: "bg-green-100 text-green-800 dark:bg-green-900/20 dark:text-green-300",
failed: "bg-red-100 text-red-800 dark:bg-red-900/20 dark:text-red-300",
waiting_gift_card: "bg-yellow-100 text-yellow-800 dark:bg-yellow-900/20 dark:text-yellow-300",
gift_card_received: "bg-purple-100 text-purple-800 dark:bg-purple-900/20 dark:text-purple-300",
};
return colorMap[status] || "bg-gray-100 text-gray-800 dark:bg-gray-900/20 dark:text-gray-300";
};
// 获取状态文本
const getStatusText = (status: OrderTaskStatus) => {
const textMap: Record<OrderTaskStatus, string> = {
pending: "等待中",
running: "运行中",
success: "成功",
failed: "失败",
waiting_gift_card: "等待礼品卡",
gift_card_received: "礼品卡已接收",
};
return textMap[status] || status;
};
// 获取状态图标
const getStatusIcon = (status: OrderTaskStatus) => {
const iconMap: Record<OrderTaskStatus, React.ReactNode> = {
pending: <Clock className="h-4 w-4" />,
running: <Loader2 className="h-4 w-4 animate-spin" />,
success: <CheckCircle className="h-4 w-4" />,
failed: <XCircle className="h-4 w-4" />,
waiting_gift_card: <Gift className="h-4 w-4" />,
gift_card_received: <CheckCircle className="h-4 w-4" />,
};
return iconMap[status] || <Clock className="h-4 w-4" />;
};
return (
<div className={cn("space-y-6", className)}>
{/* 任务列表卡片 */}
<div className="apple-glass-card rounded-2xl p-6 transition-all duration-300">
{/* 头部 */}
<div className="flex items-center justify-between mb-6">
<div className="flex items-center gap-3">
<div className="w-10 h-10 rounded-full flex items-center justify-center bg-purple-600">
<User className="w-5 h-5 text-white" />
</div>
<div>
<h3 className="text-xl font-semibold text-gray-900 dark:text-gray-100">
</h3>
<p className="text-sm text-gray-600 dark:text-gray-400">
{tasks.length}
</p>
</div>
</div>
<div className="flex items-center gap-2">
<AppleButton
variant="outline"
size="icon"
onClick={handleRefresh}
disabled={isLoading || isLocalRefreshing}
className="w-10 h-10 rounded-xl"
>
<RefreshCw
className={cn("h-4 w-4", (isLoading || isLocalRefreshing) && "animate-spin")}
/>
</AppleButton>
</div>
</div>
{/* 内容区域 */}
<div className="space-y-4">
{isLoading && (
<div className="flex items-center justify-center py-8">
<div className="flex items-center gap-3">
<Loader2 className="h-6 w-6 animate-spin text-blue-600" />
<span className="text-sm text-gray-600 dark:text-gray-400">...</span>
</div>
</div>
)}
{error ? (
<div className="flex items-center justify-center py-8">
<div className="flex items-center gap-3 text-red-600">
<AlertCircle className="h-6 w-6" />
<span className="text-sm">: {error instanceof Error ? error.message : String(error)}</span>
</div>
</div>
) : null}
{!isLoading && !error && tasks.length === 0 && (
<div className="flex items-center justify-center py-8">
<div className="text-center">
<Clock className="h-12 w-12 text-gray-400 mx-auto mb-4" />
<p className="text-sm text-gray-600 dark:text-gray-400"></p>
</div>
</div>
)}
{!isLoading && !error && tasks.length > 0 && (
<>
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4">
{paginatedTasks.map((task) => (
<div
key={task.task_id}
className="bg-white dark:bg-gray-800 rounded-xl p-4 border border-gray-200 dark:border-gray-700 hover:shadow-md transition-all duration-200"
>
{/* 任务头部 */}
<div className="flex items-center justify-between mb-3">
<div className="flex items-center gap-3">
<div className="flex items-center gap-2">
{getStatusIcon(task.status)}
<span className="text-sm font-medium text-gray-900 dark:text-gray-100">
{task.task_id.slice(-8)}
</span>
</div>
<Badge className={cn("px-2 py-1 rounded-full text-xs font-medium", getStatusColor(task.status))}>
{getStatusText(task.status)}
</Badge>
</div>
<div className="text-xs text-gray-500 dark:text-gray-400">
{formatTime(task.created_at)}
</div>
</div>
{/* 用户信息 */}
{task.user_info && (
<div className="space-y-2 mb-3 p-3 bg-gray-50 dark:bg-gray-800/50 rounded-lg">
<div className="flex items-center gap-2">
<User className="h-4 w-4 text-gray-500" />
<span className="text-sm font-medium text-gray-900 dark:text-gray-100">
{task.user_info.first_name} {task.user_info.last_name}
</span>
</div>
{/* 用户邮箱 */}
{task.user_info.email && (
<div className="flex items-center gap-2">
<div className="w-3 h-3 rounded-full bg-blue-500"></div>
<span className="text-xs text-gray-600 dark:text-gray-400">
{task.user_info.email}
</span>
</div>
)}
{/* 用户ID */}
{task.user_info?.first_name && (
<div className="flex items-center gap-2">
<div className="w-3 h-3 rounded-full bg-gray-400"></div>
<span className="text-xs text-gray-500 dark:text-gray-500">
: {task.user_info?.first_name} {task.user_info?.last_name}
</span>
</div>
)}
</div>
)}
{/* 链接信息 */}
{task.link_info && (
<div className="space-y-2 mb-3">
<div className="flex items-center justify-between">
<div className="flex items-center gap-1">
<span className="text-sm font-medium text-green-600">
${task.link_info.amount || 0}
</span>
</div>
</div>
{/* 链接 URL */}
{task.link_info.url && (
<div className="flex items-start gap-2">
<Link className="h-3 w-3 text-gray-400 mt-0.5" />
<a
href={task.link_info.url}
target="_blank"
rel="noopener noreferrer"
className="text-xs text-blue-600 dark:text-blue-400 hover:underline break-all"
>
{task.link_info.url}
</a>
</div>
)}
</div>
)}
{/* 进度条 */}
<div className="space-y-2">
<div className="flex items-center justify-between">
<span className="text-xs text-gray-600 dark:text-gray-400">
</span>
<span className="text-xs font-medium text-gray-900 dark:text-gray-100">
<AnimatedNumber
value={task.progress}
duration={500}
decimals={1}
/>%
</span>
</div>
<Progress
value={task.progress}
className={cn(
"h-2",
task.progress === 100
? "[&>[data-slot='progress-indicator']]:bg-green-600"
: task.progress > 0 ? "[&>[data-slot='progress-indicator']]:bg-blue-600" : "[&>[data-slot='progress-indicator']]:bg-gray-400"
)}
/>
</div>
{/* 礼品卡输入框 - 只在等待礼品卡状态显示 */}
{task.status === "waiting_gift_card" && task.link_info?.amount && (
<div className="mt-4">
<GiftCardInput
taskId={task.task_id}
amount={task.link_info.amount}
updatedAt={task.updated_at}
onSubmit={(success) => {
if (success) {
refetch();
}
}}
triggerButton={
<AppleButton variant="default" size="sm" className="w-full flex items-center gap-2 apple-glass-button">
<Gift className="h-4 w-4" />
(${task.link_info.amount})
</AppleButton>
}
/>
</div>
)}
{/* 错误信息 */}
{task.error_message && (
<div className="mt-3 p-3 bg-white dark:bg-gray-800 rounded-lg border border-red-200 dark:border-red-800 shadow-sm">
<div className="flex items-start gap-2">
<AlertCircle className="h-4 w-4 text-red-600 dark:text-red-400 mt-0.5 flex-shrink-0" />
<div className="flex-1 min-w-0">
{task.error_message.length > 50 ? (
<Tooltip>
<TooltipTrigger>
<span className="text-sm text-red-700 dark:text-red-300 cursor-help font-medium break-words">
{truncateErrorMessage(task.error_message)}
</span>
</TooltipTrigger>
<TooltipContent className="bg-white dark:bg-gray-800 text-gray-900 dark:text-gray-100 border border-gray-200 dark:border-gray-700 shadow-lg">
<p className="max-w-xs text-sm font-medium leading-relaxed break-words">{task.error_message}</p>
</TooltipContent>
</Tooltip>
) : (
<span className="text-sm text-red-700 dark:text-red-300 font-medium break-words">
{task.error_message}
</span>
)}
</div>
</div>
</div>
)}
</div>
))}
</div>
{/* 分页控件 */}
{totalPages > 1 && (
<div className="flex items-center justify-between pt-4 mt-4 border-t border-gray-200 dark:border-gray-700">
<div className="flex items-center gap-2">
<span className="text-sm text-gray-600 dark:text-gray-400">
{currentPage} {totalPages}
</span>
<span className="text-sm text-gray-500 dark:text-gray-500">
({sortedTasks.length} )
</span>
</div>
<div className="flex items-center gap-2">
<AppleButton
variant="outline"
size="icon"
onClick={() => setCurrentPage(prev => Math.max(1, prev - 1))}
disabled={currentPage === 1}
className="w-8 h-8 rounded-lg"
>
<ChevronLeft className="h-4 w-4" />
</AppleButton>
<div className="flex items-center gap-1">
{Array.from({ length: Math.min(5, totalPages) }, (_, i) => {
let pageNum;
if (totalPages <= 5) {
pageNum = i + 1;
} else if (currentPage <= 3) {
pageNum = i + 1;
} else if (currentPage >= totalPages - 2) {
pageNum = totalPages - 4 + i;
} else {
pageNum = currentPage - 2 + i;
}
return (
<AppleButton
key={pageNum}
variant={currentPage === pageNum ? "default" : "outline"}
size="sm"
onClick={() => setCurrentPage(pageNum)}
className={`w-8 h-8 rounded-lg ${currentPage === pageNum ? 'apple-glass-button' : ''}`}
>
{pageNum}
</AppleButton>
);
})}
</div>
<AppleButton
variant="outline"
size="icon"
onClick={() => setCurrentPage(prev => Math.min(totalPages, prev + 1))}
disabled={currentPage === totalPages}
className="w-8 h-8 rounded-lg"
>
<ChevronRight className="h-4 w-4" />
</AppleButton>
</div>
</div>
)}
</>
)}
</div>
{/* 底部信息 */}
{!isLoading && !error && (
<div className="flex justify-between items-center pt-4 mt-4 border-t border-gray-200 dark:border-gray-700">
<span className="text-sm text-gray-600 dark:text-gray-400">
{paginatedTasks.length} ( {tasks.length} )
</span>
{refreshEnabled && (
<span className="text-xs text-gray-500 dark:text-gray-500">
: {refreshInterval / 1000}
</span>
)}
</div>
)}
</div>
</div>
);
}