| 1 | /** |
| 2 | * TaskCard - 任务卡片组件 |
| 3 | * 功能:显示任务简要信息,支持点击跳转到对话详情 |
| 4 | */ |
| 5 | |
| 6 | 'use client' |
| 7 | |
| 8 | import type React from 'react' |
| 9 | import { |
| 10 | AlertCircle, |
| 11 | CheckCircle2, |
| 12 | Heart, |
| 13 | Loader2, |
| 14 | MessageSquare, |
| 15 | Share2, |
| 16 | Star, |
| 17 | Trash2, |
| 18 | } from 'lucide-react' |
| 19 | import { useRouter } from 'next/navigation' |
| 20 | import { useState } from 'react' |
| 21 | import { useTransClient } from '@/app/i18n/client' |
| 22 | import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from '@/components/ui/tooltip' |
| 23 | import { cn } from '@/utils/className' |
| 24 | import { formatRelativeTime } from '@/utils/format' |
| 25 | |
| 26 | export interface ITaskCardProps { |
| 27 | /** 任务ID */ |
| 28 | id: string |
| 29 | /** 任务标题 */ |
| 30 | title: string |
| 31 | /** 任务状态(英文原始状态字符串) */ |
| 32 | status?: string |
| 33 | /** 创建时间 */ |
| 34 | createdAt: string | number |
| 35 | /** 更新时间 */ |
| 36 | updatedAt?: string | number |
| 37 | /** 任务评分(1-5) */ |
| 38 | rating?: number | null |
| 39 | /** 是否已收藏 */ |
| 40 | isFavorited?: boolean |
| 41 | /** 收藏加载中 */ |
| 42 | isFavoriteLoading?: boolean |
| 43 | /** 删除回调 */ |
| 44 | onDelete?: (id: string) => void | Promise<void> |
| 45 | /** 评分回调(用于历史列表触发外部评分弹窗) */ |
| 46 | onRateClick?: (taskId: string) => void |
| 47 | /** 选择回调(如果提供,点击卡片将触发选择而不是跳转) */ |
| 48 | onSelect?: (id: string) => void |
| 49 | /** 在主页展示内联评分控件 */ |
| 50 | showInlineRating?: boolean |
| 51 | /** 自定义类名 */ |
| 52 | className?: string |
| 53 | /** 分享回调(由父组件触发 ShareModal) */ |
| 54 | onShare?: (id: string) => void |
| 55 | /** 收藏切换回调 */ |
| 56 | onFavoriteToggle?: (id: string, isFavorited: boolean) => void | Promise<void> |
| 57 | } |
| 58 | |
| 59 | /** 获取状态显示配置 */ |
| 60 | function getStatusConfig(status: string | undefined, t: (key: string) => string) { |
| 61 | const normalizedStatus = status?.toLowerCase() |
| 62 | |
| 63 | switch (normalizedStatus) { |
| 64 | case 'requires_action': |
| 65 | return { |
| 66 | label: t('task.status.requiresAction') || 'Requires Action', |
| 67 | className: |
| 68 | 'bg-amber-100 dark:bg-amber-900/30 text-amber-700 dark:text-amber-400 border-amber-200 dark:border-amber-800', |
| 69 | icon: AlertCircle, |
| 70 | } |
| 71 | case 'completed': |
| 72 | return { |
| 73 | label: t('task.status.completed') || 'Completed', |
| 74 | className: |
| 75 | 'bg-green-100 dark:bg-green-900/30 text-green-700 dark:text-green-400 border-green-200 dark:border-green-800', |
| 76 | icon: CheckCircle2, |
| 77 | } |
| 78 | case 'running': |
| 79 | return { |
| 80 | label: t('task.status.running') || 'Running', |
| 81 | className: |
| 82 | 'bg-blue-100 dark:bg-blue-900/30 text-blue-700 dark:text-blue-400 border-blue-200 dark:border-blue-800', |
| 83 | icon: Loader2, |
| 84 | } |
| 85 | case 'error': |
| 86 | case 'failed': |
| 87 | return { |
| 88 | label: t('task.status.failed') || 'Failed', |
| 89 | className: |
| 90 | 'bg-red-100 dark:bg-red-900/30 text-red-700 dark:text-red-400 border-red-200 dark:border-red-800', |
| 91 | icon: AlertCircle, |
| 92 | } |
| 93 | default: |
| 94 | return { |
| 95 | label: status || '', |
| 96 | className: 'bg-muted text-muted-foreground border-border', |
| 97 | icon: null, |
| 98 | } |
| 99 | } |
| 100 | } |
| 101 | |
| 102 | /** |
| 103 | * TaskCard - 任务卡片组件 |
| 104 | */ |
| 105 | export function TaskCard({ |
| 106 | id, |
| 107 | title, |
| 108 | status, |
| 109 | createdAt, |
| 110 | updatedAt, |
| 111 | rating, |
| 112 | isFavorited = false, |
| 113 | isFavoriteLoading = false, |
| 114 | onDelete, |
| 115 | className, |
| 116 | onSelect, |
| 117 | onRateClick, |
| 118 | onShare, |
| 119 | onFavoriteToggle, |
| 120 | }: ITaskCardProps) { |
| 121 | const router = useRouter() |
| 122 | const { t } = useTransClient('chat') |
| 123 | const [isDeleting, setIsDeleting] = useState(false) |
| 124 | |
| 125 | const statusConfig = getStatusConfig(status, t as (key: string) => string) |
| 126 | |
| 127 | /** 跳转到对话详情页 */ |
| 128 | const handleClick = () => { |
| 129 | if (isDeleting) |
| 130 | return |
| 131 | if (onSelect) { |
| 132 | onSelect(id) |
| 133 | return |
| 134 | } |
| 135 | router.push(`/chat/${id}`) |
| 136 | } |
| 137 | |
| 138 | /** 处理删除 */ |
| 139 | const handleDelete = async (e: React.MouseEvent) => { |
| 140 | e.stopPropagation() |
| 141 | e.preventDefault() |
| 142 | if (!onDelete || isDeleting) |
| 143 | return |
| 144 | |
| 145 | try { |
| 146 | setIsDeleting(true) |
| 147 | await onDelete(id) |
| 148 | } |
| 149 | finally { |
| 150 | setIsDeleting(false) |
| 151 | } |
| 152 | } |
| 153 | |
| 154 | /** 触发评分回调(由历史列表等外部组件使用) */ |
| 155 | const handleRateClick = (e?: React.MouseEvent) => { |
| 156 | e?.stopPropagation() |
| 157 | e?.preventDefault() |
| 158 | if (!onRateClick) |
| 159 | return |
| 160 | onRateClick(id) |
| 161 | } |
| 162 | |
| 163 | const handleShareClick = (e?: React.MouseEvent) => { |
| 164 | e?.stopPropagation() |
| 165 | e?.preventDefault() |
| 166 | if (typeof onShare === 'function') |
| 167 | onShare(id) |
| 168 | } |
| 169 | |
| 170 | /** 处理收藏切换 */ |
| 171 | const handleFavoriteToggle = (e: React.MouseEvent) => { |
| 172 | e.stopPropagation() |
| 173 | e.preventDefault() |
| 174 | if (isFavoriteLoading) |
| 175 | return |
| 176 | onFavoriteToggle?.(id, !isFavorited) |
| 177 | } |
| 178 | |
| 179 | return ( |
| 180 | <div |
| 181 | onClick={handleClick} |
| 182 | className={cn( |
| 183 | 'flex flex-col p-4 rounded-xl border border-border bg-card cursor-pointer transition-all', |
| 184 | 'hover:border-border hover:shadow-md', |
| 185 | isDeleting && 'opacity-60 cursor-wait', |
| 186 | className, |
| 187 | )} |
| 188 | > |
| 189 | {/* 图标 + 标题 */} |
| 190 | <div className="flex items-start gap-3 mb-2"> |
| 191 | <div className="w-8 h-8 rounded-lg bg-muted flex items-center justify-center shrink-0"> |
| 192 | <MessageSquare className="w-4 h-4 text-muted-foreground" /> |
| 193 | </div> |
| 194 | <h4 title={title} className="text-sm font-medium text-foreground truncate flex-1 pt-1"> |
| 195 | {title || 'New Chat'} |
| 196 | </h4> |
| 197 | </div> |
| 198 | |
| 199 | {/* 时间 & 状态 */} |
| 200 | <div className="mt-1 flex items-center justify-between gap-2"> |
| 201 | <span className="text-xs text-muted-foreground"> |
| 202 | {formatRelativeTime(new Date(updatedAt || createdAt))} |
| 203 | </span> |
| 204 | {status && statusConfig.label && ( |
| 205 | <span |
| 206 | className={cn( |
| 207 | 'inline-flex items-center gap-1 rounded-full border px-1.5 sm:px-2 py-0.5 text-[11px] font-medium shrink-0', |
| 208 | statusConfig.className, |
| 209 | )} |
| 210 | title={statusConfig.label} |
| 211 | > |
| 212 | {statusConfig.icon && ( |
| 213 | <statusConfig.icon |
| 214 | className={cn('w-3 h-3', status?.toLowerCase() === 'running' && 'animate-spin')} |
| 215 | /> |
| 216 | )} |
| 217 | {/* 移动端只显示图标,桌面端显示文字 */} |
| 218 | <span className="hidden sm:inline">{statusConfig.label}</span> |
| 219 | </span> |
| 220 | )} |
| 221 | </div> |
| 222 | |
| 223 | {/* Action buttons: 收藏 + 评分 + 分享 + 删除 - 只显示图标 + Tooltip */} |
| 224 | <TooltipProvider delayDuration={300}> |
| 225 | <div className="mt-3 flex items-center justify-end gap-1"> |
| 226 | {/* 收藏按钮 */} |
| 227 | <Tooltip> |
| 228 | <TooltipTrigger asChild> |
| 229 | <button |
| 230 | onClick={handleFavoriteToggle} |
| 231 | disabled={isFavoriteLoading} |
| 232 | className="flex items-center justify-center w-7 h-7 rounded-md hover:bg-muted text-muted-foreground hover:text-foreground transition-colors opacity-60 hover:opacity-100 cursor-pointer" |
| 233 | aria-label={isFavorited ? t('task.unfavorite') : t('task.favorite')} |
| 234 | > |
| 235 | {isFavoriteLoading ? ( |
| 236 | <Loader2 className="w-4 h-4 animate-spin" /> |
| 237 | ) : ( |
| 238 | <Heart |
| 239 | className={cn( |
| 240 | 'w-4 h-4 transition-colors', |
| 241 | isFavorited && 'text-red-500 fill-red-500', |
| 242 | )} |
| 243 | /> |
| 244 | )} |
| 245 | </button> |
| 246 | </TooltipTrigger> |
| 247 | <TooltipContent> |
| 248 | {isFavorited ? t('task.unfavorite') : t('task.favorite')} |
| 249 | </TooltipContent> |
| 250 | </Tooltip> |
| 251 | |
| 252 | {/* 评分按钮 */} |
| 253 | <Tooltip> |
| 254 | <TooltipTrigger asChild> |
| 255 | <button |
| 256 | onClick={handleRateClick} |
| 257 | className="flex items-center justify-center w-7 h-7 rounded-md hover:bg-muted text-muted-foreground hover:text-foreground transition-colors opacity-60 hover:opacity-100 cursor-pointer" |
| 258 | aria-label={t('task.rate')} |
| 259 | > |
| 260 | <Star |
| 261 | className={cn( |
| 262 | 'w-4 h-4 transition-colors', |
| 263 | rating && 'text-amber-400 fill-amber-400', |
| 264 | )} |
| 265 | /> |
| 266 | </button> |
| 267 | </TooltipTrigger> |
| 268 | <TooltipContent>{t('task.rate')}</TooltipContent> |
| 269 | </Tooltip> |
| 270 | |
| 271 | {/* 分享按钮 */} |
| 272 | <Tooltip> |
| 273 | <TooltipTrigger asChild> |
| 274 | <button |
| 275 | onClick={handleShareClick} |
| 276 | className="flex items-center justify-center w-7 h-7 rounded-md hover:bg-muted text-muted-foreground hover:text-foreground transition-colors opacity-60 hover:opacity-100 cursor-pointer" |
| 277 | aria-label={t('task.share')} |
| 278 | > |
| 279 | <Share2 className="w-4 h-4" /> |
| 280 | </button> |
| 281 | </TooltipTrigger> |
| 282 | <TooltipContent>{t('task.share')}</TooltipContent> |
| 283 | </Tooltip> |
| 284 | |
| 285 | {/* 删除按钮 */} |
| 286 | <Tooltip> |
| 287 | <TooltipTrigger asChild> |
| 288 | <button |
| 289 | onClick={handleDelete} |
| 290 | disabled={isDeleting} |
| 291 | className="flex items-center justify-center w-7 h-7 rounded-md hover:bg-destructive/10 text-muted-foreground hover:text-destructive transition-colors opacity-60 hover:opacity-100 cursor-pointer" |
| 292 | aria-label={t('task.delete')} |
| 293 | > |
| 294 | {isDeleting ? ( |
| 295 | <Loader2 className="w-4 h-4 animate-spin" /> |
| 296 | ) : ( |
| 297 | <Trash2 className="w-4 h-4" /> |
| 298 | )} |
| 299 | </button> |
| 300 | </TooltipTrigger> |
| 301 | <TooltipContent>{t('task.delete')}</TooltipContent> |
| 302 | </Tooltip> |
| 303 | </div> |
| 304 | </TooltipProvider> |
| 305 | </div> |
| 306 | ) |
| 307 | } |
| 308 | |
| 309 | export default TaskCard |
| 310 |