11import { useState , type FormEvent } from 'react' ;
22import { useTranslation } from 'react-i18next' ;
3- import { Button , Card , Chip , Description , Form , Input , Label , Switch , TextField as HeroTextField } from '@heroui/react' ;
3+ import { Button , Card , Chip , Description , EmptyState , Form , Input , Label , Modal , Skeleton , Switch , Table as HeroTable , TextField as HeroTextField , useOverlayState } from '@heroui/react' ;
44import { useAuth } from '../../app/providers/AuthProvider' ;
55import { usersApi } from '../../shared/api/users' ;
66import { useToast } from '../../shared/ui' ;
77import { useCrudMutation } from '../../shared/hooks/useCrudMutation' ;
88import { queryKeys } from '../../shared/queryKeys' ;
9- import { useMutation } from '@tanstack/react-query' ;
9+ import { useMutation , useQuery } from '@tanstack/react-query' ;
10+ import { getTotalPages } from '../../shared/utils/pagination' ;
11+ import { TablePaginationFooter } from '../../shared/components/TablePaginationFooter' ;
12+ import type { BalanceLogResp } from '../../shared/types' ;
1013import {
1114 User ,
1215 Mail ,
@@ -17,6 +20,7 @@ import {
1720 Lock ,
1821 KeyRound ,
1922 Bell ,
23+ ChevronRight ,
2024} from 'lucide-react' ;
2125
2226export default function ProfilePage ( ) {
@@ -75,6 +79,8 @@ export default function ProfilePage() {
7579 } ) ;
7680 }
7781
82+ const [ balanceHistoryOpen , setBalanceHistoryOpen ] = useState ( false ) ;
83+
7884 if ( ! user ) return null ;
7985
8086 return (
@@ -102,15 +108,20 @@ export default function ProfilePage() {
102108 { user . role === 'admin' ? t ( 'nav.admin' ) : t ( 'nav.user' ) }
103109 </ Chip >
104110 </ div >
105- < div className = "flex items-center gap-4" >
111+ < button
112+ type = "button"
113+ className = "flex items-center gap-4 w-full rounded-md px-1 -mx-1 py-1 transition-colors hover:bg-surface-hover cursor-pointer text-left"
114+ onClick = { ( ) => setBalanceHistoryOpen ( true ) }
115+ >
106116 < div className = "flex items-center gap-2 w-28 shrink-0" >
107117 < Wallet className = "w-4 h-4 text-text-tertiary" />
108118 < span className = "text-xs font-medium text-text-secondary" > { t ( 'profile.balance' ) } </ span >
109119 </ div >
110120 < span className = "text-sm text-text font-mono" >
111121 ${ user . balance . toFixed ( 4 ) }
112122 </ span >
113- </ div >
123+ < ChevronRight className = "w-4 h-4 text-text-tertiary ml-auto" />
124+ </ button >
114125 < div className = "flex items-center gap-4" >
115126 < div className = "flex items-center gap-2 w-28 shrink-0" >
116127 < Layers className = "w-4 h-4 text-text-tertiary" />
@@ -164,6 +175,13 @@ export default function ProfilePage() {
164175 </ Card . Content >
165176 </ Card >
166177
178+ { /* 余额记录 */ }
179+ < MyBalanceHistoryModal
180+ open = { balanceHistoryOpen }
181+ balance = { user . balance }
182+ onClose = { ( ) => setBalanceHistoryOpen ( false ) }
183+ />
184+
167185 { /* 修改密码 */ }
168186 < Card className = "mb-6" >
169187 < Card . Header >
@@ -241,6 +259,150 @@ export default function ProfilePage() {
241259 ) ;
242260}
243261
262+ /* ==================== 余额记录弹窗 ==================== */
263+
264+ function MyBalanceHistoryModal ( { open, balance, onClose } : { open : boolean ; balance : number ; onClose : ( ) => void } ) {
265+ const { t } = useTranslation ( ) ;
266+ const [ page , setPage ] = useState ( 1 ) ;
267+
268+ const { data, isLoading } = useQuery ( {
269+ queryKey : [ 'my-balance-history' , page ] ,
270+ queryFn : ( ) => usersApi . myBalanceHistory ( { page, page_size : 10 } ) ,
271+ enabled : open ,
272+ } ) ;
273+
274+ const actionLabel = ( action : string ) => {
275+ switch ( action ) {
276+ case 'add' : return t ( 'users.action_add' ) ;
277+ case 'subtract' : return t ( 'users.action_subtract' ) ;
278+ case 'set' : return t ( 'users.action_set' ) ;
279+ default : return action ;
280+ }
281+ } ;
282+
283+ const actionColor = ( action : string ) : 'success' | 'warning' | 'accent' => {
284+ switch ( action ) {
285+ case 'add' : return 'success' ;
286+ case 'subtract' : return 'warning' ;
287+ default : return 'accent' ;
288+ }
289+ } ;
290+
291+ const rows = data ?. list ?? [ ] ;
292+ const total = data ?. total ?? 0 ;
293+ const totalPages = getTotalPages ( total , 10 ) ;
294+ const modalState = useOverlayState ( {
295+ isOpen : open ,
296+ onOpenChange : ( nextOpen ) => {
297+ if ( ! nextOpen ) onClose ( ) ;
298+ } ,
299+ } ) ;
300+
301+ return (
302+ < Modal state = { modalState } >
303+ < Modal . Backdrop >
304+ < Modal . Container placement = "center" scroll = "inside" size = "md" >
305+ < Modal . Dialog
306+ className = "ag-elevation-modal"
307+ style = { { maxWidth : '750px' , width : 'min(100%, calc(100vw - 2rem))' } }
308+ >
309+ < Modal . Header >
310+ < Modal . Heading > { t ( 'profile.balance_history' ) } </ Modal . Heading >
311+ < Modal . CloseTrigger />
312+ </ Modal . Header >
313+ < Modal . Body >
314+ < div className = "mb-4 rounded-md border border-glass-border bg-surface px-4 py-3" >
315+ < p className = "text-xs text-text-tertiary" > { t ( 'users.current_balance' ) } </ p >
316+ < p className = "mt-1 font-mono text-lg font-bold" > ${ balance . toFixed ( 2 ) } </ p >
317+ </ div >
318+
319+ < HeroTable variant = "primary" >
320+ < HeroTable . ScrollContainer >
321+ < HeroTable . Content aria-label = { t ( 'profile.balance_history' ) } >
322+ < HeroTable . Header >
323+ < HeroTable . Column id = "action" isRowHeader style = { { width : 96 } } >
324+ { t ( 'users.action_type' ) }
325+ </ HeroTable . Column >
326+ < HeroTable . Column id = "amount" > { t ( 'users.amount' ) } </ HeroTable . Column >
327+ < HeroTable . Column id = "balance_change" >
328+ { t ( 'users.before_balance' ) } → { t ( 'users.after_balance' ) }
329+ </ HeroTable . Column >
330+ < HeroTable . Column id = "remark" > { t ( 'users.remark' ) } </ HeroTable . Column >
331+ < HeroTable . Column id = "created_at" > { t ( 'users.created_at' ) } </ HeroTable . Column >
332+ </ HeroTable . Header >
333+ < HeroTable . Body >
334+ { isLoading ? (
335+ Array . from ( { length : 5 } ) . map ( ( _ , index ) => (
336+ < HeroTable . Row id = { `loading-${ index } ` } key = { `loading-${ index } ` } >
337+ { Array . from ( { length : 5 } ) . map ( ( __ , cellIndex ) => (
338+ < HeroTable . Cell key = { cellIndex } >
339+ < Skeleton className = "h-4 w-24" />
340+ </ HeroTable . Cell >
341+ ) ) }
342+ </ HeroTable . Row >
343+ ) )
344+ ) : rows . length === 0 ? (
345+ < HeroTable . Row id = "empty" >
346+ < HeroTable . Cell colSpan = { 5 } >
347+ < EmptyState >
348+ < div className = "text-sm text-default-500" > { t ( 'common.no_data' ) } </ div >
349+ </ EmptyState >
350+ </ HeroTable . Cell >
351+ </ HeroTable . Row >
352+ ) : (
353+ rows . map ( ( row : BalanceLogResp ) => (
354+ < HeroTable . Row id = { String ( row . id ) } key = { row . id } >
355+ < HeroTable . Cell >
356+ < Chip color = { actionColor ( row . action ) } size = "sm" variant = "soft" >
357+ { actionLabel ( row . action ) }
358+ </ Chip >
359+ </ HeroTable . Cell >
360+ < HeroTable . Cell >
361+ < span className = { `font-mono text-xs font-semibold ${ row . action === 'add' ? 'text-success' : row . action === 'subtract' ? 'text-danger' : 'text-info' } ` } >
362+ { row . action === 'add' ? '+' : row . action === 'subtract' ? '-' : '=' } { row . amount . toFixed ( 2 ) }
363+ </ span >
364+ </ HeroTable . Cell >
365+ < HeroTable . Cell >
366+ < span className = "font-mono text-xs text-text-secondary" >
367+ ${ row . before_balance . toFixed ( 2 ) } → ${ row . after_balance . toFixed ( 2 ) }
368+ </ span >
369+ </ HeroTable . Cell >
370+ < HeroTable . Cell >
371+ < span className = "text-xs text-text-tertiary" > { row . remark || '-' } </ span >
372+ </ HeroTable . Cell >
373+ < HeroTable . Cell >
374+ < span className = "text-xs text-text-secondary" >
375+ { new Date ( row . created_at ) . toLocaleString ( 'zh-CN' , {
376+ day : '2-digit' ,
377+ hour : '2-digit' ,
378+ minute : '2-digit' ,
379+ month : '2-digit' ,
380+ } ) }
381+ </ span >
382+ </ HeroTable . Cell >
383+ </ HeroTable . Row >
384+ ) )
385+ ) }
386+ </ HeroTable . Body >
387+ </ HeroTable . Content >
388+ </ HeroTable . ScrollContainer >
389+ < HeroTable . Footer >
390+ < TablePaginationFooter
391+ page = { page }
392+ setPage = { setPage }
393+ total = { total }
394+ totalPages = { totalPages }
395+ />
396+ </ HeroTable . Footer >
397+ </ HeroTable >
398+ </ Modal . Body >
399+ </ Modal . Dialog >
400+ </ Modal . Container >
401+ </ Modal . Backdrop >
402+ </ Modal >
403+ ) ;
404+ }
405+
244406/* ==================== 余额预警卡片 ==================== */
245407
246408function BalanceAlertCard ( { threshold, balance } : { threshold : number ; balance : number } ) {
0 commit comments