- 数据模型与迁移: 新增 000082 迁移在 users 表追加 distribution_site_id 并建索引,用户首次在分销站点注册/短信登录时自动绑定站点归属 - 分销管理接口: 新增 GET /admin/distribution/users 接口,聚合本站注册用户与下单客户,返回完整11位手机号、累计消费金额、订单数及来源标签 - 统计与大盘扩展: GET /admin/distribution/sites/:id/analytics 补全今日注册、累计用户、成交客户指标,主站分销商列表追加用户体量展示 - 分销后台前端: 新增「客户与用户」页面 (Users.tsx) 支持一键复制手机号与订单穿透,大盘 (Dashboard.tsx) 增加用户规模核心卡片 - 自动化测试与门禁: 新增 TestListDistributionUsersAndAnalytics 单元测试,通过全量 Go 后端集成测试及前后端构建检查
512 lines
18 KiB
TypeScript
512 lines
18 KiB
TypeScript
import { useEffect, useState, useCallback } from 'react'
|
|
import {
|
|
Card,
|
|
Table,
|
|
Typography,
|
|
Statistic,
|
|
Space,
|
|
Row,
|
|
Col,
|
|
Select,
|
|
Tag,
|
|
Button,
|
|
message,
|
|
} from 'antd'
|
|
import {
|
|
ShoppingCartOutlined,
|
|
DollarCircleOutlined,
|
|
WalletOutlined,
|
|
AppstoreOutlined,
|
|
LineChartOutlined,
|
|
ArrowRightOutlined,
|
|
SkinOutlined,
|
|
TeamOutlined,
|
|
ShoppingOutlined,
|
|
} from '@ant-design/icons'
|
|
import { Link } from 'react-router-dom'
|
|
import {
|
|
listOrders,
|
|
listSites,
|
|
fetchDashboardAnalytics,
|
|
formatMoney,
|
|
getMe,
|
|
type Site,
|
|
type DistributionAnalytics,
|
|
type DistributionOrder,
|
|
} from '../api'
|
|
import { useMember } from '../MemberContext'
|
|
|
|
const ORDER_STATUS_MAP: Record<string, { label: string; color: string }> = {
|
|
pending_payment: { label: '待支付', color: 'orange' },
|
|
pending_handoff: { label: '待上号交付', color: 'processing' },
|
|
renting: { label: '在租中', color: 'cyan' },
|
|
overdue: { label: '已逾期', color: 'volcano' },
|
|
pending_checkout_confirm: { label: '待结单确认', color: 'blue' },
|
|
pending_checkout_accept: { label: '待号主验收', color: 'blue' },
|
|
checkout_disputing: { label: '结单争议中', color: 'magenta' },
|
|
disputing: { label: '维权中', color: 'magenta' },
|
|
abnormal: { label: '异常', color: 'error' },
|
|
completed: { label: '已完成', color: 'success' },
|
|
refunded: { label: '已退款', color: 'purple' },
|
|
cancelled: { label: '已取消', color: 'default' },
|
|
closed: { label: '已关闭', color: 'default' },
|
|
}
|
|
|
|
const SETTLEMENT_STATUS_MAP: Record<string, { label: string; color: string }> = {
|
|
settled: { label: '已结算', color: 'success' },
|
|
pending: { label: '待结算', color: 'warning' },
|
|
unsettled: { label: '未结算', color: 'default' },
|
|
settling: { label: '结算中', color: 'processing' },
|
|
disputed: { label: '争议冻结', color: 'magenta' },
|
|
failed: { label: '结算异常', color: 'error' },
|
|
arbitrated: { label: '已仲裁结算', color: 'purple' },
|
|
closed: { label: '已关闭', color: 'default' },
|
|
}
|
|
|
|
function formatGameName(gameName?: string): string {
|
|
if (!gameName) return ''
|
|
const lower = gameName.toLowerCase().trim()
|
|
if (
|
|
lower === 'delta_force' ||
|
|
lower === 'deltaforce' ||
|
|
lower === 'delta' ||
|
|
lower.includes('delta_force') ||
|
|
lower.includes('delta') ||
|
|
gameName.includes('三角洲')
|
|
) {
|
|
return '三角洲'
|
|
}
|
|
if (lower.includes('honor') || gameName.includes('王者')) {
|
|
return '王者荣耀'
|
|
}
|
|
if (lower.includes('genshin') || gameName.includes('原神')) {
|
|
return '原神'
|
|
}
|
|
if (lower.includes('cs') || lower.includes('csgo')) {
|
|
return 'CS2'
|
|
}
|
|
return gameName
|
|
}
|
|
|
|
function formatLoginPlatform(platform?: string): string {
|
|
if (!platform) return ''
|
|
const lower = platform.toLowerCase().trim()
|
|
if (lower === 'qq') return 'QQ'
|
|
if (lower === 'wechat' || lower === 'wx') return '微信'
|
|
if (lower === 'steam') return 'Steam'
|
|
if (lower === 'epic') return 'Epic'
|
|
if (lower === 'wegame') return 'WeGame'
|
|
if (lower === 'mobile') return '手游端'
|
|
if (lower === 'pc') return 'PC端'
|
|
return platform
|
|
}
|
|
|
|
export default function Dashboard() {
|
|
const member = useMember()
|
|
const [sites, setSites] = useState<Site[]>([])
|
|
const [siteID, setSiteID] = useState<number | undefined>(member?.site_id)
|
|
const [analytics, setAnalytics] = useState<DistributionAnalytics | null>(null)
|
|
const [recentOrders, setRecentOrders] = useState<DistributionOrder[]>([])
|
|
const [loading, setLoading] = useState(false)
|
|
|
|
const loadData = useCallback(async (sid: number) => {
|
|
setLoading(true)
|
|
try {
|
|
const [anaRes, ordRes] = await Promise.all([
|
|
fetchDashboardAnalytics(sid),
|
|
listOrders({ siteID: sid, pageSize: 8 }),
|
|
])
|
|
setAnalytics(anaRes)
|
|
setRecentOrders(ordRes.items)
|
|
} catch {
|
|
message.error('加载大盘数据失败')
|
|
} finally {
|
|
setLoading(false)
|
|
}
|
|
}, [])
|
|
|
|
useEffect(() => {
|
|
async function init() {
|
|
let currentMember = member
|
|
if (!currentMember) {
|
|
try {
|
|
currentMember = await getMe()
|
|
} catch {
|
|
// ignore
|
|
}
|
|
}
|
|
const activeSiteID = currentMember?.site_id
|
|
if (activeSiteID) {
|
|
setSiteID(activeSiteID)
|
|
void loadData(activeSiteID)
|
|
}
|
|
|
|
if (currentMember?.is_superadmin) {
|
|
try {
|
|
const rows = await listSites()
|
|
setSites(rows)
|
|
if (!activeSiteID && rows.length > 0) {
|
|
setSiteID(rows[0].id)
|
|
void loadData(rows[0].id)
|
|
}
|
|
} catch {
|
|
// ignore
|
|
}
|
|
}
|
|
}
|
|
void init()
|
|
}, [member, loadData])
|
|
|
|
const selectedSiteName =
|
|
member?.site_name || sites.find((s) => s.id === siteID)?.site_name || '当前分销商'
|
|
|
|
return (
|
|
<Space direction="vertical" size="large" className="page" style={{ width: '100%' }}>
|
|
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center' }}>
|
|
<div>
|
|
<Typography.Title level={3} style={{ marginBottom: 4 }}>
|
|
运营数据大盘
|
|
</Typography.Title>
|
|
<Typography.Text type="secondary">
|
|
当前站点:{selectedSiteName} · 实时聚合收号、货架、订单与结算收益
|
|
</Typography.Text>
|
|
</div>
|
|
<Space>
|
|
{member?.is_superadmin && sites.length > 1 && (
|
|
<Select
|
|
placeholder="切换分销网站"
|
|
style={{ width: 220 }}
|
|
value={siteID}
|
|
options={sites.map((site) => ({ label: `${site.site_name} (${site.site_code})`, value: site.id }))}
|
|
onChange={(val) => {
|
|
setSiteID(val)
|
|
void loadData(val)
|
|
}}
|
|
/>
|
|
)}
|
|
<Link to="/wallet">
|
|
<Button type="primary" icon={<WalletOutlined />}>
|
|
去提现
|
|
</Button>
|
|
</Link>
|
|
<Link to="/settings">
|
|
<Button icon={<SkinOutlined />}>
|
|
站点装修
|
|
</Button>
|
|
</Link>
|
|
</Space>
|
|
</div>
|
|
|
|
{/* 核心业务指标看板 */}
|
|
<Row gutter={[16, 16]}>
|
|
<Col xs={24} sm={12} md={6}>
|
|
<Card bordered={false} style={{ background: '#f6ffed', border: '1px solid #b7eb8f' }} loading={loading}>
|
|
<Statistic
|
|
title="今日注册 / 累计用户"
|
|
value={analytics?.today_user_count ?? 0}
|
|
suffix={`/ ${analytics?.total_user_count ?? 0} 人`}
|
|
prefix={<TeamOutlined style={{ color: '#52c41a' }} />}
|
|
valueStyle={{ color: '#52c41a', fontWeight: 'bold' }}
|
|
/>
|
|
<div style={{ marginTop: 8 }}>
|
|
<Link to="/users" style={{ fontSize: 12 }}>查看客户列表 <ArrowRightOutlined /></Link>
|
|
</div>
|
|
</Card>
|
|
</Col>
|
|
<Col xs={24} sm={12} md={6}>
|
|
<Card bordered={false} style={{ background: '#fff7e6', border: '1px solid #ffd591' }} loading={loading}>
|
|
<Statistic
|
|
title="本站成交客户"
|
|
value={analytics?.total_order_user_count ?? 0}
|
|
suffix="人"
|
|
prefix={<ShoppingOutlined style={{ color: '#fa8c16' }} />}
|
|
valueStyle={{ color: '#fa8c16', fontWeight: 'bold' }}
|
|
/>
|
|
<div style={{ marginTop: 8 }}>
|
|
<Link to="/users" style={{ fontSize: 12 }}>成交客户分析 <ArrowRightOutlined /></Link>
|
|
</div>
|
|
</Card>
|
|
</Col>
|
|
<Col xs={24} sm={12} md={6}>
|
|
<Card bordered={false} style={{ background: '#fafafa' }} loading={loading}>
|
|
<Statistic
|
|
title="今日订单量"
|
|
value={analytics?.today_order_count ?? 0}
|
|
prefix={<ShoppingCartOutlined style={{ color: '#722ed1' }} />}
|
|
suffix="单"
|
|
valueStyle={{ color: '#722ed1', fontWeight: 'bold' }}
|
|
/>
|
|
<div style={{ marginTop: 8 }}>
|
|
<Link to="/sold" style={{ fontSize: 12 }}>查看交易订单 <ArrowRightOutlined /></Link>
|
|
</div>
|
|
</Card>
|
|
</Col>
|
|
<Col xs={24} sm={12} md={6}>
|
|
<Card bordered={false} style={{ background: '#fafafa' }} loading={loading}>
|
|
<Statistic
|
|
title="今日成交 GMV"
|
|
value={((analytics?.today_gmv_cent ?? 0) / 100).toFixed(2)}
|
|
suffix="元"
|
|
valueStyle={{ color: '#fa8c16', fontWeight: 'bold' }}
|
|
/>
|
|
<div style={{ marginTop: 8, fontSize: 12, color: '#888' }}>
|
|
今日收益: {formatMoney(analytics?.today_profit_cent)}
|
|
</div>
|
|
</Card>
|
|
</Col>
|
|
<Col xs={24} sm={12} md={6}>
|
|
<Card bordered={false} style={{ background: '#fafafa' }} loading={loading}>
|
|
<Statistic
|
|
title="今日收号 / 累计收号"
|
|
value={analytics?.today_received_count ?? 0}
|
|
suffix={`/ ${analytics?.total_received_count ?? 0} 个`}
|
|
valueStyle={{ color: '#1677ff', fontWeight: 'bold' }}
|
|
/>
|
|
<div style={{ marginTop: 8 }}>
|
|
<Link to="/receive" style={{ fontSize: 12 }}>查看收号资产 <ArrowRightOutlined /></Link>
|
|
</div>
|
|
</Card>
|
|
</Col>
|
|
<Col xs={24} sm={12} md={6}>
|
|
<Card bordered={false} style={{ background: '#fafafa' }} loading={loading}>
|
|
<Statistic
|
|
title="在售可租货源"
|
|
value={analytics?.active_listing_count ?? 0}
|
|
prefix={<AppstoreOutlined style={{ color: '#52c41a' }} />}
|
|
suffix="个"
|
|
valueStyle={{ color: '#52c41a', fontWeight: 'bold' }}
|
|
/>
|
|
<div style={{ marginTop: 8 }}>
|
|
<Link to="/accounts" style={{ fontSize: 12 }}>管理商品货架 <ArrowRightOutlined /></Link>
|
|
</div>
|
|
</Card>
|
|
</Col>
|
|
<Col xs={24} sm={12} md={6}>
|
|
<Card bordered={false} style={{ background: '#fafafa' }} loading={loading}>
|
|
<Statistic
|
|
title="本月预估分润收益"
|
|
value={((analytics?.month_profit_cent ?? 0) / 100).toFixed(2)}
|
|
prefix={<DollarCircleOutlined style={{ color: '#eb2f96' }} />}
|
|
suffix="元"
|
|
valueStyle={{ color: '#eb2f96', fontWeight: 'bold' }}
|
|
/>
|
|
<div style={{ marginTop: 8 }}>
|
|
<Link to="/settlements" style={{ fontSize: 12 }}>结算对账 <ArrowRightOutlined /></Link>
|
|
</div>
|
|
</Card>
|
|
</Col>
|
|
<Col xs={24} sm={12} md={6}>
|
|
<Card bordered={false} style={{ background: '#e6f4ff', border: '1px solid #91caff' }} loading={loading}>
|
|
<Statistic
|
|
title="钱包可提现余额"
|
|
value={((analytics?.available_balance_cent ?? 0) / 100).toFixed(2)}
|
|
prefix={<WalletOutlined style={{ color: '#1677ff' }} />}
|
|
suffix="元"
|
|
valueStyle={{ color: '#1677ff', fontWeight: 'bold' }}
|
|
/>
|
|
<div style={{ marginTop: 8 }}>
|
|
<Link to="/wallet" style={{ fontSize: 12 }}>立即提现 <ArrowRightOutlined /></Link>
|
|
</div>
|
|
</Card>
|
|
</Col>
|
|
</Row>
|
|
|
|
{/* 近7天运营走势 */}
|
|
<Card
|
|
title={
|
|
<Space>
|
|
<LineChartOutlined />
|
|
<span>近 7 天交易与收号走势</span>
|
|
</Space>
|
|
}
|
|
loading={loading}
|
|
>
|
|
<Table
|
|
rowKey="date"
|
|
dataSource={analytics?.trend_days ?? []}
|
|
pagination={false}
|
|
scroll={{ x: 720 }}
|
|
columns={[
|
|
{ title: '日期', dataIndex: 'date', width: 130, render: (v: string) => <strong>{v}</strong> },
|
|
{
|
|
title: '收号数量',
|
|
dataIndex: 'received_count',
|
|
width: 120,
|
|
render: (v: number) => <Tag color={v > 0 ? 'blue' : 'default'}>{v} 个</Tag>,
|
|
},
|
|
{
|
|
title: '成交订单',
|
|
dataIndex: 'order_count',
|
|
width: 120,
|
|
render: (v: number) => <Tag color={v > 0 ? 'purple' : 'default'}>{v} 单</Tag>,
|
|
},
|
|
{
|
|
title: '成交 GMV',
|
|
dataIndex: 'gmv_cent',
|
|
width: 160,
|
|
render: (v: number) => <span style={{ color: '#cf1322', fontWeight: 'bold' }}>{formatMoney(v)}</span>,
|
|
},
|
|
{
|
|
title: '预计分成收益',
|
|
dataIndex: 'profit_cent',
|
|
width: 160,
|
|
render: (v: number) => <span style={{ color: '#52c41a', fontWeight: 'bold' }}>{formatMoney(v)}</span>,
|
|
},
|
|
]}
|
|
/>
|
|
</Card>
|
|
|
|
{/* 最近交易订单 */}
|
|
<Card
|
|
title="最近分销订单"
|
|
extra={<Link to="/sold">查看全部订单 <ArrowRightOutlined /></Link>}
|
|
loading={loading}
|
|
>
|
|
<Table
|
|
rowKey="order_id"
|
|
dataSource={recentOrders}
|
|
pagination={false}
|
|
scroll={{ x: 1100 }}
|
|
columns={[
|
|
{
|
|
title: '订单与商品编号',
|
|
width: 280,
|
|
render: (_: unknown, row: DistributionOrder) => (
|
|
<div style={{ whiteSpace: 'nowrap', display: 'flex', flexDirection: 'column', gap: 4 }}>
|
|
<div style={{ display: 'inline-flex', alignItems: 'center', whiteSpace: 'nowrap' }}>
|
|
<Tag
|
|
color="default"
|
|
style={{
|
|
margin: 0,
|
|
marginInlineEnd: 6,
|
|
fontSize: 11,
|
|
padding: '0 4px',
|
|
lineHeight: '18px',
|
|
flexShrink: 0,
|
|
}}
|
|
>
|
|
订单
|
|
</Tag>
|
|
<Typography.Text
|
|
copyable={{ text: row.order_no, tooltips: ['复制订单号', '已复制'] }}
|
|
strong
|
|
className="mono-code"
|
|
style={{
|
|
fontSize: 13,
|
|
whiteSpace: 'nowrap',
|
|
wordBreak: 'keep-all',
|
|
display: 'inline-flex',
|
|
alignItems: 'center',
|
|
}}
|
|
>
|
|
{row.order_no}
|
|
</Typography.Text>
|
|
</div>
|
|
{row.listing_no ? (
|
|
<div style={{ display: 'inline-flex', alignItems: 'center', whiteSpace: 'nowrap' }}>
|
|
<Tag
|
|
color="blue"
|
|
style={{
|
|
margin: 0,
|
|
marginInlineEnd: 6,
|
|
fontSize: 11,
|
|
padding: '0 4px',
|
|
lineHeight: '18px',
|
|
flexShrink: 0,
|
|
}}
|
|
>
|
|
商品
|
|
</Tag>
|
|
<Typography.Text
|
|
copyable={{ text: row.listing_no, tooltips: ['复制商品编号', '已复制'] }}
|
|
className="mono-code"
|
|
style={{
|
|
fontSize: 13,
|
|
color: '#1677ff',
|
|
fontWeight: 600,
|
|
whiteSpace: 'nowrap',
|
|
wordBreak: 'keep-all',
|
|
display: 'inline-flex',
|
|
alignItems: 'center',
|
|
}}
|
|
>
|
|
{row.listing_no}
|
|
</Typography.Text>
|
|
</div>
|
|
) : null}
|
|
</div>
|
|
),
|
|
},
|
|
{
|
|
title: '租赁账号与游戏',
|
|
width: 240,
|
|
render: (_: unknown, row: DistributionOrder) => (
|
|
<div>
|
|
<Typography.Text
|
|
strong
|
|
style={{ display: 'block', fontSize: 14, lineHeight: '20px' }}
|
|
ellipsis={{ tooltip: row.title }}
|
|
>
|
|
{row.title || (row.listing_no ? `商品 #${row.listing_no}` : `订单 #${row.order_id}`)}
|
|
</Typography.Text>
|
|
<Space size={4} style={{ marginTop: 4 }} wrap>
|
|
{row.game_name && <Tag color="blue">{formatGameName(row.game_name)}</Tag>}
|
|
{row.server_region && <Tag>{row.server_region}</Tag>}
|
|
{row.login_platform && <Tag color="cyan">{formatLoginPlatform(row.login_platform)}</Tag>}
|
|
</Space>
|
|
</div>
|
|
),
|
|
},
|
|
{
|
|
title: '实付金额',
|
|
width: 120,
|
|
render: (_: unknown, row: DistributionOrder) => (
|
|
<Typography.Text strong style={{ color: '#1677ff', fontSize: 15, whiteSpace: 'nowrap' }}>
|
|
{formatMoney(row.net_paid_amount_cent || row.gross_paid_amount_cent || 0)}
|
|
</Typography.Text>
|
|
),
|
|
},
|
|
{
|
|
title: '本站净收益',
|
|
width: 130,
|
|
render: (_: unknown, row: DistributionOrder) => (
|
|
<Typography.Text strong style={{ color: '#389e0d', fontSize: 15, whiteSpace: 'nowrap' }}>
|
|
+{formatMoney(row.distributor_earning_cent || 0)}
|
|
</Typography.Text>
|
|
),
|
|
},
|
|
{
|
|
title: '订单状态',
|
|
dataIndex: 'status',
|
|
width: 110,
|
|
render: (v: string) => {
|
|
const conf = ORDER_STATUS_MAP[v] || { label: v, color: 'default' }
|
|
return <Tag color={conf.color}>{conf.label}</Tag>
|
|
},
|
|
},
|
|
{
|
|
title: '结算状态',
|
|
dataIndex: 'settlement_status',
|
|
width: 110,
|
|
render: (v: string) => {
|
|
const conf = SETTLEMENT_STATUS_MAP[v] || { label: v, color: 'default' }
|
|
return <Tag color={conf.color}>{conf.label}</Tag>
|
|
},
|
|
},
|
|
{
|
|
title: '下单时间',
|
|
dataIndex: 'created_at',
|
|
width: 160,
|
|
render: (v: string) => (
|
|
<span style={{ fontSize: 12, color: '#8c8c8c', whiteSpace: 'nowrap' }}>
|
|
{v ? new Date(v).toLocaleString('zh-CN', { hour12: false }) : '-'}
|
|
</span>
|
|
),
|
|
},
|
|
]}
|
|
/>
|
|
</Card>
|
|
</Space>
|
|
)
|
|
}
|