feat(admin): 新增邀请管理页

This commit is contained in:
yml2213
2026-10-30 17:12:32 +08:00
parent bb0581c218
commit e309fddd18
5 changed files with 636 additions and 0 deletions
+6
View File
@@ -261,6 +261,11 @@ const AdminPushPage = lazy(() =>
default: module.AdminPushPage,
}))
)
const AdminInvitationsPage = lazy(() =>
import('@/features/admin/AdminInvitationsPage').then(module => ({
default: module.AdminInvitationsPage,
}))
)
const AdminPickupDetailPage = lazy(() =>
import('@/features/admin/AdminPickupDetailPage').then(module => ({
default: module.AdminPickupDetailPage,
@@ -432,6 +437,7 @@ export function AppRouter() {
<Route path="qrcode-pool" element={<AdminQrCodePoolPage />} />
<Route path="notifications" element={<AdminNotificationsPage />} />
<Route path="push" element={<AdminPushPage />} />
<Route path="invitations" element={<AdminInvitationsPage />} />
<Route path="finance/details" element={<AdminFinanceDetailsPage />} />
<Route path="wallet-ledger" element={<AdminWalletLedgerPage />} />
<Route path="chat-quick-replies" element={<AdminQuickRepliesPage />} />
@@ -0,0 +1,472 @@
import { useEffect, useState } from 'react'
import { keepPreviousData, useQuery, useQueryClient } from '@tanstack/react-query'
import {
Alert,
App,
Button,
Card,
Form,
Input,
InputNumber,
Segmented,
Space,
Statistic,
Switch,
Tag,
Typography,
type TableColumnsType,
} from 'antd'
import { ReloadOutlined } from '@ant-design/icons'
import {
fetchAdminInvitationConfig,
fetchAdminInvitationRelations,
fetchAdminInviteRebates,
fetchAdminInviteRebateSummary,
updateAdminInvitationConfig,
} from '@/features/admin/api'
import { AdminPageHeader } from '@/features/admin/components/AdminPageHeader'
import { DataTable } from '@/features/admin/components/DataTable'
import { FilterBar, FilterField } from '@/features/admin/components/FilterBar'
import type {
AdminInvitationRelation,
AdminInviteRebate,
} from '@/features/admin/types'
import { useAdminSessionStore } from '@/stores/adminSession'
import { formatDateTime, formatMoney, formatText } from '@/shared/utils/format'
const statusMeta: Record<string, { label: string; color: string }> = {
pending: { label: '待实名', color: 'gold' },
active: { label: '已生效', color: 'green' },
}
function errorText(error: unknown) {
const response = (error as { response?: { data?: { message?: string } } })?.response
return response?.data?.message || (error instanceof Error ? error.message : '请求失败')
}
const relationColumns: TableColumnsType<AdminInvitationRelation> = [
{
title: '邀请人',
key: 'inviter',
width: 200,
render: (_, row) => (
<div className="admin-stack">
<strong>{formatText(row.inviter_nickname)}</strong>
<span className="admin-mono">{formatText(row.inviter_phone)}</span>
</div>
),
},
{
title: '被邀请人',
key: 'invitee',
width: 200,
render: (_, row) => (
<div className="admin-stack">
<strong>{formatText(row.invitee_nickname)}</strong>
<span className="admin-mono">{formatText(row.invitee_phone)}</span>
</div>
),
},
{
title: '邀请码',
dataIndex: 'invite_code',
width: 140,
render: value => <span className="admin-mono">{formatText(value)}</span>,
},
{
title: '状态',
dataIndex: 'status',
width: 110,
render: value => {
const meta = statusMeta[value] ?? { label: value || '—', color: 'default' }
return (
<Tag bordered={false} color={meta.color}>
{meta.label}
</Tag>
)
},
},
{
title: '实名时间',
dataIndex: 'verified_at',
width: 170,
render: value => (value ? formatDateTime(value) : '—'),
},
{
title: '绑定时间',
dataIndex: 'created_at',
width: 170,
render: value => formatDateTime(value),
},
]
const rebateColumns: TableColumnsType<AdminInviteRebate> = [
{
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: '角色 / 周期',
key: 'role',
width: 160,
render: (_, row) => (
<Space size={4} wrap>
<Tag bordered={false}>{row.trade_role === 'buyer' ? '买家' : '卖家'}</Tag>
<Tag bordered={false}>{formatText(row.cycle_stage)}</Tag>
</Space>
),
},
{
title: '基数',
dataIndex: 'single_side_basis_cent',
width: 130,
align: 'right',
render: value => formatMoney(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>{formatMoney(value)}</strong>,
},
{
title: '结算时间',
dataIndex: 'settled_at',
width: 170,
render: value => formatDateTime(value),
},
]
export function AdminInvitationsPage() {
const { message } = App.useApp()
const queryClient = useQueryClient()
const [configForm] = Form.useForm<{
first_month_rate: number
second_month_rate: number
subsequent_rate: number
enabled: boolean
}>()
const [tab, setTab] = useState<'relations' | 'rebates'>('relations')
const [relFilters, setRelFilters] = useState({ phone: '', invite_code: '' })
const [relApplied, setRelApplied] = useState({ phone: '', invite_code: '' })
const [relPage, setRelPage] = useState(1)
const [rebateFilters, setRebateFilters] = useState({ order_no: '', inviter_phone: '', invitee_phone: '' })
const [rebateApplied, setRebateApplied] = useState(rebateFilters)
const [rebatePage, setRebatePage] = useState(1)
const [pageSize] = useState(20)
const [saving, setSaving] = useState(false)
const permissions = useAdminSessionStore(state => state.admin?.permissions)
const canView = Boolean(
permissions?.includes('*') ||
permissions?.includes('distribution:site_view') ||
permissions?.includes('distribution:settlement_view')
)
const canManage = Boolean(permissions?.includes('*') || permissions?.includes('distribution:site_manage'))
const configQuery = useQuery({
queryKey: ['admin-invitation-config'],
queryFn: () => fetchAdminInvitationConfig(),
enabled: canView,
})
const relationsQuery = useQuery({
queryKey: ['admin-invitation-relations', relApplied, relPage],
queryFn: () =>
fetchAdminInvitationRelations({
phone: relApplied.phone.trim() || undefined,
invite_code: relApplied.invite_code.trim() || undefined,
page: relPage,
page_size: pageSize,
}),
enabled: canView && tab === 'relations',
placeholderData: keepPreviousData,
})
const rebatesQuery = useQuery({
queryKey: ['admin-invite-rebates', rebateApplied, rebatePage],
queryFn: () =>
fetchAdminInviteRebates({
order_no: rebateApplied.order_no.trim() || undefined,
inviter_phone: rebateApplied.inviter_phone.trim() || undefined,
invitee_phone: rebateApplied.invitee_phone.trim() || undefined,
page: rebatePage,
page_size: pageSize,
}),
enabled: canView && tab === 'rebates',
placeholderData: keepPreviousData,
})
const summaryQuery = useQuery({
queryKey: ['admin-invite-rebate-summary', rebateApplied],
queryFn: () =>
fetchAdminInviteRebateSummary({
order_no: rebateApplied.order_no.trim() || undefined,
inviter_phone: rebateApplied.inviter_phone.trim() || undefined,
invitee_phone: rebateApplied.invitee_phone.trim() || undefined,
}),
enabled: canView && tab === 'rebates',
})
useEffect(() => {
if (!configQuery.data) return
configForm.setFieldsValue({
first_month_rate: Number((configQuery.data.first_month_rate * 100).toFixed(2)),
second_month_rate: Number((configQuery.data.second_month_rate * 100).toFixed(2)),
subsequent_rate: Number((configQuery.data.subsequent_rate * 100).toFixed(2)),
enabled: configQuery.data.enabled,
})
}, [configQuery.data, configForm])
if (!canView) {
return (
<div className="admin-page">
<AdminPageHeader eyebrow="JIEYOU OPS · 运营" title="邀请管理" />
<Alert type="warning" showIcon message="当前账号没有邀请数据查看权限" />
</div>
)
}
const summary = summaryQuery.data
return (
<div className="admin-page">
<AdminPageHeader
eyebrow="JIEYOU OPS · 运营"
title="邀请管理"
description="维护邀请返利比例,查看邀请关系与返利流水;比例按百分比录入,提交时转 0–1 小数。"
meta={
<Space size={8} wrap>
<Tag color="geekblue">返利总额 {formatMoney(summary?.total_rebate_cent ?? 0)}</Tag>
<Tag>明细 {summary?.ledger_count ?? 0} 条</Tag>
</Space>
}
extra={
<Button
icon={<ReloadOutlined />}
loading={relationsQuery.isFetching || rebatesQuery.isFetching}
onClick={() => {
void relationsQuery.refetch()
void rebatesQuery.refetch()
void summaryQuery.refetch()
}}
>
刷新
</Button>
}
/>
<Card title="返利比例配置" className="admin-table-card" style={{ marginBottom: 16 }}>
<Form form={configForm} layout="inline" style={{ gap: 12, flexWrap: 'wrap' }}>
<Form.Item name="first_month_rate" label="首月(%)">
<InputNumber min={0} max={100} precision={2} style={{ width: 120 }} disabled={!canManage} />
</Form.Item>
<Form.Item name="second_month_rate" label="次月(%)">
<InputNumber min={0} max={100} precision={2} style={{ width: 120 }} disabled={!canManage} />
</Form.Item>
<Form.Item name="subsequent_rate" label="后续(%)">
<InputNumber min={0} max={100} precision={2} style={{ width: 120 }} disabled={!canManage} />
</Form.Item>
<Form.Item name="enabled" label="启用" valuePropName="checked">
<Switch disabled={!canManage} />
</Form.Item>
<Form.Item>
<Button
type="primary"
loading={saving}
disabled={!canManage}
onClick={async () => {
const values = await configForm.validateFields()
setSaving(true)
try {
await updateAdminInvitationConfig({
first_month_rate: values.first_month_rate / 100,
second_month_rate: values.second_month_rate / 100,
subsequent_rate: values.subsequent_rate / 100,
enabled: values.enabled,
})
message.success('返利比例已更新')
await queryClient.invalidateQueries({ queryKey: ['admin-invitation-config'] })
} catch (error) {
message.error(errorText(error))
} finally {
setSaving(false)
}
}}
>
保存配置
</Button>
</Form.Item>
</Form>
<Typography.Paragraph type="secondary" style={{ margin: '8px 0 0' }}>
最近更新:{formatDateTime(configQuery.data?.updated_at)}
</Typography.Paragraph>
</Card>
<Space style={{ marginBottom: 12 }}>
<Segmented
value={tab}
options={[
{ value: 'relations', label: '邀请关系' },
{ value: 'rebates', label: '返利流水' },
]}
onChange={value => setTab(value as 'relations' | 'rebates')}
/>
</Space>
{tab === 'relations' ? (
<>
<FilterBar
loading={relationsQuery.isFetching}
onSearch={() => {
setRelPage(1)
setRelApplied(relFilters)
}}
onReset={() => {
setRelFilters({ phone: '', invite_code: '' })
setRelApplied({ phone: '', invite_code: '' })
setRelPage(1)
}}
>
<FilterField label="手机号">
<Input
allowClear
value={relFilters.phone}
placeholder="邀请人/被邀请人"
onChange={event =>
setRelFilters(current => ({ ...current, phone: event.target.value }))
}
/>
</FilterField>
<FilterField label="邀请码">
<Input
allowClear
value={relFilters.invite_code}
onChange={event =>
setRelFilters(current => ({ ...current, invite_code: event.target.value }))
}
/>
</FilterField>
</FilterBar>
<DataTable<AdminInvitationRelation>
columns={relationColumns}
dataSource={relationsQuery.data?.items ?? []}
rowKey="id"
loading={relationsQuery.isFetching}
total={relationsQuery.data?.total ?? 0}
page={relationsQuery.data?.page ?? relPage}
pageSize={relationsQuery.data?.page_size ?? pageSize}
onPageChange={nextPage => setRelPage(nextPage)}
emptyDescription="还没有邀请关系"
/>
</>
) : (
<>
<div className="wallet-metrics" style={{ marginBottom: 12 }}>
<div className="wallet-metric">
<span>返利总额</span>
<strong>{formatMoney(summary?.total_rebate_cent ?? 0)}</strong>
<small>明细 {summary?.ledger_count ?? 0} 条</small>
</div>
<div className="wallet-metric">
<span>邀请人 / 被邀请人</span>
<strong>
{summary?.inviter_count ?? 0} / {summary?.invitee_count ?? 0}
</strong>
<small>去重统计</small>
</div>
<div className="wallet-metric">
<span>订单 / 提号返利</span>
<strong>{formatMoney(summary?.order_rebate_cent ?? 0)}</strong>
<small>提号 {formatMoney(summary?.pickup_rebate_cent ?? 0)}</small>
</div>
<div className="wallet-metric">
<span>首月 / 次月 / 后续</span>
<strong>{formatMoney(summary?.first_month_cent ?? 0)}</strong>
<small>
次月 {formatMoney(summary?.second_month_cent ?? 0)} · 后续{' '}
{formatMoney(summary?.subsequent_cent ?? 0)}
</small>
</div>
</div>
<FilterBar
loading={rebatesQuery.isFetching}
onSearch={() => {
setRebatePage(1)
setRebateApplied(rebateFilters)
}}
onReset={() => {
const empty = { order_no: '', inviter_phone: '', invitee_phone: '' }
setRebateFilters(empty)
setRebateApplied(empty)
setRebatePage(1)
}}
>
<FilterField label="订单号">
<Input
allowClear
value={rebateFilters.order_no}
onChange={event =>
setRebateFilters(current => ({ ...current, order_no: event.target.value }))
}
/>
</FilterField>
<FilterField label="邀请人手机">
<Input
allowClear
value={rebateFilters.inviter_phone}
onChange={event =>
setRebateFilters(current => ({ ...current, inviter_phone: event.target.value }))
}
/>
</FilterField>
<FilterField label="被邀请人手机">
<Input
allowClear
value={rebateFilters.invitee_phone}
onChange={event =>
setRebateFilters(current => ({ ...current, invitee_phone: event.target.value }))
}
/>
</FilterField>
</FilterBar>
<DataTable<AdminInviteRebate>
columns={rebateColumns}
dataSource={rebatesQuery.data?.items ?? []}
rowKey="id"
loading={rebatesQuery.isFetching}
total={rebatesQuery.data?.total ?? 0}
page={rebatesQuery.data?.page ?? rebatePage}
pageSize={rebatesQuery.data?.page_size ?? pageSize}
onPageChange={nextPage => setRebatePage(nextPage)}
emptyDescription="还没有返利流水"
/>
</>
)}
<Statistic style={{ display: 'none' }} value={0} />
</div>
)
}
+82
View File
@@ -123,6 +123,10 @@ import type {
AdminNotificationPage,
AdminPushChannel,
AdminPushRule,
AdminInvitationConfig,
AdminInvitationRelationPage,
AdminInviteRebatePage,
AdminInviteRebateSummary,
ManualDisbursementPayload,
OperatingExpensePage,
OperatingExpensePayload,
@@ -1796,3 +1800,81 @@ export async function updatePushRule(
export async function fetchAdminInvitationConfig(siteId?: number) {
const { data } = await adminClient.get<ApiResponse<AdminInvitationConfig>>(
'/admin/invitations/configs',
{ params: siteId ? { site_id: siteId } : undefined }
)
return data.data
}
export async function updateAdminInvitationConfig(payload: {
first_month_rate: number
second_month_rate: number
subsequent_rate: number
enabled: boolean
site_id?: number
}) {
const { data } = await adminClient.put<ApiResponse<AdminInvitationConfig>>(
'/admin/invitations/configs',
payload
)
return data.data
}
export async function fetchAdminInvitationRelations(query: {
phone?: string
invite_code?: string
status?: string
page?: number
page_size?: number
}) {
const { data } = await adminClient.get<ApiResponse<Partial<AdminInvitationRelationPage>>>(
'/admin/invitations',
{ params: compactParams(query) }
)
return {
items: data.data.items ?? [],
total: data.data.total ?? 0,
page: data.data.page ?? query.page ?? 1,
page_size: data.data.page_size ?? query.page_size ?? 20,
}
}
export async function fetchAdminInviteRebates(query: {
order_no?: string
inviter_phone?: string
invitee_phone?: string
trade_role?: string
start_date?: string
end_date?: string
page?: number
page_size?: number
}) {
const { data } = await adminClient.get<ApiResponse<Partial<AdminInviteRebatePage>>>(
'/admin/invitations/rebates',
{ params: compactParams(query) }
)
return {
items: data.data.items ?? [],
total: data.data.total ?? 0,
page: data.data.page ?? query.page ?? 1,
page_size: data.data.page_size ?? query.page_size ?? 20,
}
}
export async function fetchAdminInviteRebateSummary(query: {
order_no?: string
inviter_phone?: string
invitee_phone?: string
trade_role?: string
start_date?: string
end_date?: string
}) {
const { data } = await adminClient.get<ApiResponse<AdminInviteRebateSummary>>(
'/admin/invitations/rebates/summary',
{ params: compactParams(query) }
)
return data.data
}
+71
View File
@@ -913,6 +913,77 @@ export interface AdminPushRule {
updated_at: string
}
export interface AdminInvitationConfig {
distribution_site_id: number
first_month_rate: number
second_month_rate: number
subsequent_rate: number
enabled: boolean
updated_at: string
}
export interface AdminInvitationRelation {
id: number
inviter_id: number
inviter_phone: string
inviter_nickname: string
invitee_id: number
invitee_phone: string
invitee_nickname: string
distribution_site_id?: number | null
invite_code: string
status: string
verified_at?: string | null
created_at: string
}
export interface AdminInvitationRelationPage {
items: AdminInvitationRelation[]
total: number
page: number
page_size: number
}
export interface AdminInviteRebate {
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 AdminInviteRebatePage {
items: AdminInviteRebate[]
total: number
page: number
page_size: number
}
export interface AdminInviteRebateSummary {
total_rebate_cent: number
ledger_count: number
inviter_count: number
invitee_count: number
avg_rebate_cent: number
order_rebate_cent: number
order_count: number
pickup_rebate_cent: number
pickup_count: number
buyer_rebate_cent: number
buyer_count: number
seller_rebate_cent: number
seller_count: number
first_month_cent: number
second_month_cent: number
subsequent_cent: number
}
export interface AdminAnnouncementQuery {
category?: string
status?: string
+5
View File
@@ -139,6 +139,7 @@ const allNavGroups: NavGroup[] = [
{ key: '/tracking-links', label: '推广追踪', permission: 'ad_tracking:manage' },
{ key: '/notifications', label: '通知中心', permission: 'notification:view' },
{ key: '/push', label: '推送通知', permission: 'system_config:view' },
{ key: '/invitations', label: '邀请管理', permission: 'distribution:site_view' },
],
},
{
@@ -212,6 +213,10 @@ const sectionMeta: Record<string, { title: string; description: string }> = {
},
'/notifications': { title: '通知中心', description: '查看后台系统通知并批量标记已读。' },
'/push': { title: '推送通知', description: '维护推送渠道与通知规则。' },
'/invitations': {
title: '邀请管理',
description: '维护邀请返利比例,查看邀请关系与返利流水。',
},
'/chat-support-groups': {
title: '客服群组',
description: '维护客服群与坐席分配,支撑会话分流。',