返回 AiToEarn
1 /**
2 * VideoHistoryModal - 视频生成历史弹窗组件
3 * 显示用户的视频生成记录,支持分页查看
4 */
5
6 'use client'
7
8 import type { VideoGenerationHistoryItem, VideoGenerationTimestamp } from '@/api/ai/ai.types'
9 import type { MediaPreviewItem } from '@/components/common/MediaPreview'
10 import { Eye, FileVideo, Loader2 } from 'lucide-react'
11 import { useEffect, useState } from 'react'
12 import { getVideoGenerations } from '@/api/ai/ai.api'
13 import { useTransClient } from '@/app/i18n/client'
14 import { getDateLocale } from '@/app/i18n/languageConfig'
15 import MediaPreview from '@/components/common/MediaPreview'
16 import { Dialog, DialogContent, DialogHeader, DialogTitle } from '@/components/ui/dialog'
17 import { Empty } from '@/components/ui/empty'
18 import { ScrollArea } from '@/components/ui/scroll-area'
19 import { Skeleton } from '@/components/ui/skeleton'
20 import { useGetClientLng } from '@/hooks/useSystem'
21 import { getOssUrl } from '@/utils/oss'
22 import { toast } from '@/utils/ui/toast'
23
24 type VideoHistoryItem = VideoGenerationHistoryItem
25
26 export interface VideoHistoryModalProps {
27 /** 是否显示弹窗 */
28 open: boolean
29 /** 关闭弹窗回调 */
30 onClose: () => void
31 }
32
33 /**
34 * VideoHistoryModal 视频生成历史弹窗组件
35 */
36 export function VideoHistoryModal({ open, onClose }: VideoHistoryModalProps) {
37 const { t } = useTransClient('chat')
38 const lng = useGetClientLng()
39
40 // 状态管理
41 const [videos, setVideos] = useState<VideoHistoryItem[]>([])
42 const [isLoading, setIsLoading] = useState(false)
43 const [page, setPage] = useState(1)
44 const [total, setTotal] = useState(0)
45 const [totalPages, setTotalPages] = useState(0)
46 const [previewOpen, setPreviewOpen] = useState(false)
47 const [previewItems, setPreviewItems] = useState<MediaPreviewItem[]>([])
48 const [previewIndex, setPreviewIndex] = useState(0)
49
50 const pageSize = 20
51
52 /** 加载视频历史数据 */
53 const loadVideos = async (pageNum: number, isLoadMore = false) => {
54 if (isLoading)
55 return
56
57 setIsLoading(true)
58 try {
59 const result = await getVideoGenerations({
60 page: pageNum,
61 pageSize,
62 })
63
64 if (result && result.code === 0 && result.data) {
65 const newVideos = result.data.list || []
66
67 if (isLoadMore) {
68 setVideos(prev => [...prev, ...newVideos])
69 }
70 else {
71 setVideos(newVideos)
72 }
73
74 setTotal(result.data.total || 0)
75 setTotalPages(result.data.totalPages || Math.ceil((result.data.total || 0) / pageSize))
76 setPage(pageNum)
77 }
78 }
79 catch (error) {
80 console.error('Load video history failed:', error)
81 toast.error(t('history.logsError'))
82 }
83 finally {
84 setIsLoading(false)
85 }
86 }
87
88 /** 初始加载 */
89 useEffect(() => {
90 if (open) {
91 loadVideos(1)
92 }
93 }, [open])
94
95 /** 加载更多 */
96 const handleLoadMore = () => {
97 if (!isLoading && page < totalPages) {
98 loadVideos(page + 1, true)
99 }
100 }
101
102 /** 格式化时间 */
103 const formatTime = (value: VideoGenerationTimestamp) => {
104 const date = parseTimestamp(value)
105 if (!date)
106 return '--'
107
108 return date.toLocaleString(getDateLocale(lng), {
109 year: 'numeric',
110 month: '2-digit',
111 day: '2-digit',
112 hour: '2-digit',
113 minute: '2-digit',
114 })
115 }
116
117 /** 解析接口时间戳,兼容秒、毫秒和 ISO 字符串 */
118 const parseTimestamp = (value: VideoGenerationTimestamp) => {
119 if (value == null || value === '')
120 return null
121
122 const numericValue = typeof value === 'number' ? value : Number(value)
123 const date = Number.isFinite(numericValue)
124 ? new Date(Math.abs(numericValue) < 1_000_000_000_000 ? numericValue * 1000 : numericValue)
125 : new Date(value)
126
127 return Number.isNaN(date.getTime()) ? null : date
128 }
129
130 /** 获取状态文本 */
131 const getStatusText = (status: string) => {
132 switch (status.toLowerCase()) {
133 case 'pending':
134 return t('history.videoStatus.pending')
135 case 'processing':
136 return t('history.videoStatus.processing')
137 case 'success':
138 case 'completed':
139 return t('history.videoStatus.completed')
140 case 'failed':
141 return t('history.videoStatus.failed')
142 default:
143 return status
144 }
145 }
146
147 /** 格式化时长 */
148 const formatDuration = (duration?: number) => {
149 if (!duration)
150 return null
151 const seconds = Math.floor(duration)
152 if (seconds < 60)
153 return `${seconds}${t('history.duration.seconds')}`
154 const minutes = Math.floor(seconds / 60)
155 const remainingSeconds = seconds % 60
156 return `${minutes}${t('history.duration.minutes')}${remainingSeconds}${t('history.duration.seconds')}`
157 }
158
159 /** 预览视频 */
160 const handlePreviewVideo = (video: VideoHistoryItem) => {
161 if (video.status === 'SUCCESS' && video.data?.video_url) {
162 const videoUrl = getOssUrl(video.data.video_url)
163 setPreviewItems([
164 {
165 type: 'video',
166 src: videoUrl,
167 title: `${video.prompt.substring(0, 50)}...`,
168 },
169 ])
170 setPreviewIndex(0)
171 setPreviewOpen(true)
172 }
173 }
174
175 return (
176 <>
177 <Dialog open={open} onOpenChange={isOpen => !isOpen && onClose()}>
178 <DialogContent className="w-[95vw] max-w-[800px] max-h-[80vh]">
179 <DialogHeader>
180 <DialogTitle className="flex items-center gap-2">
181 <FileVideo className="w-5 h-5" />
182 {t('history.videoHistoryTitle')}
183 </DialogTitle>
184 </DialogHeader>
185
186 <ScrollArea className="flex-1 max-h-[60vh] pr-4">
187 {isLoading && videos.length === 0 ? (
188 // 初始加载骨架屏
189 <div className="space-y-4">
190 {Array.from({ length: 5 }).map((_, index) => (
191 <div key={index} className="border rounded-lg p-4 space-y-3">
192 <div className="flex justify-between items-start">
193 <Skeleton className="h-4 w-32" />
194 <Skeleton className="h-4 w-16" />
195 </div>
196 <Skeleton className="h-4 w-48" />
197 <Skeleton className="h-3 w-24" />
198 </div>
199 ))}
200 </div>
201 ) : videos.length === 0 ? (
202 // 空状态
203 <Empty
204 image={<FileVideo className="w-12 h-12 text-muted-foreground/50" />}
205 description={t('history.videoHistoryEmpty')}
206 />
207 ) : (
208 // 视频历史列表
209 <div className="space-y-4">
210 {videos.map(video => (
211 <div
212 key={video.task_id}
213 className="border rounded-lg p-4 hover:bg-muted/50 transition-colors"
214 >
215 <div className="flex justify-between items-start mb-3">
216 <div className="flex items-center gap-2">
217 <span className="font-medium text-sm">{t('history.videoGeneration')}</span>
218 <span
219 className={`text-xs px-2 py-1 rounded-full ${
220 video.status === 'SUCCESS'
221 ? 'bg-green-100 text-green-700 dark:bg-green-900 dark:text-green-300'
222 : video.status === 'FAILED'
223 ? 'bg-red-100 text-red-700 dark:bg-red-900 dark:text-red-300'
224 : 'bg-yellow-100 text-yellow-700 dark:bg-yellow-900 dark:text-yellow-300'
225 }`}
226 >
227 {getStatusText(video.status)}
228 </span>
229 </div>
230 <span className="text-xs text-muted-foreground">
231 {formatTime(video.submit_time)}
232 </span>
233 </div>
234
235 <div className="space-y-2 text-sm">
236 <div className="flex justify-between">
237 <span className="text-muted-foreground">
238 {t('history.logFields.model')}
239 :
240 </span>
241 <span className="font-mono text-xs">{video.data?.model}</span>
242 </div>
243
244 <div className="flex justify-between">
245 <span className="text-muted-foreground">
246 {t('history.videoSize')}
247 :
248 </span>
249 <span className="text-xs">{video.data?.size}</span>
250 </div>
251
252 <div className="flex justify-between">
253 <span className="text-muted-foreground">
254 {t('history.videoDuration')}
255 :
256 </span>
257 <span className="text-xs">
258 {video.data?.seconds
259 ? `${video.data.seconds}${t('history.duration.secondsUnit')}`
260 : ''}
261 </span>
262 </div>
263
264 <div className="flex justify-between">
265 <span className="text-muted-foreground">
266 {t('history.progress')}
267 :
268 </span>
269 <span className="text-xs">{video.progress}</span>
270 </div>
271 </div>
272
273 {/* 提示词 */}
274 <div className="mt-3">
275 <div className="font-medium mb-1 text-sm">
276 {t('history.prompt')}
277 :
278 </div>
279 <div className="bg-muted p-2 rounded text-xs max-h-20 overflow-y-auto">
280 {video.prompt}
281 </div>
282 </div>
283
284 {/* 操作按钮 */}
285 {video.status === 'SUCCESS' && video.data?.video_url && (
286 <div className="flex gap-2 mt-3">
287 <button
288 onClick={() => handlePreviewVideo(video)}
289 className="flex items-center gap-1 text-xs bg-gradient-back text-gradient-foreground px-3 py-1 rounded shadow-sm shadow-primary/20 transition-all hover:shadow-md hover:shadow-primary/25"
290 >
291 <Eye className="w-3 h-3" />
292 {t('history.previewVideo')}
293 </button>
294 </div>
295 )}
296 </div>
297 ))}
298
299 {/* 加载更多按钮 */}
300 {page < totalPages && (
301 <div className="text-center py-4 flex justify-center">
302 <button
303 onClick={handleLoadMore}
304 disabled={isLoading}
305 className="flex items-center gap-2 px-4 py-2 text-sm text-primary hover:bg-muted rounded-md transition-colors disabled:opacity-50"
306 >
307 {isLoading ? (
308 <>
309 <Loader2 className="w-4 h-4 animate-spin" />
310 {t('history.logsLoading')}
311 </>
312 ) : (
313 t('history.loadMore')
314 )}
315 </button>
316 </div>
317 )}
318
319 {/* 分页信息 */}
320 {total > 0 && (
321 <div className="flex justify-center mb-4">
322 <div className="text-sm text-muted-foreground bg-muted px-3 py-1 rounded-full">
323 {t('history.pageInfo', { page, totalPages, total })}
324 </div>
325 </div>
326 )}
327 </div>
328 )}
329 </ScrollArea>
330 </DialogContent>
331 </Dialog>
332
333 {/* 视频预览 */}
334 <MediaPreview
335 open={previewOpen}
336 items={previewItems}
337 initialIndex={previewIndex}
338 onClose={() => setPreviewOpen(false)}
339 />
340 </>
341 )
342 }
343
344 export default VideoHistoryModal
345
345 lines Plain Text