From 96faee026b0c514db24a26fe41ff73d9b5fcd72e Mon Sep 17 00:00:00 2001 From: yml2213 Date: Fri, 30 Oct 2026 17:16:04 +0800 Subject: [PATCH] =?UTF-8?q?feat(user):=20=E6=96=B0=E5=A2=9E=E9=82=80?= =?UTF-8?q?=E8=AF=B7=E5=A5=BD=E5=8F=8B=E9=A1=B5?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- frontend/src/app/AppRouter.tsx | 6 + .../features/invitations/InvitationsPage.tsx | 291 ++++++++++++++++++ frontend/src/features/invitations/api.ts | 59 ++++ frontend/src/features/invitations/types.ts | 49 +++ frontend/src/layouts/StorefrontLayout.tsx | 5 + 5 files changed, 410 insertions(+) create mode 100644 frontend/src/features/invitations/InvitationsPage.tsx create mode 100644 frontend/src/features/invitations/api.ts create mode 100644 frontend/src/features/invitations/types.ts diff --git a/frontend/src/app/AppRouter.tsx b/frontend/src/app/AppRouter.tsx index 22cf81b..e7f0e0c 100644 --- a/frontend/src/app/AppRouter.tsx +++ b/frontend/src/app/AppRouter.tsx @@ -75,6 +75,11 @@ const NotificationsPage = lazy(() => default: module.NotificationsPage, })) ) +const InvitationsPage = lazy(() => + import('@/features/invitations/InvitationsPage').then(module => ({ + default: module.InvitationsPage, + })) +) const MessagesPage = lazy(() => import('@/features/messages/MessagesPage').then(module => ({ default: module.MessagesPage })) ) @@ -395,6 +400,7 @@ export function AppRouter() { } /> } /> } /> + } /> } /> } /> } /> diff --git a/frontend/src/features/invitations/InvitationsPage.tsx b/frontend/src/features/invitations/InvitationsPage.tsx new file mode 100644 index 0000000..de072df --- /dev/null +++ b/frontend/src/features/invitations/InvitationsPage.tsx @@ -0,0 +1,291 @@ +import { useState } from 'react' +import { useQuery } from '@tanstack/react-query' +import { Alert, Button, Card, Empty, Space, Table, Tabs, Tag, Typography, type TableColumnsType } from 'antd' +import { CopyOutlined, GiftOutlined, ReloadOutlined } from '@ant-design/icons' + +import { + fetchInvitedRecords, + fetchMyRebates, + useInvitationSummary, +} from '@/features/invitations/api' +import type { InviteRebateLedger, InvitedUserRecord } from '@/features/invitations/types' +import { useSessionStore } from '@/stores/session' +import { formatDate, formatDateTime, formatMoney, formatText } from '@/shared/utils/format' + +const stageLabels: Record = { + first_month: '首月', + second_month: '次月', + subsequent: '后续', + unverified: '待实名', +} + +const statusLabels: Record = { + pending: { label: '待实名', color: 'gold' }, + active: { label: '已生效', color: 'green' }, +} + +const recordColumns: TableColumnsType = [ + { + title: '好友', + key: 'invitee', + width: 220, + render: (_, row) => ( +
+ {row.nickname || '未设置昵称'} + {formatText(row.masked_phone)} +
+ ), + }, + { + title: '状态', + key: 'status', + width: 120, + render: (_, row) => { + const meta = statusLabels[row.status] ?? { label: row.status || '—', color: 'default' } + return ( + + {meta.label} + + ) + }, + }, + { + title: '返利周期', + dataIndex: 'cycle_stage', + width: 110, + render: value => formatText(stageLabels[value] ?? value), + }, + { + title: '实名天数', + dataIndex: 'days_since_verified', + width: 110, + align: 'right', + render: value => `${value ?? 0} 天`, + }, + { + title: '累计贡献', + dataIndex: 'total_earned_from_cent', + width: 140, + align: 'right', + render: value => {formatMoney(value)}, + }, + { + title: '注册时间', + dataIndex: 'registered_at', + width: 130, + render: value => formatDate(value), + }, +] + +const rebateColumns: TableColumnsType = [ + { + title: '订单', + dataIndex: 'order_no', + width: 200, + render: value => {formatText(value)}, + }, + { + title: '好友', + key: 'invitee', + width: 180, + render: (_, row) => ( +
+ {formatText(row.invitee_nickname)} + {formatText(row.invitee_masked_phone)} +
+ ), + }, + { + title: '身份', + dataIndex: 'trade_role', + width: 90, + render: value => {value === 'buyer' ? '买家' : '卖家'}, + }, + { + title: '周期', + dataIndex: 'cycle_stage', + width: 90, + render: value => formatText(stageLabels[value] ?? value), + }, + { + title: '比例', + dataIndex: 'rate_applied', + width: 90, + align: 'right', + render: value => `${(Number(value) * 100).toFixed(1)}%`, + }, + { + title: '返利', + dataIndex: 'rebate_amount_cent', + width: 130, + align: 'right', + render: value => {formatMoney(value)}, + }, + { + title: '结算时间', + dataIndex: 'settled_at', + width: 170, + render: value => formatDateTime(value), + }, +] + +export function InvitationsPage() { + const user = useSessionStore(state => state.user) + const [tab, setTab] = useState('records') + const [recordPage, setRecordPage] = useState(1) + const [rebatePage, setRebatePage] = useState(1) + + const summaryQuery = useInvitationSummary(Boolean(user)) + const recordsQuery = useQuery({ + queryKey: ['invitation-records', recordPage], + queryFn: () => fetchInvitedRecords(recordPage), + enabled: Boolean(user) && tab === 'records', + }) + const rebatesQuery = useQuery({ + queryKey: ['invitation-rebates', rebatePage], + queryFn: () => fetchMyRebates(rebatePage), + enabled: Boolean(user) && tab === 'rebates', + }) + + const summary = summaryQuery.data + + const copyLink = async () => { + if (!summary?.invite_link) return + try { + await navigator.clipboard.writeText(summary.invite_link) + } catch { + // 剪贴板不可用时忽略 + } + } + + return ( +
+
+
+ JIEYOU · 邀请 + + 邀请好友 + + + 邀请好友注册并完成实名后,好友每笔交易你可获得返利:首月{' '} + {Math.round((summary?.first_month_rate ?? 0) * 100)}%,次月{' '} + {Math.round((summary?.second_month_rate ?? 0) * 100)}%,后续{' '} + {Math.round((summary?.subsequent_rate ?? 0) * 100)}%。 + +
+
+ 累计返利 + {formatMoney(summary?.total_rebate_cent ?? 0)} + + + + +
+
+ + {!summary?.is_realname_verified && !summaryQuery.isPending ? ( + 去实名} + /> + ) : null} + +
+
+ 邀请码 + {summary?.invite_code || '—'} + 好友注册时填写或通过链接进入 +
+
+ 已邀请好友 + {summary?.total_invited_count ?? 0} + + 已生效 {summary?.active_invited_count ?? 0} · 待实名{' '} + {summary?.pending_invited_count ?? 0} + +
+
+ 本月预估 + {formatMoney(summary?.current_month_est_cent ?? 0)} + 按已结算交易预估 +
+
+ 累计返利 + {formatMoney(summary?.total_rebate_cent ?? 0)} + + 返利直接进入钱包余额 + +
+
+ + {summaryQuery.isError ? ( + + ) : null} + + + + ) : ( + + rowKey="id" + size="middle" + columns={recordColumns} + dataSource={recordsQuery.data?.items ?? []} + loading={recordsQuery.isFetching} + scroll={{ x: 'max-content' }} + pagination={{ + current: recordsQuery.data?.page ?? recordPage, + pageSize: recordsQuery.data?.page_size ?? 20, + total: recordsQuery.data?.total ?? 0, + onChange: next => setRecordPage(next), + showSizeChanger: false, + }} + /> + ), + }, + { + key: 'rebates', + label: '返利明细', + children: (rebatesQuery.data?.items ?? []).length === 0 && !rebatesQuery.isPending ? ( + + ) : ( + + rowKey="id" + size="middle" + columns={rebateColumns} + dataSource={rebatesQuery.data?.items ?? []} + loading={rebatesQuery.isFetching} + scroll={{ x: 'max-content' }} + pagination={{ + current: rebatesQuery.data?.page ?? rebatePage, + pageSize: rebatesQuery.data?.page_size ?? 20, + total: rebatesQuery.data?.total ?? 0, + onChange: next => setRebatePage(next), + showSizeChanger: false, + }} + /> + ), + }, + ]} + /> + +
+ ) +} diff --git a/frontend/src/features/invitations/api.ts b/frontend/src/features/invitations/api.ts new file mode 100644 index 0000000..4e387e6 --- /dev/null +++ b/frontend/src/features/invitations/api.ts @@ -0,0 +1,59 @@ +import { useQuery } from '@tanstack/react-query' + +import { apiClient, unwrapData } from '@/shared/api/client' +import type { ApiResponse } from '@/shared/api/types' +import type { + InvitationSummary, + InviteRebateLedger, + InvitedUserRecord, + PagedResult, +} from '@/features/invitations/types' + +const PAGE_SIZE = 20 + +export function useInvitationSummary(enabled = true) { + return useQuery({ + queryKey: ['invitation-summary'], + queryFn: () => + unwrapData( + apiClient.get>('/user/invitations/summary') + ), + enabled, + }) +} + +export function fetchInvitedRecords(page = 1, pageSize = PAGE_SIZE) { + return unwrapData>>( + apiClient.get>>>( + '/user/invitations/records', + { params: { page, page_size: pageSize } } + ) + ).then(data => ({ + items: data.items ?? [], + total: data.total ?? 0, + page: data.page ?? page, + page_size: data.page_size ?? pageSize, + })) +} + +export function fetchMyRebates(page = 1, pageSize = PAGE_SIZE) { + return unwrapData>>( + apiClient.get>>>( + '/user/invitations/rebates', + { params: { page, page_size: pageSize } } + ) + ).then(data => ({ + items: data.items ?? [], + total: data.total ?? 0, + page: data.page ?? page, + page_size: data.page_size ?? pageSize, + })) +} + +export function fetchInviteCodeInfo(code: string) { + return unwrapData<{ valid: boolean; invite_code: string; inviter_nickname: string; inviter_avatar: string }>( + apiClient.get>( + `/invitations/code-info/${code}` + ) + ) +} diff --git a/frontend/src/features/invitations/types.ts b/frontend/src/features/invitations/types.ts new file mode 100644 index 0000000..14919bc --- /dev/null +++ b/frontend/src/features/invitations/types.ts @@ -0,0 +1,49 @@ +export interface InvitationSummary { + has_invite_code: boolean + invite_code: string + invite_link: string + is_realname_verified: boolean + total_invited_count: number + active_invited_count: number + pending_invited_count: number + total_rebate_cent: number + current_month_est_cent: number + first_month_rate: number + second_month_rate: number + subsequent_rate: number +} + +export interface InvitedUserRecord { + id: number + invitee_id: number + masked_phone: string + nickname: string + avatar_url: string + status: string + cycle_stage: string + days_since_verified: number + verified_at?: string | null + registered_at: string + total_earned_from_cent: number +} + +export interface InviteRebateLedger { + id: number + order_id: number + order_no: string + invitee_masked_phone: string + invitee_nickname: string + trade_role: string + single_side_basis_cent: number + rate_applied: number + cycle_stage: string + rebate_amount_cent: number + settled_at: string +} + +export interface PagedResult { + items: T[] + total: number + page: number + page_size: number +} diff --git a/frontend/src/layouts/StorefrontLayout.tsx b/frontend/src/layouts/StorefrontLayout.tsx index 125d9ab..653aafa 100644 --- a/frontend/src/layouts/StorefrontLayout.tsx +++ b/frontend/src/layouts/StorefrontLayout.tsx @@ -58,6 +58,11 @@ export function StorefrontLayout() { label: '我的通知', onClick: () => navigate('/notifications'), }, + { + key: 'invitations', + label: '邀请好友', + onClick: () => navigate('/invitations'), + }, { key: 'announcements', label: '公告中心',