Files
hfb_jysh/distribution-admin/src/pages/Users.tsx
T

419 lines
12 KiB
TypeScript

import { useEffect, useState, useCallback } from 'react'
import {
Card,
Table,
Typography,
Space,
Input,
Select,
Tag,
Button,
Avatar,
Row,
Col,
Statistic,
message,
Tooltip,
} from 'antd'
import {
UserOutlined,
SearchOutlined,
ReloadOutlined,
CopyOutlined,
CheckOutlined,
ShoppingOutlined,
TeamOutlined,
CheckCircleOutlined,
ClockCircleOutlined,
} from '@ant-design/icons'
import { useNavigate } from 'react-router-dom'
import {
listDistributionUsers,
listSites,
formatMoney,
type DistributionUser,
type Site,
} from '../api'
import { useMember } from '../MemberContext'
function formatDateTime(str?: string): string {
if (!str) return '-'
try {
const d = new Date(str)
if (isNaN(d.getTime())) return str
const pad = (n: number) => (n < 10 ? `0${n}` : `${n}`)
return `${d.getFullYear()}-${pad(d.getMonth() + 1)}-${pad(d.getDate())} ${pad(d.getHours())}:${pad(d.getMinutes())}:${pad(d.getSeconds())}`
} catch {
return str
}
}
export default function Users() {
const member = useMember()
const navigate = useNavigate()
const [sites, setSites] = useState<Site[]>([])
const [siteID, setSiteID] = useState<number | undefined>(member?.site_id)
const [users, setUsers] = useState<DistributionUser[]>([])
const [total, setTotal] = useState(0)
const [totalUsers, setTotalUsers] = useState(0)
const [orderUsers, setOrderUsers] = useState(0)
const [loading, setLoading] = useState(false)
// 筛选条件
const [keyword, setKeyword] = useState('')
const [source, setSource] = useState<'all' | 'register' | 'order'>('all')
const [page, setPage] = useState(1)
const [pageSize, setPageSize] = useState(20)
// 复制状态记录
const [copiedPhone, setCopiedPhone] = useState<string | null>(null)
const copyToClipboard = (text: string) => {
navigator.clipboard?.writeText(text)
setCopiedPhone(text)
message.success(`已复制手机号:${text}`)
setTimeout(() => {
setCopiedPhone(null)
}, 2000)
}
const loadData = useCallback(
async (curSiteID?: number, curPage = page, curSize = pageSize) => {
setLoading(true)
try {
const res = await listDistributionUsers({
siteID: curSiteID,
keyword: keyword.trim() || undefined,
source,
page: curPage,
pageSize: curSize,
})
setUsers(res.items || [])
setTotal(res.total || 0)
setTotalUsers(res.total_users || 0)
setOrderUsers(res.order_users || 0)
} catch {
message.error('加载用户列表失败')
} finally {
setLoading(false)
}
},
[keyword, source, page, pageSize]
)
useEffect(() => {
async function init() {
if (member?.is_superadmin) {
try {
const rows = await listSites()
setSites(rows)
if (!siteID && rows.length > 0) {
setSiteID(rows[0].id)
void loadData(rows[0].id, 1, pageSize)
return
}
} catch {
// ignore
}
}
void loadData(siteID, 1, pageSize)
}
void init()
}, [member]) // eslint-disable-line react-hooks/exhaustive-deps
const handleSearch = () => {
setPage(1)
void loadData(siteID, 1, pageSize)
}
const handleReset = () => {
setKeyword('')
setSource('all')
setPage(1)
setTimeout(() => {
void loadData(siteID, 1, pageSize)
}, 0)
}
const columns = [
{
title: '用户 ID',
dataIndex: 'id',
key: 'id',
width: 90,
render: (id: number) => (
<Typography.Text code style={{ fontSize: 12 }}>
#{id}
</Typography.Text>
),
},
{
title: '用户手机号',
dataIndex: 'phone',
key: 'phone',
width: 170,
render: (phone: string) => (
<Space size="small">
<Typography.Text strong style={{ fontFamily: 'monospace', fontSize: 13, color: '#1e4d42' }}>
{phone}
</Typography.Text>
<Tooltip title={copiedPhone === phone ? '已复制' : '点击复制完整手机号'}>
<Button
type="text"
size="small"
icon={copiedPhone === phone ? <CheckOutlined style={{ color: '#52c41a' }} /> : <CopyOutlined />}
onClick={() => copyToClipboard(phone)}
/>
</Tooltip>
</Space>
),
},
{
title: '昵称 / 头像',
key: 'user_info',
width: 160,
render: (_: unknown, row: DistributionUser) => (
<Space size="small">
<Avatar
size="small"
src={row.avatar_url || undefined}
icon={!row.avatar_url ? <UserOutlined /> : undefined}
style={{ backgroundColor: '#1e4d42' }}
/>
<Typography.Text style={{ fontSize: 13 }}>{row.nickname || '-'}</Typography.Text>
</Space>
),
},
{
title: '归属来源',
dataIndex: 'source',
key: 'source',
width: 130,
render: (src: string) => {
if (src === 'both') {
return <Tag color="blue">注册且下单</Tag>
}
if (src === 'order') {
return <Tag color="orange">本站下单客户</Tag>
}
return <Tag color="green">本站注册用户</Tag>
},
},
{
title: '本站下单数',
dataIndex: 'order_count',
key: 'order_count',
width: 120,
render: (count: number, row: DistributionUser) => {
if (count > 0) {
return (
<Tooltip title="点击查看该用户在当前站点的全部订单">
<Button
type="link"
size="small"
style={{ padding: 0, fontWeight: 'bold' }}
onClick={() => navigate(`/orders?q=${encodeURIComponent(row.phone)}`)}
>
{count} 单 ↗
</Button>
</Tooltip>
)
}
return <Typography.Text type="secondary">0 单</Typography.Text>
},
},
{
title: '本站累计消费',
dataIndex: 'total_spent_cent',
key: 'total_spent_cent',
width: 130,
render: (cent: number) => {
if (cent > 0) {
return (
<Typography.Text strong style={{ color: '#fa8c16' }}>
{formatMoney(cent)}
</Typography.Text>
)
}
return <Typography.Text type="secondary">¥0.00</Typography.Text>
},
},
{
title: '实名状态',
dataIndex: 'realname_status',
key: 'realname_status',
width: 110,
render: (st: string) => {
if (st === 'verified') {
return (
<Tag icon={<CheckCircleOutlined />} color="success">
已实名
</Tag>
)
}
return (
<Tag icon={<ClockCircleOutlined />} color="default">
未实名
</Tag>
)
},
},
{
title: '注册时间',
dataIndex: 'created_at',
key: 'created_at',
width: 170,
render: (t: string) => (
<Typography.Text style={{ fontSize: 12, color: '#666' }}>{formatDateTime(t)}</Typography.Text>
),
},
{
title: '最后登录',
dataIndex: 'last_login_at',
key: 'last_login_at',
width: 170,
render: (t?: string) => (
<Typography.Text style={{ fontSize: 12, color: '#888' }}>{formatDateTime(t)}</Typography.Text>
),
},
]
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)
setPage(1)
void loadData(val, 1, pageSize)
}}
/>
)}
<Button icon={<ReloadOutlined />} onClick={() => void loadData(siteID, page, pageSize)}>
刷新
</Button>
</Space>
</div>
{/* 顶部统计卡片 */}
<Row gutter={[16, 16]}>
<Col xs={24} sm={8}>
<Card bordered={false} style={{ background: '#f6ffed', border: '1px solid #b7eb8f' }}>
<Statistic
title="本站注册用户"
value={totalUsers}
prefix={<TeamOutlined style={{ color: '#52c41a' }} />}
suffix="人"
valueStyle={{ color: '#52c41a', fontWeight: 'bold' }}
/>
<div style={{ marginTop: 6, fontSize: 12, color: '#52c41a' }}>
通过本分销站点/专属域名注册绑定的私域用户
</div>
</Card>
</Col>
<Col xs={24} sm={8}>
<Card bordered={false} style={{ background: '#fff7e6', border: '1px solid #ffd591' }}>
<Statistic
title="本站成交客户"
value={orderUsers}
prefix={<ShoppingOutlined style={{ color: '#fa8c16' }} />}
suffix="人"
valueStyle={{ color: '#fa8c16', fontWeight: 'bold' }}
/>
<div style={{ marginTop: 6, fontSize: 12, color: '#fa8c16' }}>
在本分销站点完成过账号租借交易的有效客户
</div>
</Card>
</Col>
<Col xs={24} sm={8}>
<Card bordered={false} style={{ background: '#e6f4ff', border: '1px solid #91caff' }}>
<Statistic
title="关联用户总规模"
value={total}
prefix={<UserOutlined style={{ color: '#1e4d42' }} />}
suffix="人"
valueStyle={{ color: '#1e4d42', fontWeight: 'bold' }}
/>
<div style={{ marginTop: 6, fontSize: 12, color: '#1e4d42' }}>
本站注册或产生过消费行为的全部用户总计
</div>
</Card>
</Col>
</Row>
{/* 筛选与检索栏 */}
<Card bordered={false}>
<Space wrap size="middle" style={{ marginBottom: 16 }}>
<Input
placeholder="搜索用户手机号 / 昵称"
value={keyword}
onChange={(e) => setKeyword(e.target.value)}
onPressEnter={handleSearch}
prefix={<SearchOutlined />}
style={{ width: 240 }}
allowClear
/>
<Select
value={source}
onChange={(val) => {
setSource(val)
setPage(1)
}}
style={{ width: 150 }}
options={[
{ label: '全部来源客户', value: 'all' },
{ label: '仅本站注册用户', value: 'register' },
{ label: '仅本站下单客户', value: 'order' },
]}
/>
<Button type="primary" icon={<SearchOutlined />} onClick={handleSearch}>
查询
</Button>
<Button onClick={handleReset}>重置</Button>
</Space>
<Table
rowKey="id"
columns={columns}
dataSource={users}
loading={loading}
pagination={{
current: page,
pageSize,
total,
showSizeChanger: true,
showTotal: (t) => `共 ${t} 个用户`,
onChange: (p, ps) => {
setPage(p)
setPageSize(ps)
void loadData(siteID, p, ps)
},
}}
scroll={{ x: 1100 }}
/>
</Card>
</Space>
)
}