Merge branch 'yongpxu-dev' into yongpxu-dev4
This commit is contained in:
@@ -1,10 +0,0 @@
|
||||
import React from "react";
|
||||
import PlaceholderPage from "@/components/PlaceholderPage";
|
||||
|
||||
const TrafficPool: React.FC = () => {
|
||||
return (
|
||||
<PlaceholderPage title="流量池" showAddButton addButtonText="新建流量池" />
|
||||
);
|
||||
};
|
||||
|
||||
export default TrafficPool;
|
||||
@@ -1,8 +0,0 @@
|
||||
import React from "react";
|
||||
import PlaceholderPage from "@/components/PlaceholderPage";
|
||||
|
||||
const TrafficPoolDetail: React.FC = () => {
|
||||
return <PlaceholderPage title="流量池详情" />;
|
||||
};
|
||||
|
||||
export default TrafficPoolDetail;
|
||||
@@ -0,0 +1,30 @@
|
||||
import request from "@/api/request";
|
||||
import type { TrafficPoolListResponse } from "./data";
|
||||
|
||||
// 获取流量池列表
|
||||
export function fetchTrafficPoolList(params: {
|
||||
page?: number;
|
||||
pageSize?: number;
|
||||
keyword?: string;
|
||||
}) {
|
||||
return request("/v1/traffic/pool", params, "GET");
|
||||
}
|
||||
|
||||
// 获取设备列表(如无真实接口可用mock)
|
||||
export async function fetchDeviceOptions(): Promise<DeviceOption[]> {
|
||||
// TODO: 替换为真实接口
|
||||
return [
|
||||
{ id: "device-1", name: "设备1" },
|
||||
{ id: "device-2", name: "设备2" },
|
||||
{ id: "device-3", name: "设备3" },
|
||||
];
|
||||
}
|
||||
|
||||
// 获取分组列表(如无真实接口可用mock)
|
||||
export async function fetchPackageOptions(): Promise<PackageOption[]> {
|
||||
// TODO: 替换为真实接口
|
||||
return [
|
||||
{ id: "pkg-1", name: "高价值客户池" },
|
||||
{ id: "pkg-2", name: "测试流量池" },
|
||||
];
|
||||
}
|
||||
|
||||
@@ -0,0 +1,45 @@
|
||||
// 流量池用户类型
|
||||
export interface TrafficPoolUser {
|
||||
id: number;
|
||||
identifier: string;
|
||||
mobile: string;
|
||||
wechatId: string;
|
||||
fromd: string;
|
||||
status: number;
|
||||
createTime: string;
|
||||
companyId: number;
|
||||
sourceId: string;
|
||||
type: number;
|
||||
nickname: string;
|
||||
avatar: string;
|
||||
gender: number;
|
||||
phone: string;
|
||||
packages: string[];
|
||||
tags: string[];
|
||||
}
|
||||
|
||||
// 列表响应类型
|
||||
export interface TrafficPoolUserListResponse {
|
||||
list: TrafficPoolUser[];
|
||||
total: number;
|
||||
page: number;
|
||||
pageSize: number;
|
||||
}
|
||||
|
||||
// 设备类型
|
||||
export interface DeviceOption {
|
||||
id: string;
|
||||
name: string;
|
||||
}
|
||||
|
||||
// 分组类型
|
||||
export interface PackageOption {
|
||||
id: string;
|
||||
name: string;
|
||||
}
|
||||
|
||||
// 用户价值类型
|
||||
export type ValueLevel = "all" | "high" | "medium" | "low";
|
||||
|
||||
// 状态类型
|
||||
export type UserStatus = "all" | "added" | "pending" | "failed" | "duplicate";
|
||||
|
||||
@@ -0,0 +1,50 @@
|
||||
.listWrap {
|
||||
padding: 12px;
|
||||
}
|
||||
|
||||
.card {
|
||||
background: #fff;
|
||||
border-radius: 8px;
|
||||
box-shadow: 0 2px 8px rgba(0,0,0,0.04);
|
||||
padding: 16px;
|
||||
margin-bottom: 12px;
|
||||
}
|
||||
|
||||
.title {
|
||||
font-size: 16px;
|
||||
font-weight: 600;
|
||||
color: #222;
|
||||
}
|
||||
|
||||
.desc {
|
||||
font-size: 13px;
|
||||
color: #888;
|
||||
margin: 6px 0 4px 0;
|
||||
}
|
||||
|
||||
.count {
|
||||
font-size: 13px;
|
||||
color: #1677ff;
|
||||
}
|
||||
|
||||
.pagination {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 16px;
|
||||
margin: 16px 0;
|
||||
}
|
||||
|
||||
.pagination button {
|
||||
background: #f5f5f5;
|
||||
border: none;
|
||||
border-radius: 4px;
|
||||
padding: 4px 12px;
|
||||
color: #1677ff;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.pagination button:disabled {
|
||||
color: #ccc;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
@@ -1,3 +1,439 @@
|
||||
export default function TrafficPoolList() {
|
||||
return <div>TrafficPoolList</div>;
|
||||
}
|
||||
import React, { useEffect, useState } from "react";
|
||||
import Layout from "@/components/Layout/Layout";
|
||||
import { SearchOutlined, ReloadOutlined } from "@ant-design/icons";
|
||||
import { Input } from "antd";
|
||||
import { fetchTrafficPoolList } from "./api";
|
||||
import type { TrafficPoolUser } from "./data";
|
||||
import styles from "./index.module.scss";
|
||||
import {
|
||||
List,
|
||||
Empty,
|
||||
Avatar,
|
||||
Button,
|
||||
Modal,
|
||||
Selector,
|
||||
Toast,
|
||||
Card,
|
||||
} from "antd-mobile";
|
||||
import { fetchDeviceOptions, fetchPackageOptions } from "./api";
|
||||
import type {
|
||||
DeviceOption,
|
||||
PackageOption,
|
||||
ValueLevel,
|
||||
UserStatus,
|
||||
} from "./data";
|
||||
import { useNavigate } from "react-router-dom";
|
||||
import NavCommon from "@/components/NavCommon";
|
||||
|
||||
const defaultAvatar =
|
||||
"https://cdn.jsdelivr.net/gh/maokaka/static/avatar-default.png";
|
||||
|
||||
const valueLevelOptions = [
|
||||
{ label: "全部", value: "all" },
|
||||
{ label: "高价值", value: "high" },
|
||||
{ label: "中价值", value: "medium" },
|
||||
{ label: "低价值", value: "low" },
|
||||
];
|
||||
const statusOptions = [
|
||||
{ label: "全部", value: "all" },
|
||||
{ label: "已添加", value: "added" },
|
||||
{ label: "待添加", value: "pending" },
|
||||
{ label: "添加失败", value: "failed" },
|
||||
{ label: "重复", value: "duplicate" },
|
||||
];
|
||||
|
||||
const TrafficPoolList: React.FC = () => {
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [list, setList] = useState<TrafficPoolUser[]>([]);
|
||||
const [page, setPage] = useState(1);
|
||||
const [pageSize] = useState(10);
|
||||
const [total, setTotal] = useState(0);
|
||||
const [search, setSearch] = useState("");
|
||||
const [showFilter, setShowFilter] = useState(false);
|
||||
const [deviceOptions, setDeviceOptions] = useState<DeviceOption[]>([]);
|
||||
const [packageOptions, setPackageOptions] = useState<PackageOption[]>([]);
|
||||
const [deviceId, setDeviceId] = useState<string>("all");
|
||||
const [packageId, setPackageId] = useState<string>("all");
|
||||
const [valueLevel, setValueLevel] = useState<ValueLevel>("all");
|
||||
const [userStatus, setUserStatus] = useState<UserStatus>("all");
|
||||
const [selectedIds, setSelectedIds] = useState<number[]>([]);
|
||||
const [batchModal, setBatchModal] = useState(false);
|
||||
const [batchTarget, setBatchTarget] = useState<string>("");
|
||||
const [showStats, setShowStats] = useState(false);
|
||||
const navigate = useNavigate();
|
||||
|
||||
// 统计数据
|
||||
const stats = React.useMemo(() => {
|
||||
const total = list.length;
|
||||
const highValue = list.filter((u) =>
|
||||
u.tags.includes("高价值客户池")
|
||||
).length;
|
||||
const added = list.filter((u) => u.status === 1).length;
|
||||
const pending = list.filter((u) => u.status === 0).length;
|
||||
const failed = list.filter((u) => u.status === -1).length;
|
||||
const addSuccessRate = total ? Math.round((added / total) * 100) : 0;
|
||||
return { total, highValue, added, pending, failed, addSuccessRate };
|
||||
}, [list]);
|
||||
|
||||
const getList = async () => {
|
||||
setLoading(true);
|
||||
try {
|
||||
const res = await fetchTrafficPoolList({
|
||||
page,
|
||||
pageSize,
|
||||
keyword: search,
|
||||
});
|
||||
setList(res.list || []);
|
||||
setTotal(res.total || 0);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
// 获取筛选项
|
||||
useEffect(() => {
|
||||
fetchDeviceOptions().then(setDeviceOptions);
|
||||
fetchPackageOptions().then(setPackageOptions);
|
||||
}, []);
|
||||
|
||||
// 筛选条件变化时刷新列表
|
||||
useEffect(() => {
|
||||
getList();
|
||||
// eslint-disable-next-line
|
||||
}, [page, search, deviceId, packageId, valueLevel, userStatus]);
|
||||
|
||||
// 全选/反选
|
||||
const handleSelectAll = (checked: boolean) => {
|
||||
if (checked) {
|
||||
setSelectedIds(list.map((item) => item.id));
|
||||
} else {
|
||||
setSelectedIds([]);
|
||||
}
|
||||
};
|
||||
// 单选
|
||||
const handleSelect = (id: number, checked: boolean) => {
|
||||
setSelectedIds((prev) =>
|
||||
checked ? [...prev, id] : prev.filter((i) => i !== id)
|
||||
);
|
||||
};
|
||||
|
||||
// 批量加入分组/流量池
|
||||
const handleBatchAdd = () => {
|
||||
if (!batchTarget) {
|
||||
Toast.show({ content: "请选择目标分组", position: "top" });
|
||||
return;
|
||||
}
|
||||
// TODO: 调用后端批量接口,这里仅模拟
|
||||
Toast.show({
|
||||
content: `已将${selectedIds.length}个用户加入${packageOptions.find((p) => p.id === batchTarget)?.name || ""}`,
|
||||
position: "top",
|
||||
});
|
||||
setBatchModal(false);
|
||||
setSelectedIds([]);
|
||||
setBatchTarget("");
|
||||
// 可刷新列表
|
||||
};
|
||||
|
||||
// 性别icon
|
||||
const renderGender = (gender: number) => {
|
||||
if (gender === 1)
|
||||
return <span style={{ color: "#1890ff", marginLeft: 4 }}>♂</span>;
|
||||
if (gender === 2)
|
||||
return <span style={{ color: "#eb2f96", marginLeft: 4 }}>♀</span>;
|
||||
return <span style={{ color: "#aaa", marginLeft: 4 }}>?</span>;
|
||||
};
|
||||
|
||||
return (
|
||||
<Layout
|
||||
loading={loading}
|
||||
header={
|
||||
<>
|
||||
<NavCommon
|
||||
title="流量池用户列表"
|
||||
right={
|
||||
<Button
|
||||
size="small"
|
||||
onClick={() => setShowStats((s) => !s)}
|
||||
style={{ marginLeft: 8 }}
|
||||
>
|
||||
{showStats ? "收起分析" : "数据分析"}
|
||||
</Button>
|
||||
}
|
||||
/>
|
||||
{/* 搜索栏 */}
|
||||
<div className="search-bar">
|
||||
<div className="search-input-wrapper">
|
||||
<Input
|
||||
placeholder="搜索计划名称"
|
||||
value={search}
|
||||
onChange={(e) => setSearch(e.target.value)}
|
||||
prefix={<SearchOutlined />}
|
||||
allowClear
|
||||
size="large"
|
||||
/>
|
||||
</div>
|
||||
<Button
|
||||
size="small"
|
||||
onClick={getList}
|
||||
loading={loading}
|
||||
className="refresh-btn"
|
||||
>
|
||||
<ReloadOutlined />
|
||||
</Button>
|
||||
</div>
|
||||
</>
|
||||
}
|
||||
>
|
||||
{/* 数据分析面板 */}
|
||||
{showStats && (
|
||||
<div
|
||||
style={{
|
||||
background: "#fff",
|
||||
padding: "16px",
|
||||
margin: "8px 0",
|
||||
borderRadius: 8,
|
||||
boxShadow: "0 2px 8px rgba(0,0,0,0.04)",
|
||||
}}
|
||||
>
|
||||
<div style={{ display: "flex", gap: 16, marginBottom: 12 }}>
|
||||
<Card style={{ flex: 1 }}>
|
||||
<div style={{ fontSize: 18, fontWeight: 600, color: "#1677ff" }}>
|
||||
{stats.total}
|
||||
</div>
|
||||
<div style={{ fontSize: 13, color: "#888" }}>总用户数</div>
|
||||
</Card>
|
||||
<Card style={{ flex: 1 }}>
|
||||
<div style={{ fontSize: 18, fontWeight: 600, color: "#eb2f96" }}>
|
||||
{stats.highValue}
|
||||
</div>
|
||||
<div style={{ fontSize: 13, color: "#888" }}>高价值用户</div>
|
||||
</Card>
|
||||
</div>
|
||||
<div style={{ display: "flex", gap: 16 }}>
|
||||
<Card style={{ flex: 1 }}>
|
||||
<div style={{ fontSize: 18, fontWeight: 600, color: "#52c41a" }}>
|
||||
{stats.addSuccessRate}%
|
||||
</div>
|
||||
<div style={{ fontSize: 13, color: "#888" }}>添加成功率</div>
|
||||
</Card>
|
||||
<Card style={{ flex: 1 }}>
|
||||
<div style={{ fontSize: 18, fontWeight: 600, color: "#faad14" }}>
|
||||
{stats.added}
|
||||
</div>
|
||||
<div style={{ fontSize: 13, color: "#888" }}>已添加</div>
|
||||
</Card>
|
||||
<Card style={{ flex: 1 }}>
|
||||
<div style={{ fontSize: 18, fontWeight: 600, color: "#bfbfbf" }}>
|
||||
{stats.pending}
|
||||
</div>
|
||||
<div style={{ fontSize: 13, color: "#888" }}>待添加</div>
|
||||
</Card>
|
||||
<Card style={{ flex: 1 }}>
|
||||
<div style={{ fontSize: 18, fontWeight: 600, color: "#ff4d4f" }}>
|
||||
{stats.failed}
|
||||
</div>
|
||||
<div style={{ fontSize: 13, color: "#888" }}>添加失败</div>
|
||||
</Card>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
{/* 批量操作栏 */}
|
||||
<div
|
||||
style={{
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
padding: "8px 12px",
|
||||
background: "#fff",
|
||||
borderBottom: "1px solid #f0f0f0",
|
||||
}}
|
||||
>
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={selectedIds.length === list.length && list.length > 0}
|
||||
onChange={(e) => handleSelectAll(e.target.checked)}
|
||||
style={{ marginRight: 8 }}
|
||||
/>
|
||||
<span>全选</span>
|
||||
{selectedIds.length > 0 && (
|
||||
<>
|
||||
<span
|
||||
style={{ marginLeft: 16, color: "#1677ff" }}
|
||||
>{`已选${selectedIds.length}项`}</span>
|
||||
<Button
|
||||
size="small"
|
||||
color="primary"
|
||||
style={{ marginLeft: 16 }}
|
||||
onClick={() => setBatchModal(true)}
|
||||
>
|
||||
批量加入分组
|
||||
</Button>
|
||||
</>
|
||||
)}
|
||||
<Button
|
||||
size="small"
|
||||
onClick={() => setShowFilter(true)}
|
||||
style={{ marginLeft: 8 }}
|
||||
>
|
||||
筛选
|
||||
</Button>
|
||||
</div>
|
||||
{/* 批量加入分组弹窗 */}
|
||||
<Modal
|
||||
visible={batchModal}
|
||||
title="批量加入分组"
|
||||
onClose={() => setBatchModal(false)}
|
||||
footer={[
|
||||
{ text: "取消", onClick: () => setBatchModal(false) },
|
||||
{ text: "确定", onClick: handleBatchAdd },
|
||||
]}
|
||||
>
|
||||
<div style={{ marginBottom: 12 }}>
|
||||
<div>选择目标分组</div>
|
||||
<Selector
|
||||
options={packageOptions.map((p) => ({
|
||||
label: p.name,
|
||||
value: p.id,
|
||||
}))}
|
||||
value={[batchTarget]}
|
||||
onChange={(v) => setBatchTarget(v[0])}
|
||||
/>
|
||||
</div>
|
||||
<div style={{ color: "#888", fontSize: 13 }}>
|
||||
将选中的{selectedIds.length}个用户加入所选分组
|
||||
</div>
|
||||
</Modal>
|
||||
{/* 筛选弹窗 */}
|
||||
<Modal
|
||||
visible={showFilter}
|
||||
title="筛选"
|
||||
onClose={() => setShowFilter(false)}
|
||||
footer={[
|
||||
{
|
||||
text: "重置",
|
||||
onClick: () => {
|
||||
setDeviceId("all");
|
||||
setPackageId("all");
|
||||
setValueLevel("all");
|
||||
setUserStatus("all");
|
||||
},
|
||||
},
|
||||
{ text: "确定", onClick: () => setShowFilter(false) },
|
||||
]}
|
||||
>
|
||||
<div style={{ marginBottom: 12 }}>
|
||||
<div>设备</div>
|
||||
<Selector
|
||||
options={[
|
||||
{ label: "全部", value: "all" },
|
||||
...deviceOptions.map((d) => ({ label: d.name, value: d.id })),
|
||||
]}
|
||||
value={[deviceId]}
|
||||
onChange={(v) => setDeviceId(v[0])}
|
||||
/>
|
||||
</div>
|
||||
<div style={{ marginBottom: 12 }}>
|
||||
<div>分组</div>
|
||||
<Selector
|
||||
options={[
|
||||
{ label: "全部", value: "all" },
|
||||
...packageOptions.map((p) => ({ label: p.name, value: p.id })),
|
||||
]}
|
||||
value={[packageId]}
|
||||
onChange={(v) => setPackageId(v[0])}
|
||||
/>
|
||||
</div>
|
||||
<div style={{ marginBottom: 12 }}>
|
||||
<div>用户价值</div>
|
||||
<Selector
|
||||
options={valueLevelOptions}
|
||||
value={[valueLevel]}
|
||||
onChange={(v) => setValueLevel(v[0] as ValueLevel)}
|
||||
/>
|
||||
</div>
|
||||
<div style={{ marginBottom: 12 }}>
|
||||
<div>状态</div>
|
||||
<Selector
|
||||
options={statusOptions}
|
||||
value={[userStatus]}
|
||||
onChange={(v) => setUserStatus(v[0] as UserStatus)}
|
||||
/>
|
||||
</div>
|
||||
</Modal>
|
||||
<div className={styles.listWrap}>
|
||||
{list.length === 0 && !loading ? (
|
||||
<Empty description="暂无数据" />
|
||||
) : (
|
||||
<List>
|
||||
{list.map((item) => (
|
||||
<List.Item key={item.id}>
|
||||
<div
|
||||
className={styles.card}
|
||||
style={{ cursor: "pointer" }}
|
||||
onClick={() =>
|
||||
navigate(`/mine/traffic-pool/detail/${item.id}`)
|
||||
}
|
||||
>
|
||||
<div
|
||||
style={{ display: "flex", alignItems: "center", gap: 12 }}
|
||||
>
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={selectedIds.includes(item.id)}
|
||||
onChange={(e) => handleSelect(item.id, e.target.checked)}
|
||||
style={{ marginRight: 8 }}
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
/>
|
||||
<Avatar
|
||||
src={item.avatar || defaultAvatar}
|
||||
style={{ "--size": "44px" }}
|
||||
/>
|
||||
<div style={{ flex: 1 }}>
|
||||
<div className={styles.title}>
|
||||
{item.nickname || item.identifier}
|
||||
{renderGender(item.gender)}
|
||||
</div>
|
||||
<div className={styles.desc}>
|
||||
微信号:{item.wechatId || "-"}
|
||||
</div>
|
||||
<div className={styles.desc}>
|
||||
来源:{item.fromd || "-"}
|
||||
</div>
|
||||
<div className={styles.desc}>
|
||||
分组:
|
||||
{item.packages && item.packages.length
|
||||
? item.packages.join(",")
|
||||
: "-"}
|
||||
</div>
|
||||
<div className={styles.desc}>
|
||||
创建时间:{item.createTime}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</List.Item>
|
||||
))}
|
||||
</List>
|
||||
)}
|
||||
</div>
|
||||
{/* 分页 */}
|
||||
{total > pageSize && (
|
||||
<div className={styles.pagination}>
|
||||
<button disabled={page === 1} onClick={() => setPage(page - 1)}>
|
||||
上一页
|
||||
</button>
|
||||
<span>
|
||||
{page} / {Math.ceil(total / pageSize)}
|
||||
</span>
|
||||
<button
|
||||
disabled={page === Math.ceil(total / pageSize)}
|
||||
onClick={() => setPage(page + 1)}
|
||||
>
|
||||
下一页
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</Layout>
|
||||
);
|
||||
};
|
||||
|
||||
export default TrafficPoolList;
|
||||
|
||||
Reference in New Issue
Block a user