Files
hfb_sys/distribution-admin/src/pages/Dashboard.tsx
T
yml2213 4ea08e355b feat(distribution): 分销站点增加用户数量统计与用户信息展示模块
- 数据模型与迁移: 新增 000082 迁移在 users 表追加 distribution_site_id 并建索引,用户首次在分销站点注册/短信登录时自动绑定站点归属
- 分销管理接口: 新增 GET /admin/distribution/users 接口,聚合本站注册用户与下单客户,返回完整11位手机号、累计消费金额、订单数及来源标签
- 统计与大盘扩展: GET /admin/distribution/sites/:id/analytics 补全今日注册、累计用户、成交客户指标,主站分销商列表追加用户体量展示
- 分销后台前端: 新增「客户与用户」页面 (Users.tsx) 支持一键复制手机号与订单穿透,大盘 (Dashboard.tsx) 增加用户规模核心卡片
- 自动化测试与门禁: 新增 TestListDistributionUsersAndAnalytics 单元测试,通过全量 Go 后端集成测试及前后端构建检查
2026-10-10 14:58:33 +08:00

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>
)
}