Merge branch 'yongpxu-dev' into yongpxu-dev4

This commit is contained in:
笔记本里的永平
2025-07-24 17:58:38 +08:00
6 changed files with 564 additions and 21 deletions

View File

@@ -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;

View File

@@ -1,8 +0,0 @@
import React from "react";
import PlaceholderPage from "@/components/PlaceholderPage";
const TrafficPoolDetail: React.FC = () => {
return <PlaceholderPage title="流量池详情" />;
};
export default TrafficPoolDetail;

View File

@@ -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: "测试流量池" },
];
}

View File

@@ -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";

View File

@@ -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;
}

View File

@@ -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;