返回 AiToEarn
1 /**
2 * UserLogsModal - 用户使用日志弹窗组件
3 * 显示用户的AI使用记录,支持分页查看
4 */
5
6 'use client'
7
8 import { FileText, Loader2 } from 'lucide-react'
9 import { useEffect, useState } from 'react'
10 import { getLogs } from '@/api/ai/ai.api'
11 import { useTransClient } from '@/app/i18n/client'
12 import { getDateLocale } from '@/app/i18n/languageConfig'
13 import { Dialog, DialogContent, DialogHeader, DialogTitle } from '@/components/ui/dialog'
14 import { Empty } from '@/components/ui/empty'
15 import { ScrollArea } from '@/components/ui/scroll-area'
16 import { Skeleton } from '@/components/ui/skeleton'
17 import { useGetClientLng } from '@/hooks/useSystem'
18 import { toast } from '@/utils/ui/toast'
19
20 // 日志数据类型定义
21 interface LogItem {
22 id: string
23 userId: string
24 userType: string
25 taskId?: string
26 type: string
27 model: string
28 channel: string
29 status: 'success' | 'failed' | 'pending' | string
30 startedAt: string
31 duration?: number
32 points: number
33 createdAt: string
34 updatedAt: string
35 }
36
37 interface LogsData {
38 page: number
39 pageSize: number
40 totalPages: number
41 total: number
42 list: LogItem[]
43 }
44
45 interface LogsResponse {
46 code: string | number
47 data: LogsData
48 message: string
49 }
50
51 export interface UserLogsModalProps {
52 /** 是否显示弹窗 */
53 open: boolean
54 /** 关闭弹窗回调 */
55 onClose: () => void
56 }
57
58 /**
59 * UserLogsModal 用户使用日志弹窗组件
60 */
61 export function UserLogsModal({ open, onClose }: UserLogsModalProps) {
62 const { t } = useTransClient('chat')
63 const lng = useGetClientLng()
64
65 // 状态管理
66 const [logs, setLogs] = useState<LogItem[]>([])
67 const [isLoading, setIsLoading] = useState(false)
68 const [page, setPage] = useState(1)
69 const [total, setTotal] = useState(0)
70 const [totalPages, setTotalPages] = useState(0)
71
72 const pageSize = 20
73
74 /** 加载日志数据 */
75 const loadLogs = async (pageNum: number, isLoadMore = false) => {
76 if (isLoading)
77 return
78
79 setIsLoading(true)
80 try {
81 const result: any = await getLogs({
82 page: pageNum,
83 pageSize,
84 })
85
86 if (result && result.code === 0 && result.data) {
87 const newLogs = result.data.list || []
88
89 if (isLoadMore) {
90 setLogs(prev => [...prev, ...newLogs])
91 }
92 else {
93 setLogs(newLogs)
94 }
95
96 setTotal(result.data.total || 0)
97 setTotalPages(result.data.totalPages || 0)
98 setPage(pageNum)
99 }
100 }
101 catch (error) {
102 console.error('Load logs failed:', error)
103 toast.error(t('history.logsError'))
104 }
105 finally {
106 setIsLoading(false)
107 }
108 }
109
110 /** 初始加载 */
111 useEffect(() => {
112 if (open) {
113 loadLogs(1)
114 }
115 }, [open])
116
117 /** 加载更多 */
118 const handleLoadMore = () => {
119 if (!isLoading && page < totalPages) {
120 loadLogs(page + 1, true)
121 }
122 }
123
124 /** 格式化时间 */
125 const formatTime = (dateString: string) => {
126 const date = new Date(dateString)
127 return date.toLocaleString(getDateLocale(lng), {
128 year: 'numeric',
129 month: '2-digit',
130 day: '2-digit',
131 hour: '2-digit',
132 minute: '2-digit',
133 })
134 }
135
136 /** 获取状态文本 */
137 const getStatusText = (status: string) => {
138 switch (status) {
139 case 'success':
140 return t('history.logStatus.success')
141 case 'failed':
142 return t('history.logStatus.failed')
143 case 'pending':
144 return t('history.logStatus.pending')
145 default:
146 return status
147 }
148 }
149
150 /** 获取类型文本 */
151 const getTypeText = (type: string) => {
152 switch (type) {
153 case 'agent':
154 return t('history.logType.agent')
155 case 'chat':
156 return t('history.logType.chat')
157 default:
158 return type
159 }
160 }
161
162 /** 格式化持续时间 */
163 const formatDuration = (duration?: number) => {
164 if (!duration)
165 return null
166 const seconds = Math.floor(duration / 1000)
167 if (seconds < 60)
168 return `${seconds}s`
169 const minutes = Math.floor(seconds / 60)
170 const remainingSeconds = seconds % 60
171 return `${minutes}m${remainingSeconds}s`
172 }
173
174 return (
175 <Dialog open={open} onOpenChange={isOpen => !isOpen && onClose()}>
176 <DialogContent className="w-[95vw] max-w-[800px] max-h-[80vh]">
177 <DialogHeader>
178 <DialogTitle className="flex items-center gap-2">
179 <FileText className="w-5 h-5" />
180 {t('history.logsTitle')}
181 </DialogTitle>
182 </DialogHeader>
183
184 <ScrollArea className="flex-1 max-h-[60vh] pr-4">
185 {isLoading && logs.length === 0 ? (
186 // 初始加载骨架屏
187 <div className="space-y-4">
188 {Array.from({ length: 5 }).map((_, index) => (
189 <div key={index} className="border rounded-lg p-4 space-y-3">
190 <div className="flex justify-between items-start">
191 <Skeleton className="h-4 w-32" />
192 <Skeleton className="h-4 w-16" />
193 </div>
194 <Skeleton className="h-4 w-48" />
195 <Skeleton className="h-3 w-24" />
196 </div>
197 ))}
198 </div>
199 ) : logs.length === 0 ? (
200 // 空状态
201 <Empty
202 image={<FileText className="w-12 h-12 text-muted-foreground/50" />}
203 description={t('history.logsEmpty')}
204 />
205 ) : (
206 // 日志列表
207 <div className="space-y-4">
208 {logs.map(log => (
209 <div
210 key={log.id}
211 className="border rounded-lg p-4 hover:bg-muted/50 transition-colors"
212 >
213 <div className="flex justify-between items-start mb-3">
214 <div className="flex items-center gap-2">
215 <span className="font-medium text-sm">{getTypeText(log.type)}</span>
216 <span
217 className={`text-xs px-2 py-1 rounded-full ${
218 log.status === 'success'
219 ? 'bg-green-100 text-green-700 dark:bg-green-900 dark:text-green-300'
220 : log.status === 'failed'
221 ? 'bg-red-100 text-red-700 dark:bg-red-900 dark:text-red-300'
222 : 'bg-yellow-100 text-yellow-700 dark:bg-yellow-900 dark:text-yellow-300'
223 }`}
224 >
225 {getStatusText(log.status)}
226 </span>
227 </div>
228 <span className="text-xs text-muted-foreground">
229 {formatTime(log.startedAt)}
230 </span>
231 </div>
232
233 <div className="space-y-2 text-sm">
234 <div className="flex justify-between">
235 <span className="text-muted-foreground">
236 {t('history.logFields.model')}
237 :
238 </span>
239 <span className="font-mono text-xs">{log.model}</span>
240 </div>
241
242 <div className="flex justify-between">
243 <span className="text-muted-foreground">
244 {t('history.logFields.channel')}
245 :
246 </span>
247 <span className="font-mono text-xs">{log.channel}</span>
248 </div>
249
250 <div className="flex justify-between items-center">
251 <span className="text-muted-foreground">
252 {t('history.logFields.points')}
253 :
254 </span>
255 <span className="font-semibold text-orange-600 dark:text-orange-400">
256 {log.points.toFixed(4)}
257 </span>
258 </div>
259
260 {log.duration && (
261 <div className="flex justify-between">
262 <span className="text-muted-foreground">
263 {t('history.logFields.duration')}
264 :
265 </span>
266 <span className="text-xs">{formatDuration(log.duration)}</span>
267 </div>
268 )}
269
270 {log.taskId && (
271 <div className="flex justify-between">
272 <span className="text-muted-foreground">
273 {t('history.logFields.taskId')}
274 :
275 </span>
276 <span className="font-mono text-xs truncate max-w-32" title={log.taskId}>
277 {log.taskId}
278 </span>
279 </div>
280 )}
281 </div>
282 </div>
283 ))}
284
285 {/* 加载更多按钮 */}
286 {page < totalPages && (
287 <div className="text-center py-4 flex justify-center">
288 <button
289 onClick={handleLoadMore}
290 disabled={isLoading}
291 className="flex items-center gap-2 px-4 py-2 text-sm text-primary hover:bg-muted rounded-md transition-colors disabled:opacity-50"
292 >
293 {isLoading ? (
294 <>
295 <Loader2 className="w-4 h-4 animate-spin" />
296 {t('history.logsLoading')}
297 </>
298 ) : (
299 t('history.loadMore')
300 )}
301 </button>
302 </div>
303 )}
304
305 {/* 分页信息 */}
306 {total > 0 && (
307 <div className="flex justify-center mb-4">
308 <div className="text-sm text-muted-foreground bg-muted px-3 py-1 rounded-full">
309 {t('history.pageInfo', { page, totalPages, total })}
310 </div>
311 </div>
312 )}
313 </div>
314 )}
315 </ScrollArea>
316 </DialogContent>
317 </Dialog>
318 )
319 }
320
321 export default UserLogsModal
322
322 lines Plain Text