feat(user): 新增邀请好友页
This commit is contained in:
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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: '公告中心',
|
||||
|
||||
Reference in New Issue
Block a user