Skip to content

Commit c324cf8

Browse files
committed
feat(profile): 余额记录可点击查看,用户可在个人资料页查看自己的余额变更历史
新增用户端 GET /api/v1/users/me/balance-history 接口,个人资料页余额行 点击后弹出分页表格展示操作类型、金额、变更前后余额、备注和时间。
1 parent 438e325 commit c324cf8

6 files changed

Lines changed: 201 additions & 6 deletions

File tree

backend/internal/server/handler/user_handler_routes.go

Lines changed: 28 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -119,6 +119,34 @@ func (h *UserHandler) ChangePassword(c *gin.Context) {
119119
response.Success(c, nil)
120120
}
121121

122+
// GetMyBalanceHistory 查询当前用户余额变更记录。
123+
func (h *UserHandler) GetMyBalanceHistory(c *gin.Context) {
124+
userID, ok := currentUserID(c)
125+
if !ok {
126+
response.Unauthorized(c, "用户未认证")
127+
return
128+
}
129+
130+
var page dto.PageReq
131+
if err := c.ShouldBindQuery(&page); err != nil {
132+
response.BindError(c, err)
133+
return
134+
}
135+
136+
result, err := h.service.ListBalanceLogs(c.Request.Context(), userID, page.Page, page.PageSize)
137+
if err != nil {
138+
httpCode, message := h.handleError("查询余额日志失败", "查询失败", err)
139+
response.Error(c, httpCode, httpCode, message)
140+
return
141+
}
142+
143+
list := make([]dto.BalanceLogResp, 0, len(result.List))
144+
for _, item := range result.List {
145+
list = append(list, toBalanceLogResp(item))
146+
}
147+
response.Success(c, response.PagedData(list, result.Total, result.Page, result.PageSize))
148+
}
149+
122150
// ListUsers 管理员查询用户列表。
123151
func (h *UserHandler) ListUsers(c *gin.Context) {
124152
var page dto.PageReq

backend/internal/server/router.go

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -68,6 +68,7 @@ func (s *Server) registerRoutes() {
6868
userGroup.PUT("/users/me", handlers.User.UpdateProfile)
6969
userGroup.POST("/users/me/password", handlers.User.ChangePassword)
7070
userGroup.PUT("/users/me/balance-alert", handlers.User.UpdateBalanceAlert)
71+
userGroup.GET("/users/me/balance-history", handlers.User.GetMyBalanceHistory)
7172

7273
// API Key 管理
7374
userGroup.GET("/api-keys", handlers.APIKey.ListKeys)

web/src/i18n/en.json

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -919,7 +919,8 @@
919919
"balance_alert_desc": "Send email notification when balance drops below threshold",
920920
"balance_alert_threshold": "Alert Threshold (USD)",
921921
"balance_alert_hint": "Current balance: ${{balance}}",
922-
"balance_alert_saved": "Balance alert settings saved"
922+
"balance_alert_saved": "Balance alert settings saved",
923+
"balance_history": "Balance History"
923924
},
924925

925926
"user_keys": {

web/src/i18n/zh.json

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -922,7 +922,8 @@
922922
"balance_alert_desc": "当余额低于设定阈值时,发送邮件通知",
923923
"balance_alert_threshold": "预警阈值(美元)",
924924
"balance_alert_hint": "当前余额:${{balance}}",
925-
"balance_alert_saved": "余额预警设置已保存"
925+
"balance_alert_saved": "余额预警设置已保存",
926+
"balance_history": "余额记录"
926927
},
927928

928929
"user_keys": {

web/src/pages/user/ProfilePage.tsx

Lines changed: 166 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,12 +1,15 @@
11
import { useState, type FormEvent } from 'react';
22
import { 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';
44
import { useAuth } from '../../app/providers/AuthProvider';
55
import { usersApi } from '../../shared/api/users';
66
import { useToast } from '../../shared/ui';
77
import { useCrudMutation } from '../../shared/hooks/useCrudMutation';
88
import { 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';
1013
import {
1114
User,
1215
Mail,
@@ -17,6 +20,7 @@ import {
1720
Lock,
1821
KeyRound,
1922
Bell,
23+
ChevronRight,
2024
} from 'lucide-react';
2125

2226
export 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

246408
function BalanceAlertCard({ threshold, balance }: { threshold: number; balance: number }) {

web/src/shared/api/users.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,8 @@ export const usersApi = {
1414
updateProfile: (data: UpdateProfileReq) => put<void>('/api/v1/users/me', data),
1515
changePassword: (data: ChangePasswordReq) => post<void>('/api/v1/users/me/password', data),
1616
updateBalanceAlert: (threshold: number) => put<void>('/api/v1/users/me/balance-alert', { threshold }),
17+
myBalanceHistory: (params: PageReq) =>
18+
get<PagedData<BalanceLogResp>>('/api/v1/users/me/balance-history', params),
1719

1820
// 管理员接口
1921
list: (params: PageReq & { status?: string; role?: string }) =>

0 commit comments

Comments
 (0)