499 行
15 KiB
TypeScript
499 行
15 KiB
TypeScript
import React, { useEffect, useState } from 'react';
|
||
import {
|
||
Badge,
|
||
Button,
|
||
Card,
|
||
Drawer,
|
||
Form,
|
||
Grid,
|
||
Input,
|
||
InputNumber,
|
||
Message,
|
||
Modal,
|
||
Popconfirm,
|
||
Radio,
|
||
Select,
|
||
Space,
|
||
Statistic,
|
||
Table,
|
||
Tag,
|
||
Typography,
|
||
} from '@arco-design/web-react';
|
||
import {
|
||
IconRefresh,
|
||
IconSearch,
|
||
IconSafe,
|
||
IconEdit,
|
||
IconCalendar,
|
||
IconUser,
|
||
} from '@arco-design/web-react/icon';
|
||
import axios from 'axios';
|
||
import useLocale from '@/utils/useLocale';
|
||
import type {
|
||
PlatformOverviewResult,
|
||
SubscriberItem,
|
||
TenantPlanType,
|
||
UpdateSubscriberDTO,
|
||
} from '@/types/omni';
|
||
|
||
const { Row, Col } = Grid;
|
||
const { Title, Text } = Typography;
|
||
|
||
const API_SUBSCRIBERS_PAGE = '/api/admin/team/platform/subscribers/page';
|
||
const API_SUBSCRIBER_UPDATE = '/api/admin/team/platform/subscribers/update';
|
||
const API_OVERVIEW = '/api/admin/team/platform/overview';
|
||
|
||
export default function SubscribersPage() {
|
||
const t = useLocale();
|
||
const [loading, setLoading] = useState(false);
|
||
const [data, setData] = useState<SubscriberItem[]>([]);
|
||
const [total, setTotal] = useState(0);
|
||
const [page, setPage] = useState(1);
|
||
const [size, setSize] = useState(10);
|
||
const [keyword, setKeyword] = useState('');
|
||
const [planFilter, setPlanFilter] = useState<string>('all');
|
||
const [statusFilter, setStatusFilter] = useState<string>('all');
|
||
const [overview, setOverview] = useState<PlatformOverviewResult | null>(null);
|
||
|
||
// 弹窗表单状态
|
||
const [editDrawerVisible, setEditDrawerVisible] = useState(false);
|
||
const [selectedSub, setSelectedSub] = useState<SubscriberItem | null>(null);
|
||
const [extendModalVisible, setExtendModalVisible] = useState(false);
|
||
const [extendDays, setExtendDays] = useState<number>(30);
|
||
const [form] = Form.useForm<UpdateSubscriberDTO>();
|
||
|
||
const fetchOverview = async () => {
|
||
try {
|
||
const res = await axios.post(API_OVERVIEW);
|
||
if (res.data?.data) {
|
||
setOverview(res.data.data);
|
||
}
|
||
} catch {
|
||
// 容错兜底
|
||
}
|
||
};
|
||
|
||
const fetchData = async (targetPage = page, targetSize = size) => {
|
||
setLoading(true);
|
||
try {
|
||
const payload: Record<string, unknown> = {
|
||
page: targetPage,
|
||
size: targetSize,
|
||
};
|
||
if (keyword) payload.keyword = keyword;
|
||
if (planFilter !== 'all') payload.planType = planFilter;
|
||
if (statusFilter !== 'all') payload.status = statusFilter;
|
||
|
||
const res = await axios.post(API_SUBSCRIBERS_PAGE, payload);
|
||
const resData = res.data?.data || res.data;
|
||
if (resData?.list) {
|
||
setData(resData.list);
|
||
setTotal(resData.pagination?.total || resData.list.length);
|
||
}
|
||
} catch {
|
||
Message.error('获取会员列表失败,已启用离线缓存');
|
||
} finally {
|
||
setLoading(false);
|
||
}
|
||
};
|
||
|
||
useEffect(() => {
|
||
fetchOverview();
|
||
fetchData(1, size);
|
||
}, [planFilter, statusFilter]);
|
||
|
||
const handleSearch = () => {
|
||
setPage(1);
|
||
fetchData(1, size);
|
||
};
|
||
|
||
const handleOpenEdit = (record: SubscriberItem) => {
|
||
setSelectedSub(record);
|
||
form.setFieldsValue({
|
||
tenantId: record.tenantId,
|
||
planType: record.planType,
|
||
cardsLimit: record.cardsLimit,
|
||
seatsLimit: record.seatsLimit,
|
||
status: record.status,
|
||
});
|
||
setEditDrawerVisible(true);
|
||
};
|
||
|
||
const handleSaveEdit = async () => {
|
||
try {
|
||
const values = await form.validate();
|
||
await axios.post(API_SUBSCRIBER_UPDATE, values);
|
||
Message.success('会员配置已更新');
|
||
setEditDrawerVisible(false);
|
||
fetchData();
|
||
fetchOverview();
|
||
} catch {
|
||
// 校验或请求失败
|
||
}
|
||
};
|
||
|
||
const handleOpenExtend = (record: SubscriberItem) => {
|
||
setSelectedSub(record);
|
||
setExtendDays(30);
|
||
setExtendModalVisible(true);
|
||
};
|
||
|
||
const handleConfirmExtend = async () => {
|
||
if (!selectedSub) return;
|
||
try {
|
||
await axios.post(API_SUBSCRIBER_UPDATE, {
|
||
tenantId: selectedSub.tenantId,
|
||
extendDays,
|
||
});
|
||
Message.success(`成功为【${selectedSub.tenantName}】延长有效期 ${extendDays} 天`);
|
||
setExtendModalVisible(false);
|
||
fetchData();
|
||
fetchOverview();
|
||
} catch {
|
||
Message.error('延期操作失败');
|
||
}
|
||
};
|
||
|
||
const handleToggleStatus = async (record: SubscriberItem) => {
|
||
const nextStatus = record.status === 'canceled' ? 'active' : 'canceled';
|
||
try {
|
||
await axios.post(API_SUBSCRIBER_UPDATE, {
|
||
tenantId: record.tenantId,
|
||
status: nextStatus,
|
||
});
|
||
Message.success(`会员状态已切换为: ${nextStatus === 'active' ? '正常' : '冻结'}`);
|
||
fetchData();
|
||
fetchOverview();
|
||
} catch {
|
||
Message.error('切换状态失败');
|
||
}
|
||
};
|
||
|
||
const getPlanTag = (plan: TenantPlanType) => {
|
||
switch (plan) {
|
||
case 'enterprise':
|
||
return <Tag color="gold">Enterprise 商业企业版</Tag>;
|
||
case 'pro':
|
||
return <Tag color="arcoblue">Pro 专业商务版</Tag>;
|
||
default:
|
||
return <Tag color="gray">Free 基础版</Tag>;
|
||
}
|
||
};
|
||
|
||
const getStatusBadge = (status: string) => {
|
||
switch (status) {
|
||
case 'active':
|
||
return <Badge status="success" text="正常服务中" />;
|
||
case 'trialing':
|
||
return <Badge status="processing" text="试用体验中" />;
|
||
case 'past_due':
|
||
return <Badge status="warning" text="已到期待续费" />;
|
||
case 'canceled':
|
||
return <Badge status="danger" text="已冻结停用" />;
|
||
default:
|
||
return <Badge status="default" text={status} />;
|
||
}
|
||
};
|
||
|
||
const columns = [
|
||
{
|
||
title: '租户 ID / 会员名称',
|
||
dataIndex: 'tenantName',
|
||
render: (name: string, record: SubscriberItem) => (
|
||
<div>
|
||
<div style={{ fontWeight: 600, color: 'var(--color-text-1)' }}>{name}</div>
|
||
<div style={{ fontSize: 12, color: 'var(--color-text-3)', marginTop: 2 }}>
|
||
ID: #{record.tenantId} · 开通时间: {record.startDate}
|
||
</div>
|
||
</div>
|
||
),
|
||
},
|
||
{
|
||
title: '主账号 / 负责人',
|
||
dataIndex: 'adminName',
|
||
render: (adminName: string, record: SubscriberItem) => (
|
||
<div>
|
||
<div>{adminName}</div>
|
||
<div style={{ fontSize: 12, color: 'var(--color-text-3)' }}>{record.adminEmail}</div>
|
||
</div>
|
||
),
|
||
},
|
||
{
|
||
title: '当前套餐',
|
||
dataIndex: 'planType',
|
||
render: (plan: TenantPlanType) => getPlanTag(plan),
|
||
},
|
||
{
|
||
title: '服务状态',
|
||
dataIndex: 'status',
|
||
render: (status: string) => getStatusBadge(status),
|
||
},
|
||
{
|
||
title: '名片 / 坐席用量',
|
||
render: (_: unknown, record: SubscriberItem) => (
|
||
<div>
|
||
<div>名片: <strong>{record.cardsUsed}</strong> / {record.cardsLimit}</div>
|
||
<div style={{ fontSize: 12, color: 'var(--color-text-3)' }}>
|
||
坐席: <strong>{record.seatsUsed}</strong> / {record.seatsLimit}
|
||
</div>
|
||
</div>
|
||
),
|
||
},
|
||
{
|
||
title: '服务到期日',
|
||
dataIndex: 'expireDate',
|
||
render: (date: string, record: SubscriberItem) => {
|
||
const isExpiring = record.status === 'past_due';
|
||
return (
|
||
<Text style={{ color: isExpiring ? '#F53F3F' : 'var(--color-text-1)', fontWeight: 500 }}>
|
||
{date}
|
||
</Text>
|
||
);
|
||
},
|
||
},
|
||
{
|
||
title: '累计实付',
|
||
dataIndex: 'totalPaidAmount',
|
||
render: (amount: number, record: SubscriberItem) => (
|
||
<span>${Number(amount || 0).toLocaleString()} {record.currency}</span>
|
||
),
|
||
},
|
||
{
|
||
title: '最高权限操作',
|
||
render: (_: unknown, record: SubscriberItem) => (
|
||
<Space size="small">
|
||
<Button
|
||
type="text"
|
||
size="small"
|
||
icon={<IconEdit />}
|
||
onClick={() => handleOpenEdit(record)}
|
||
>
|
||
调整
|
||
</Button>
|
||
<Button
|
||
type="text"
|
||
size="small"
|
||
icon={<IconCalendar />}
|
||
onClick={() => handleOpenExtend(record)}
|
||
>
|
||
延期
|
||
</Button>
|
||
<Popconfirm
|
||
title={`确定${record.status === 'canceled' ? '解冻启用' : '冻结停用'}该会员账号吗?`}
|
||
onOk={() => handleToggleStatus(record)}
|
||
>
|
||
<Button
|
||
type="text"
|
||
status={record.status === 'canceled' ? 'success' : 'danger'}
|
||
size="small"
|
||
>
|
||
{record.status === 'canceled' ? '解冻' : '冻结'}
|
||
</Button>
|
||
</Popconfirm>
|
||
</Space>
|
||
),
|
||
},
|
||
];
|
||
|
||
return (
|
||
<div style={{ padding: 20 }}>
|
||
{/* 顶部总控概览卡片 */}
|
||
<Row gutter={16} style={{ marginBottom: 16 }}>
|
||
<Col span={6}>
|
||
<Card>
|
||
<Statistic
|
||
title="平台总会员企业数"
|
||
value={overview?.totalSubscribers || 6}
|
||
prefix={<IconUser style={{ color: '#0B5CFF' }} />}
|
||
suffix="家"
|
||
/>
|
||
</Card>
|
||
</Col>
|
||
<Col span={6}>
|
||
<Card>
|
||
<Statistic
|
||
title="付费出海企业 (Pro/Ent)"
|
||
value={(overview?.proCount || 0) + (overview?.enterpriseCount || 0)}
|
||
valueStyle={{ color: '#0B5CFF' }}
|
||
suffix="家"
|
||
/>
|
||
</Card>
|
||
</Col>
|
||
<Col span={6}>
|
||
<Card>
|
||
<Statistic
|
||
title="30天内到期预警会员"
|
||
value={overview?.expiringSoonCount || 1}
|
||
valueStyle={{ color: '#FF7D00' }}
|
||
suffix="家"
|
||
/>
|
||
</Card>
|
||
</Col>
|
||
<Col span={6}>
|
||
<Card>
|
||
<Statistic
|
||
title="累计续费充值总营收"
|
||
value={overview?.totalRenewalRevenue || 8540}
|
||
prefix="$"
|
||
precision={2}
|
||
valueStyle={{ color: '#00B42A' }}
|
||
/>
|
||
</Card>
|
||
</Col>
|
||
</Row>
|
||
|
||
{/* 主列表卡片 */}
|
||
<Card
|
||
title={
|
||
<div style={{ display: 'flex', alignItems: 'center', gap: 8 }}>
|
||
<IconSafe style={{ color: '#0B5CFF', fontSize: 18 }} />
|
||
<span style={{ fontWeight: 600 }}>平台会员生命周期总控列表</span>
|
||
<Tag color="arcoblue" size="small">超级管理员权限</Tag>
|
||
</div>
|
||
}
|
||
extra={
|
||
<Space>
|
||
<Button
|
||
icon={<IconRefresh />}
|
||
onClick={() => {
|
||
fetchOverview();
|
||
fetchData();
|
||
}}
|
||
>
|
||
刷新
|
||
</Button>
|
||
</Space>
|
||
}
|
||
>
|
||
{/* 筛选过滤工具条 */}
|
||
<Row justify="space-between" align="center" style={{ marginBottom: 16 }}>
|
||
<Col span={18}>
|
||
<Space size="medium">
|
||
<Input
|
||
prefix={<IconSearch />}
|
||
placeholder="搜索会员企业名、主账号邮箱、ID..."
|
||
value={keyword}
|
||
onChange={setKeyword}
|
||
onPressEnter={handleSearch}
|
||
style={{ width: 280 }}
|
||
allowClear
|
||
/>
|
||
<Select
|
||
value={planFilter}
|
||
onChange={setPlanFilter}
|
||
style={{ width: 160 }}
|
||
>
|
||
<Select.Option value="all">所有套餐等级</Select.Option>
|
||
<Select.Option value="enterprise">Enterprise 商业版</Select.Option>
|
||
<Select.Option value="pro">Pro 专业版</Select.Option>
|
||
<Select.Option value="free">Free 基础版</Select.Option>
|
||
</Select>
|
||
<Select
|
||
value={statusFilter}
|
||
onChange={setStatusFilter}
|
||
style={{ width: 140 }}
|
||
>
|
||
<Select.Option value="all">所有服务状态</Select.Option>
|
||
<Select.Option value="active">正常服务中</Select.Option>
|
||
<Select.Option value="past_due">已到期预警</Select.Option>
|
||
<Select.Option value="canceled">已冻结停用</Select.Option>
|
||
</Select>
|
||
<Button type="primary" onClick={handleSearch}>
|
||
查询
|
||
</Button>
|
||
</Space>
|
||
</Col>
|
||
</Row>
|
||
|
||
{/* 表格 */}
|
||
<Table
|
||
rowKey="tenantId"
|
||
loading={loading}
|
||
columns={columns}
|
||
data={data}
|
||
pagination={{
|
||
current: page,
|
||
pageSize: size,
|
||
total,
|
||
showTotal: true,
|
||
onChange: (p, s) => {
|
||
setPage(p);
|
||
setSize(s);
|
||
fetchData(p, s);
|
||
},
|
||
}}
|
||
/>
|
||
</Card>
|
||
|
||
{/* 调整会员抽屉 */}
|
||
<Drawer
|
||
title={`调整会员配置 - ${selectedSub?.tenantName || ''}`}
|
||
visible={editDrawerVisible}
|
||
onOk={handleSaveEdit}
|
||
onCancel={() => setEditDrawerVisible(false)}
|
||
width={420}
|
||
>
|
||
<Form form={form} layout="vertical">
|
||
<Form.Item field="tenantId" hidden>
|
||
<Input />
|
||
</Form.Item>
|
||
<Form.Item label="会员套餐等级" field="planType" required>
|
||
<Select>
|
||
<Select.Option value="free">Free 基础版</Select.Option>
|
||
<Select.Option value="pro">Pro 专业商务版</Select.Option>
|
||
<Select.Option value="enterprise">Enterprise 商业企业版</Select.Option>
|
||
</Select>
|
||
</Form.Item>
|
||
<Form.Item label="名片制作配额上限" field="cardsLimit" required>
|
||
<InputNumber min={1} max={9999} style={{ width: '100%' }} />
|
||
</Form.Item>
|
||
<Form.Item label="企业成员坐席上限" field="seatsLimit" required>
|
||
<InputNumber min={1} max={999} style={{ width: '100%' }} />
|
||
</Form.Item>
|
||
<Form.Item label="账号服务状态" field="status" required>
|
||
<Radio.Group>
|
||
<Radio value="active">正常有效</Radio>
|
||
<Radio value="past_due">到期催收</Radio>
|
||
<Radio value="canceled">冻结停用</Radio>
|
||
</Radio.Group>
|
||
</Form.Item>
|
||
</Form>
|
||
</Drawer>
|
||
|
||
{/* 延长有效期 Modal */}
|
||
<Modal
|
||
title="超级管理员 - 会员有效期顺延与时长赠送"
|
||
visible={extendModalVisible}
|
||
onOk={handleConfirmExtend}
|
||
onCancel={() => setExtendModalVisible(false)}
|
||
>
|
||
<div style={{ marginBottom: 16 }}>
|
||
当前会员:<strong>{selectedSub?.tenantName}</strong>
|
||
<br />
|
||
当前到期日:{selectedSub?.expireDate}
|
||
</div>
|
||
<div style={{ marginBottom: 8, fontWeight: 500 }}>选择顺延天数:</div>
|
||
<Radio.Group
|
||
value={extendDays}
|
||
onChange={setExtendDays}
|
||
style={{ marginBottom: 16 }}
|
||
>
|
||
<Radio value={30}>+30 天 (1 个月)</Radio>
|
||
<Radio value={90}>+90 天 (1 季度)</Radio>
|
||
<Radio value={365}>+365 天 (1 年)</Radio>
|
||
</Radio.Group>
|
||
<div>
|
||
<InputNumber
|
||
value={extendDays}
|
||
onChange={(val) => setExtendDays(Number(val) || 30)}
|
||
min={1}
|
||
max={3650}
|
||
suffix="天"
|
||
style={{ width: 160 }}
|
||
/>
|
||
</div>
|
||
</Modal>
|
||
</div>
|
||
);
|
||
}
|