返回 AiToEarn
TaskHistoryList.tsx
根目录 / project / aitoearn-web / src / components / Chat / TaskHistoryList.tsx
1 /**
2 * TaskHistoryList - 可复用的任务卡片列表组件
3 * 用于在不同页面展示任务网格,支持 loading skeleton、删除、评分回调和可选链接包裹
4 */
5 'use client'
6
7 import type { TaskListItem } from '@/api/ai/ai.types'
8 import Link from 'next/link'
9 import React, { useCallback, useState } from 'react'
10 import { agentApi } from '@/api/ai/ai.api'
11 import { useTransClient } from '@/app/i18n/client'
12 import { TaskCard, TaskCardSkeleton } from '@/components/Chat'
13 import RatingModal from '@/components/Chat/Rating'
14 import ShareModal from '@/components/Chat/Share/ShareModal'
15 import { cn } from '@/utils/className'
16 import { toast } from '@/utils/ui/toast'
17
18 export interface ITaskHistoryListProps {
19 tasks: TaskListItem[]
20 isLoading?: boolean
21 skeletonCount?: number
22 /** 刷新列表回调,由父组件提供 */
23 onRefresh?: () => void | Promise<void>
24 className?: string
25 /** 可选:为每条记录生成链接的前缀(例如 '/en/chat'),若传入则会用 <a> 包裹 TaskCard */
26 linkBasePath?: string
27 }
28
29 export function TaskHistoryList({
30 tasks,
31 isLoading = false,
32 skeletonCount = 4,
33 onRefresh,
34 className,
35 linkBasePath = '/chat',
36 }: ITaskHistoryListProps) {
37 const { t } = useTransClient('chat')
38 const [ratingModalFor, setRatingModalFor] = useState<string | null>(null)
39 const [shareTaskId, setShareTaskId] = useState<string | null>(null)
40 // 本地任务状态(用于乐观更新),初始值使用 props.tasks 避免首次渲染时空页面闪烁
41 const [localTasks, setLocalTasks] = useState<TaskListItem[]>(tasks)
42
43 // 同步外部 tasks 到 localTasks
44 React.useEffect(() => {
45 setLocalTasks(tasks)
46 }, [tasks])
47
48 const handleRateClick = (taskId: string) => {
49 setRatingModalFor(taskId)
50 }
51
52 /** 处理删除任务 */
53 const handleDelete = async (id: string) => {
54 try {
55 const result = await agentApi.deleteTask(id)
56 if (result && result.code === 0) {
57 toast.success(t('task.deleteSuccess' as any))
58 // 调用父组件的刷新回调
59 if (onRefresh) {
60 await onRefresh()
61 }
62 }
63 else {
64 toast.error((result as any)?.message || t('task.deleteFailed' as any))
65 }
66 }
67 catch (error) {
68 toast.error(t('task.deleteFailed' as any))
69 }
70 }
71
72 /** 处理评分更新 */
73 const handleRatingUpdate = async (
74 taskId: string,
75 data: { rating?: number | null, comment?: string | null },
76 ) => {
77 // 这里可以更新本地状态,或者让父组件处理
78 // 由于父组件有自己的状态管理,我们通过 onRefresh 来更新
79 if (onRefresh) {
80 await onRefresh()
81 }
82 toast.success(t('rating.saveSuccess' as any) || 'Saved')
83 }
84
85 /** 处理收藏切换 - 乐观更新 */
86 const handleFavoriteToggle = useCallback(
87 async (taskId: string, shouldFavorite: boolean) => {
88 // 乐观更新:先改本地 tasks 状态
89 setLocalTasks(prev =>
90 prev.map(t =>
91 t.id === taskId
92 ? { ...t, favoritedAt: shouldFavorite ? new Date().toISOString() : null }
93 : t,
94 ),
95 )
96
97 try {
98 const result = shouldFavorite
99 ? await agentApi.favoriteTask(taskId)
100 : await agentApi.unfavoriteTask(taskId)
101
102 if (!result || result.code !== 0) {
103 // 失败回滚
104 setLocalTasks(prev =>
105 prev.map(t =>
106 t.id === taskId
107 ? { ...t, favoritedAt: shouldFavorite ? null : new Date().toISOString() }
108 : t,
109 ),
110 )
111 toast.error((result as any)?.message || t('message.error'))
112 }
113 }
114 catch {
115 // 失败回滚
116 setLocalTasks(prev =>
117 prev.map(t =>
118 t.id === taskId
119 ? { ...t, favoritedAt: shouldFavorite ? null : new Date().toISOString() }
120 : t,
121 ),
122 )
123 toast.error(t('message.error'))
124 }
125 },
126 [t],
127 )
128
129 return (
130 <div className={cn('grid grid-cols-2 md:grid-cols-3 lg:grid-cols-4 gap-4', className)}>
131 {isLoading
132 ? Array.from({ length: skeletonCount }).map((_, index) => <TaskCardSkeleton key={index} />)
133 : localTasks.map((task) => {
134 const card = (
135 <TaskCard
136 key={task.id}
137 id={String(task.id)}
138 title={task.title ?? ''}
139 status={task.status}
140 createdAt={task.createdAt}
141 updatedAt={task.updatedAt}
142 rating={task.rating}
143 isFavorited={!!task.favoritedAt}
144 onDelete={() => handleDelete(String(task.id))}
145 onRateClick={() => handleRateClick(task.id)}
146 onShare={() => setShareTaskId(String(task.id))}
147 onFavoriteToggle={handleFavoriteToggle}
148 />
149 )
150
151 if (linkBasePath) {
152 const href = `${linkBasePath}/${task.id}` as string
153 return (
154 <Link key={task.id} href={href} className="block">
155 {/* 传递 onSelect 来阻止 TaskCard 内部的 router.push,由 Link 处理导航 */}
156 {React.cloneElement(card, { onSelect: () => {} })}
157 </Link>
158 )
159 }
160
161 return <React.Fragment key={task.id}>{card}</React.Fragment>
162 })}
163
164 <RatingModal
165 taskId={ratingModalFor ?? ''}
166 open={!!ratingModalFor}
167 onClose={() => setRatingModalFor(null)}
168 onSaved={(data) => {
169 if (ratingModalFor) {
170 handleRatingUpdate(ratingModalFor, data)
171 }
172 setRatingModalFor(null)
173 }}
174 />
175 {/* ShareModal centralized here to avoid overlay click propagation issues */}
176 <ShareModal
177 taskId={shareTaskId ?? ''}
178 open={!!shareTaskId}
179 onOpenChange={(v) => {
180 if (!v)
181 setShareTaskId(null)
182 }}
183 />
184 </div>
185 )
186 }
187
188 export default TaskHistoryList
189
189 lines Plain Text