| 1 | /** |
| 2 | * 任务记录页内容组件 - Tasks History Content |
| 3 | * 客户端组件,包含所有交互逻辑 |
| 4 | * 支持搜索任务标题和收藏筛选功能 |
| 5 | */ |
| 6 | 'use client' |
| 7 | |
| 8 | import type { TaskListItem } from '@/api/ai/ai.types' |
| 9 | import { ArrowLeft, FileText, FileVideo, History, RefreshCw, Search, Star, X } from 'lucide-react' |
| 10 | import { useParams, useRouter } from 'next/navigation' |
| 11 | import { useCallback, useEffect, useRef, useState } from 'react' |
| 12 | import { agentApi } from '@/api/ai/ai.api' |
| 13 | import { useTransClient } from '@/app/i18n/client' |
| 14 | import { TaskCardSkeleton } from '@/components/Chat' |
| 15 | import TaskHistoryList from '@/components/Chat/TaskHistoryList' |
| 16 | import { Button } from '@/components/ui/button' |
| 17 | import { Input } from '@/components/ui/input' |
| 18 | import { Pagination } from '@/components/ui/pagination' |
| 19 | import { cn } from '@/utils/className' |
| 20 | import { toast } from '@/utils/ui/toast' |
| 21 | import UserLogsModal from './components/UserLogsModal' |
| 22 | import VideoHistoryModal from './components/VideoHistoryModal' |
| 23 | |
| 24 | export function TasksHistoryPageContent() { |
| 25 | const { t } = useTransClient('chat') |
| 26 | const router = useRouter() |
| 27 | const { lng } = useParams() |
| 28 | |
| 29 | // 状态 |
| 30 | const [tasks, setTasks] = useState<TaskListItem[]>([]) |
| 31 | const [isLoading, setIsLoading] = useState(true) |
| 32 | const [page, setPage] = useState(1) |
| 33 | const [total, setTotal] = useState(0) |
| 34 | const [logsModalOpen, setLogsModalOpen] = useState(false) |
| 35 | const [videoHistoryModalOpen, setVideoHistoryModalOpen] = useState(false) |
| 36 | |
| 37 | // 搜索和筛选状态 |
| 38 | const [searchKeyword, setSearchKeyword] = useState('') |
| 39 | const [favoriteOnly, setFavoriteOnly] = useState(false) |
| 40 | const debounceTimerRef = useRef<NodeJS.Timeout | null>(null) |
| 41 | |
| 42 | // 每页 16 条 |
| 43 | const pageSize = 16 |
| 44 | |
| 45 | const totalPages = Math.max(1, Math.ceil(total / pageSize || 1)) |
| 46 | |
| 47 | // 判断是否有筛选条件 |
| 48 | const hasFilters = searchKeyword.trim() || favoriteOnly |
| 49 | |
| 50 | /** 加载任务列表(分页) */ |
| 51 | const loadTasks = useCallback( |
| 52 | async (pageNum: number, keyword?: string, onlyFavorites?: boolean) => { |
| 53 | setIsLoading(true) |
| 54 | try { |
| 55 | const result = await agentApi.getTaskList({ |
| 56 | page: pageNum, |
| 57 | pageSize, |
| 58 | keyword: keyword?.trim() || undefined, |
| 59 | favoriteOnly: onlyFavorites || undefined, |
| 60 | }) |
| 61 | if (result && result.code === 0 && result.data) { |
| 62 | const newTasks = result.data.list || [] |
| 63 | setTasks(newTasks) |
| 64 | setTotal(result.data.total || 0) |
| 65 | setPage(pageNum) |
| 66 | } |
| 67 | } |
| 68 | catch (error) { |
| 69 | console.error('Load task list failed:', error) |
| 70 | toast.error(t('message.error')) |
| 71 | } |
| 72 | finally { |
| 73 | setIsLoading(false) |
| 74 | } |
| 75 | }, |
| 76 | [pageSize, t], |
| 77 | ) |
| 78 | |
| 79 | /** 初始加载 */ |
| 80 | useEffect(() => { |
| 81 | loadTasks(1, searchKeyword, favoriteOnly) |
| 82 | }, []) |
| 83 | |
| 84 | /** 搜索防抖处理 */ |
| 85 | const handleSearchChange = (value: string) => { |
| 86 | setSearchKeyword(value) |
| 87 | |
| 88 | // 清除之前的定时器 |
| 89 | if (debounceTimerRef.current) { |
| 90 | clearTimeout(debounceTimerRef.current) |
| 91 | } |
| 92 | |
| 93 | // 设置 500ms 防抖 |
| 94 | debounceTimerRef.current = setTimeout(() => { |
| 95 | loadTasks(1, value, favoriteOnly) |
| 96 | }, 500) |
| 97 | } |
| 98 | |
| 99 | /** 清除搜索 */ |
| 100 | const handleClearSearch = () => { |
| 101 | setSearchKeyword('') |
| 102 | if (debounceTimerRef.current) { |
| 103 | clearTimeout(debounceTimerRef.current) |
| 104 | } |
| 105 | loadTasks(1, '', favoriteOnly) |
| 106 | } |
| 107 | |
| 108 | /** 切换收藏筛选 */ |
| 109 | const handleToggleFavoriteOnly = () => { |
| 110 | const newValue = !favoriteOnly |
| 111 | setFavoriteOnly(newValue) |
| 112 | loadTasks(1, searchKeyword, newValue) |
| 113 | } |
| 114 | |
| 115 | /** 清除所有筛选 */ |
| 116 | const handleClearFilters = () => { |
| 117 | setSearchKeyword('') |
| 118 | setFavoriteOnly(false) |
| 119 | if (debounceTimerRef.current) { |
| 120 | clearTimeout(debounceTimerRef.current) |
| 121 | } |
| 122 | loadTasks(1, '', false) |
| 123 | } |
| 124 | |
| 125 | /** 刷新列表,保持当前筛选条件 */ |
| 126 | const handleRefresh = () => { |
| 127 | loadTasks(1, searchKeyword, favoriteOnly) |
| 128 | } |
| 129 | |
| 130 | /** 分页切换 */ |
| 131 | const handlePageChange = (nextPage: number) => { |
| 132 | if (nextPage < 1 || nextPage > totalPages) |
| 133 | return |
| 134 | loadTasks(nextPage, searchKeyword, favoriteOnly) |
| 135 | } |
| 136 | |
| 137 | /** 返回首页 */ |
| 138 | const handleBack = () => { |
| 139 | router.push(`/ai-social`) |
| 140 | } |
| 141 | |
| 142 | /** 打开日志弹窗 */ |
| 143 | const handleOpenLogs = () => { |
| 144 | setLogsModalOpen(true) |
| 145 | } |
| 146 | |
| 147 | /** 打开视频历史弹窗 */ |
| 148 | const handleOpenVideoHistory = () => { |
| 149 | setVideoHistoryModalOpen(true) |
| 150 | } |
| 151 | |
| 152 | return ( |
| 153 | <div className="flex flex-col min-h-screen bg-background"> |
| 154 | {/* 顶部导航 */} |
| 155 | <header className="sticky top-0 z-10 flex items-center justify-between px-4 py-3 bg-card border-b border-border"> |
| 156 | <div className="flex items-center gap-3"> |
| 157 | <Button variant="ghost" size="icon" onClick={handleBack} className="w-8 h-8"> |
| 158 | <ArrowLeft className="w-5 h-5" /> |
| 159 | </Button> |
| 160 | <div className="flex items-center gap-2"> |
| 161 | <History className="w-5 h-5 text-primary" /> |
| 162 | <h1 className="text-lg font-semibold text-foreground">{t('history.title')}</h1> |
| 163 | </div> |
| 164 | {total > 0 && ( |
| 165 | <span className="text-sm text-muted-foreground"> |
| 166 | ( |
| 167 | {total} |
| 168 | ) |
| 169 | </span> |
| 170 | )} |
| 171 | </div> |
| 172 | <div className="flex items-center gap-2"> |
| 173 | <Button |
| 174 | variant="ghost" |
| 175 | size="icon" |
| 176 | onClick={handleOpenLogs} |
| 177 | className="w-8 h-8" |
| 178 | title={t('history.logs')} |
| 179 | > |
| 180 | <FileText className="w-4 h-4" /> |
| 181 | </Button> |
| 182 | <Button |
| 183 | variant="ghost" |
| 184 | size="icon" |
| 185 | onClick={handleOpenVideoHistory} |
| 186 | className="w-8 h-8" |
| 187 | title={t('history.videoHistory')} |
| 188 | > |
| 189 | <FileVideo className="w-4 h-4" /> |
| 190 | </Button> |
| 191 | <Button |
| 192 | variant="ghost" |
| 193 | size="icon" |
| 194 | onClick={handleRefresh} |
| 195 | disabled={isLoading} |
| 196 | className="w-8 h-8" |
| 197 | > |
| 198 | <RefreshCw className={cn('w-4 h-4', isLoading && 'animate-spin')} /> |
| 199 | </Button> |
| 200 | </div> |
| 201 | </header> |
| 202 | |
| 203 | {/* 搜索和筛选区域 */} |
| 204 | <div className="sticky top-[57px] z-10 px-4 py-3 bg-card border-b border-border"> |
| 205 | <div className="flex items-center gap-3 max-w-6xl mx-auto"> |
| 206 | {/* 搜索框 */} |
| 207 | <div className="relative flex-1"> |
| 208 | <Search className="absolute left-3 top-1/2 -translate-y-1/2 w-4 h-4 text-muted-foreground" /> |
| 209 | <Input |
| 210 | type="text" |
| 211 | placeholder={t('history.searchPlaceholder')} |
| 212 | value={searchKeyword} |
| 213 | onChange={e => handleSearchChange(e.target.value)} |
| 214 | className="pl-9 pr-9 border-0 bg-muted/50 focus-visible:ring-0 focus-visible:bg-muted" |
| 215 | /> |
| 216 | {searchKeyword && ( |
| 217 | <button |
| 218 | type="button" |
| 219 | onClick={handleClearSearch} |
| 220 | className="absolute right-3 top-1/2 -translate-y-1/2 text-muted-foreground hover:text-foreground cursor-pointer" |
| 221 | > |
| 222 | <X className="w-4 h-4" /> |
| 223 | </button> |
| 224 | )} |
| 225 | </div> |
| 226 | |
| 227 | {/* 筛选按钮区域 */} |
| 228 | <div className="flex items-center gap-2 shrink-0"> |
| 229 | {/* 收藏筛选按钮 */} |
| 230 | <Button |
| 231 | variant={favoriteOnly ? 'default' : 'outline'} |
| 232 | size="sm" |
| 233 | onClick={handleToggleFavoriteOnly} |
| 234 | className="gap-1.5 cursor-pointer" |
| 235 | > |
| 236 | <Star className={cn('w-4 h-4', favoriteOnly && 'fill-current')} /> |
| 237 | <span className="hidden sm:inline">{t('history.favoriteOnly')}</span> |
| 238 | </Button> |
| 239 | |
| 240 | {/* 清除筛选按钮 */} |
| 241 | {hasFilters && ( |
| 242 | <Button |
| 243 | variant="ghost" |
| 244 | size="sm" |
| 245 | onClick={handleClearFilters} |
| 246 | className="text-muted-foreground hover:text-foreground cursor-pointer hidden sm:flex" |
| 247 | > |
| 248 | {t('history.clearFilters')} |
| 249 | </Button> |
| 250 | )} |
| 251 | </div> |
| 252 | </div> |
| 253 | </div> |
| 254 | |
| 255 | {/* 任务列表 */} |
| 256 | <main className="flex-1 px-4 py-6"> |
| 257 | <div className="w-full max-w-6xl mx-auto"> |
| 258 | {isLoading ? ( |
| 259 | // 加载骨架屏 |
| 260 | <div className="grid grid-cols-2 md:grid-cols-3 lg:grid-cols-4 gap-4"> |
| 261 | {Array.from({ length: pageSize }).map((_, index) => ( |
| 262 | <TaskCardSkeleton key={index} /> |
| 263 | ))} |
| 264 | </div> |
| 265 | ) : tasks.length === 0 ? ( |
| 266 | // 空状态 - 根据筛选条件显示不同提示 |
| 267 | <div className="flex flex-col items-center justify-center py-16"> |
| 268 | {favoriteOnly ? ( |
| 269 | // 收藏筛选的空状态 |
| 270 | <> |
| 271 | <Star className="w-16 h-16 text-muted-foreground/30 mb-4" /> |
| 272 | <p className="text-muted-foreground mb-4">{t('history.emptyFavorites')}</p> |
| 273 | <Button onClick={handleClearFilters} className="cursor-pointer"> |
| 274 | {t('history.clearFilters')} |
| 275 | </Button> |
| 276 | </> |
| 277 | ) : searchKeyword.trim() ? ( |
| 278 | // 搜索的空状态 |
| 279 | <> |
| 280 | <Search className="w-16 h-16 text-muted-foreground/30 mb-4" /> |
| 281 | <p className="text-muted-foreground mb-4">{t('history.emptySearch')}</p> |
| 282 | <Button onClick={handleClearSearch} className="cursor-pointer"> |
| 283 | {t('history.clearFilters')} |
| 284 | </Button> |
| 285 | </> |
| 286 | ) : ( |
| 287 | // 无任何筛选的空状态 |
| 288 | <> |
| 289 | <History className="w-16 h-16 text-muted-foreground/30 mb-4" /> |
| 290 | <p className="text-muted-foreground mb-4">{t('history.empty')}</p> |
| 291 | <Button onClick={handleBack} className="cursor-pointer"> |
| 292 | {t('home.startChat')} |
| 293 | </Button> |
| 294 | </> |
| 295 | )} |
| 296 | </div> |
| 297 | ) : ( |
| 298 | // 任务卡片网格 + 分页器 |
| 299 | <> |
| 300 | <div> |
| 301 | <TaskHistoryList |
| 302 | tasks={tasks} |
| 303 | isLoading={isLoading} |
| 304 | onRefresh={() => loadTasks(page, searchKeyword, favoriteOnly)} |
| 305 | /> |
| 306 | </div> |
| 307 | |
| 308 | {/* 分页器(使用项目内的 Pagination 组件) */} |
| 309 | {totalPages > 1 && ( |
| 310 | <div className="mt-8"> |
| 311 | <Pagination |
| 312 | current={page} |
| 313 | pageSize={pageSize} |
| 314 | total={total} |
| 315 | onChange={handlePageChange} |
| 316 | showTotal={(totalCount, [_start, _end]) => ( |
| 317 | <span className="text-sm text-muted-foreground"> |
| 318 | {page} |
| 319 | {' '} |
| 320 | / |
| 321 | {totalPages} |
| 322 | </span> |
| 323 | )} |
| 324 | className="w-full" |
| 325 | /> |
| 326 | </div> |
| 327 | )} |
| 328 | </> |
| 329 | )} |
| 330 | </div> |
| 331 | </main> |
| 332 | |
| 333 | {/* 用户日志弹窗 */} |
| 334 | <UserLogsModal open={logsModalOpen} onClose={() => setLogsModalOpen(false)} /> |
| 335 | |
| 336 | {/* 视频历史弹窗 */} |
| 337 | <VideoHistoryModal |
| 338 | open={videoHistoryModalOpen} |
| 339 | onClose={() => setVideoHistoryModalOpen(false)} |
| 340 | /> |
| 341 | </div> |
| 342 | ) |
| 343 | } |
| 344 |