feat(user): 新增邀请好友页

This commit is contained in:
yml2213
2026-10-30 17:16:04 +08:00
parent 89e6a13fa7
commit 96faee026b
5 changed files with 410 additions and 0 deletions
+6
View File
@@ -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() {
<Route path="announcements" element={<AnnouncementsPage />} />
<Route path="announcements/:id" element={<AnnouncementDetailPage />} />
<Route path="notifications" element={<NotificationsPage />} />
<Route path="invitations" element={<InvitationsPage />} />
<Route path="messages" element={<MessagesPage />} />
<Route path="messages/:id" element={<ChatPage />} />
<Route path="seller/listings" element={<SellerListingsPage />} />
@@ -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<string, string> = {
first_month: '首月',
second_month: '次月',
subsequent: '后续',
unverified: '待实名',
}
const statusLabels: Record<string, { label: string; color: string }> = {
pending: { label: '待实名', color: 'gold' },
active: { label: '已生效', color: 'green' },
}
const recordColumns: TableColumnsType<InvitedUserRecord> = [
{
title: '好友',
key: 'invitee',
width: 220,
render: (_, row) => (
<div className="admin-stack">
<strong>{row.nickname || '未设置昵称'}</strong>
<span className="admin-mono">{formatText(row.masked_phone)}</span>
</div>
),
},
{
title: '状态',
key: 'status',
width: 120,
render: (_, row) => {
const meta = statusLabels[row.status] ?? { label: row.status || '—', color: 'default' }
return (
<Tag bordered={false} color={meta.color}>
{meta.label}
</Tag>
)
},
},
{
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 => <strong>{formatMoney(value)}</strong>,
},
{
title: '注册时间',
dataIndex: 'registered_at',
width: 130,
render: value => formatDate(value),
},
]
const rebateColumns: TableColumnsType<InviteRebateLedger> = [
{
title: '订单',
dataIndex: 'order_no',
width: 200,
render: value => <span className="admin-mono">{formatText(value)}</span>,
},
{
title: '好友',
key: 'invitee',
width: 180,
render: (_, row) => (
<div className="admin-stack">
<span>{formatText(row.invitee_nickname)}</span>
<span className="admin-mono">{formatText(row.invitee_masked_phone)}</span>
</div>
),
},
{
title: '身份',
dataIndex: 'trade_role',
width: 90,
render: value => <Tag bordered={false}>{value === 'buyer' ? '买家' : '卖家'}</Tag>,
},
{
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 => <strong className="wallet-amount-up">{formatMoney(value)}</strong>,
},
{
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 (
<div className="wallet-page">
<section className="wallet-hero">
<div>
<Typography.Text className="wallet-eyebrow">JIEYOU · 邀请</Typography.Text>
<Typography.Title level={1} className="brand-title wallet-title">
邀请好友
</Typography.Title>
<Typography.Paragraph className="wallet-desc">
邀请好友注册并完成实名后,好友每笔交易你可获得返利:首月{' '}
{Math.round((summary?.first_month_rate ?? 0) * 100)}%,次月{' '}
{Math.round((summary?.second_month_rate ?? 0) * 100)}%,后续{' '}
{Math.round((summary?.subsequent_rate ?? 0) * 100)}%。
</Typography.Paragraph>
</div>
<div className="wallet-hero__action">
<span>累计返利</span>
<strong>{formatMoney(summary?.total_rebate_cent ?? 0)}</strong>
<Space size={8} wrap>
<Button icon={<CopyOutlined />} onClick={() => void copyLink()} disabled={!summary?.invite_link}>
复制邀请链接
</Button>
<Button
icon={<ReloadOutlined />}
loading={summaryQuery.isFetching}
onClick={() => void summaryQuery.refetch()}
>
刷新
</Button>
</Space>
</div>
</section>
{!summary?.is_realname_verified && !summaryQuery.isPending ? (
<Alert
type="warning"
showIcon
message="完成实名后才能生成邀请码并获得返利"
action={<Button size="small" href="/realname">去实名</Button>}
/>
) : null}
<div className="wallet-metrics">
<div className="wallet-metric">
<span>邀请码</span>
<strong>{summary?.invite_code || '—'}</strong>
<small>好友注册时填写或通过链接进入</small>
</div>
<div className="wallet-metric">
<span>已邀请好友</span>
<strong>{summary?.total_invited_count ?? 0}</strong>
<small>
已生效 {summary?.active_invited_count ?? 0} · 待实名{' '}
{summary?.pending_invited_count ?? 0}
</small>
</div>
<div className="wallet-metric">
<span>本月预估</span>
<strong>{formatMoney(summary?.current_month_est_cent ?? 0)}</strong>
<small>按已结算交易预估</small>
</div>
<div className="wallet-metric">
<span>累计返利</span>
<strong>{formatMoney(summary?.total_rebate_cent ?? 0)}</strong>
<small>
<GiftOutlined /> 返利直接进入钱包余额
</small>
</div>
</div>
{summaryQuery.isError ? (
<Alert type="error" showIcon message="邀请信息加载失败" />
) : null}
<Card className="wallet-ledger-card">
<Tabs
activeKey={tab}
onChange={setTab}
items={[
{
key: 'records',
label: '邀请记录',
children: (recordsQuery.data?.items ?? []).length === 0 && !recordsQuery.isPending ? (
<Empty description="还没有邀请记录" />
) : (
<Table<InvitedUserRecord>
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 ? (
<Empty description="还没有返利记录" />
) : (
<Table<InviteRebateLedger>
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,
}}
/>
),
},
]}
/>
</Card>
</div>
)
}
+59
View File
@@ -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<InvitationSummary>(
apiClient.get<ApiResponse<InvitationSummary>>('/user/invitations/summary')
),
enabled,
})
}
export function fetchInvitedRecords(page = 1, pageSize = PAGE_SIZE) {
return unwrapData<Partial<PagedResult<InvitedUserRecord>>>(
apiClient.get<ApiResponse<Partial<PagedResult<InvitedUserRecord>>>>(
'/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<Partial<PagedResult<InviteRebateLedger>>>(
apiClient.get<ApiResponse<Partial<PagedResult<InviteRebateLedger>>>>(
'/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<ApiResponse<{ valid: boolean; invite_code: string; inviter_nickname: string; inviter_avatar: string }>>(
`/invitations/code-info/${code}`
)
)
}
@@ -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<T> {
items: T[]
total: number
page: number
page_size: number
}
@@ -58,6 +58,11 @@ export function StorefrontLayout() {
label: '我的通知',
onClick: () => navigate('/notifications'),
},
{
key: 'invitations',
label: '邀请好友',
onClick: () => navigate('/invitations'),
},
{
key: 'announcements',
label: '公告中心',