移除流量池详情及相关组件:删除流量池详情页面、API、数据类型和样式文件,简化代码结构,提升可维护性和用户体验。

This commit is contained in:
超级老白兔
2025-10-17 18:18:17 +08:00
parent 3230bbe2e5
commit fbb78ebd50
30 changed files with 2130 additions and 1598 deletions

View File

@@ -1,25 +0,0 @@
import request from "@/api/request";
import type { UserTagsResponse } from "./data";
export function getTrafficPoolDetail(wechatId: string) {
return request("/v1/traffic/pool/getUserInfo", { wechatId }, "GET");
}
// 获取用户旅程记录
export function getUserJourney(params: {
page: number;
pageSize: number;
userId: string;
}) {
return request("/v1/traffic/pool/getUserJourney", params, "GET");
}
// 获取用户标签
export function getUserTags(userId: string): Promise<UserTagsResponse> {
return request("/v1/traffic/pool/getUserTags", { userId }, "GET");
}
// 添加用户标签
export function addUserTag(userId: string, tagData: any): Promise<any> {
return request("/v1/user/tags", { userId, ...tagData }, "POST");
}

View File

@@ -1,133 +0,0 @@
// 设备信息类型
export interface DeviceInfo {
id: number;
memo: string;
imei: string;
brand: string;
alive: number;
address: string;
}
// 来源信息类型
export interface SourceInfo {
nickname: string;
avatar: string;
gender: number;
phone: string;
wechatId: string;
alias: string;
createTime: string;
friendId: number;
wechatAccountId: number;
lastMsgTime: string;
device: DeviceInfo;
}
// 统计总计类型
export interface TotalStats {
msg: number;
money: number;
isFriend: boolean;
percentage: string;
}
// RMM评分类型
export interface RmmScore {
r: number;
f: number;
m: number;
}
// 用户详情类型
export interface TrafficPoolUserDetail {
id: number;
identifier: string;
wechatId: string;
nickname: string;
avatar: string;
gender: number;
phone: string;
alias: string;
lastMsgTime: string;
source: SourceInfo[];
packages: any[];
total: TotalStats;
rmm: RmmScore;
}
// 扩展的用户详情类型
export interface ExtendedUserDetail extends TrafficPoolUserDetail {
// 保留原有的扩展字段用于向后兼容
userInfo?: {
nickname: string;
avatar: string;
wechatId: string;
friendShip: {
totalFriend: number;
maleFriend: number;
femaleFriend: number;
unknowFriend: number;
};
};
rfmScore?: {
recency: number;
frequency: number;
monetary: number;
totalScore: number;
};
trafficPools?: {
currentPool: string;
availablePools: string[];
};
userTags?: Array<{
id: string;
name: string;
color: string;
type: string;
}>;
valueTags?: Array<{
id: string;
name: string;
color: string;
icon: string;
rfmScore: number;
valueLevel: string;
}>;
restrictions?: Array<{
id: string;
reason: string;
level: number;
date: number | null;
}>;
}
// 互动记录类型
export interface InteractionRecord {
id: string;
type: string;
content: string;
timestamp: string;
value?: number;
}
// 用户旅程记录类型
export interface UserJourneyRecord {
id: string;
type: number;
remark: string;
createTime: string;
}
// 用户标签响应类型
export interface UserTagsResponse {
wechat: string[];
siteLabels: UserTagItem[];
}
// 用户标签项类型
export interface UserTagItem {
id: string;
name: string;
color?: string;
type?: string;
}

View File

@@ -1,426 +0,0 @@
// 头部样式
.header {
display: flex;
justify-content: space-between;
align-items: center;
padding: 16px;
background: #fff;
border-bottom: 1px solid #f0f0f0;
.title {
font-size: 18px;
font-weight: 600;
color: #333;
}
.closeBtn {
padding: 8px;
border: none;
background: transparent;
color: #999;
font-size: 16px;
}
}
// 用户卡片
.userCard {
margin: 16px;
border-radius: 12px;
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.1);
.userInfo {
display: flex;
align-items: flex-start;
gap: 16px;
}
.avatar {
width: 60px;
height: 60px;
border-radius: 50%;
flex-shrink: 0;
}
.avatarFallback {
width: 100%;
height: 100%;
display: flex;
align-items: center;
justify-content: center;
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
color: white;
font-size: 24px;
border-radius: 50%;
}
.userDetails {
flex: 1;
min-width: 0;
}
.nickname {
font-size: 18px;
font-weight: 600;
color: #333;
margin-bottom: 4px;
}
.wechatId {
font-size: 14px;
color: #666;
margin-bottom: 8px;
}
.tags {
display: flex;
flex-wrap: wrap;
gap: 8px;
}
.userTag {
font-size: 12px;
padding: 4px 8px;
border-radius: 12px;
}
}
// 标签导航
.tabNav {
display: flex;
background: #fff;
margin: 0 16px;
border-radius: 8px;
overflow: hidden;
box-shadow: 0 2px 4px rgba(0, 0, 0, 0.1);
.tabItem {
flex: 1;
padding: 12px 16px;
text-align: center;
font-size: 14px;
color: #666;
cursor: pointer;
transition: all 0.3s ease;
border-bottom: 2px solid transparent;
&.active {
color: var(--primary-color);
border-bottom-color: var(--primary-color);
background: rgba(24, 142, 238, 0.05);
}
&:hover {
background: rgba(24, 142, 238, 0.05);
}
}
}
// 内容区域
.content {
padding: 10px 10px 10px 16px;
}
.tabContent {
display: flex;
flex-direction: column;
gap: 16px;
}
// 信息卡片
.infoCard {
border-radius: 12px;
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.1);
overflow: hidden;
:global(.adm-card-header) {
padding: 16px;
border-bottom: 1px solid #f0f0f0;
font-weight: 600;
color: #333;
}
:global(.adm-card-body) {
padding: 0;
}
}
// RFM评分网格
.rfmGrid {
display: grid;
grid-template-columns: repeat(2, 1fr);
gap: 16px;
padding: 16px;
}
.rfmItem {
text-align: center;
padding: 12px;
background: #f8f9fa;
border-radius: 8px;
}
.rfmLabel {
font-size: 12px;
color: #666;
margin-bottom: 4px;
}
.rfmValue {
font-size: 18px;
font-weight: 600;
}
// 流量池区域
.poolSection {
padding: 16px;
display: flex;
flex-direction: column;
gap: 12px;
}
.currentPool,
.availablePools {
display: flex;
align-items: center;
gap: 8px;
flex-wrap: wrap;
}
.poolLabel {
font-size: 14px;
color: #666;
white-space: nowrap;
}
// 统计数据网格
.statsGrid {
display: grid;
grid-template-columns: repeat(2, 1fr);
gap: 16px;
padding: 16px;
}
.statItem {
text-align: center;
padding: 12px;
background: #f8f9fa;
border-radius: 8px;
}
.statValue {
font-size: 18px;
font-weight: 600;
margin-bottom: 4px;
}
.statLabel {
font-size: 12px;
color: #666;
}
// 用户旅程
.journeyItem {
display: flex;
justify-content: space-between;
align-items: center;
font-size: 12px;
color: #666;
margin-top: 4px;
}
.timestamp {
color: #999;
}
// 加载状态
.loadingContainer {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
padding: 40px 16px;
text-align: center;
}
.loadingText {
font-size: 14px;
color: #999;
margin-top: 8px;
}
.loadingMore {
display: flex;
align-items: center;
justify-content: center;
gap: 8px;
padding: 16px;
color: #666;
font-size: 14px;
}
.loadMoreBtn {
display: flex;
justify-content: center;
padding: 16px;
}
// 标签区域
.tagsSection {
padding: 16px;
display: flex;
flex-wrap: wrap;
gap: 8px;
}
.valueTagsSection {
padding: 16px;
display: flex;
flex-direction: column;
gap: 12px;
}
.tagItem {
font-size: 12px;
padding: 6px 12px;
border-radius: 16px;
}
.valueTagContainer {
display: flex;
flex-direction: column;
gap: 8px;
}
.valueTagRow {
display: flex;
justify-content: space-between;
align-items: center;
gap: 8px;
}
.rfmScoreText {
font-size: 12px;
color: #666;
white-space: nowrap;
}
.valueLevelLabel {
font-size: 12px;
color: #666;
white-space: nowrap;
}
.valueTagItem {
display: flex;
justify-content: space-between;
align-items: center;
padding: 12px 0;
border-bottom: 1px solid #f0f0f0;
&:last-child {
border-bottom: none;
}
}
.valueInfo {
display: flex;
align-items: center;
gap: 8px;
font-size: 12px;
color: #666;
}
// 添加标签按钮
.addTagBtn {
margin-top: 16px;
border-radius: 8px;
height: 48px;
font-size: 16px;
font-weight: 500;
display: flex;
align-items: center;
justify-content: center;
gap: 8px;
}
// 空状态
.emptyState {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
padding: 20px 16px;
text-align: center;
}
.emptyIcon {
margin-bottom: 16px;
opacity: 0.6;
}
.emptyText {
font-size: 14px;
color: #666;
margin-bottom: 4px;
font-weight: 500;
}
.emptyDesc {
font-size: 12px;
color: #999;
line-height: 1.4;
}
// 限制记录样式
.restrictionTitle {
display: flex;
align-items: center;
justify-content: space-between;
gap: 8px;
font-size: 14px;
font-weight: 500;
color: #333;
line-height: 1.4;
}
.restrictionLevel {
font-size: 10px;
padding: 2px 6px;
border-radius: 8px;
flex-shrink: 0;
}
.restrictionContent {
display: flex;
flex-direction: column;
gap: 4px;
font-size: 12px;
color: #666;
line-height: 1.4;
margin-top: 4px;
}
// 响应式设计
@media (max-width: 375px) {
.rfmGrid,
.statsGrid {
grid-template-columns: 1fr;
}
.userInfo {
flex-direction: column;
text-align: center;
}
.avatar {
align-self: center;
}
.restrictionTitle {
font-size: 13px;
}
.restrictionContent {
font-size: 11px;
}
}

View File

@@ -1,795 +0,0 @@
import React, { useEffect, useState } from "react";
import { useParams } from "react-router-dom";
import { Card, Button, Avatar, Tag, List, SpinLoading } from "antd-mobile";
import {
UserOutlined,
CrownOutlined,
EyeOutlined,
DollarOutlined,
MobileOutlined,
TagOutlined,
FileTextOutlined,
UserAddOutlined,
} from "@ant-design/icons";
import Layout from "@/components/Layout/Layout";
import NavCommon from "@/components/NavCommon";
import { getTrafficPoolDetail, getUserJourney, getUserTags } from "./api";
import type {
ExtendedUserDetail,
UserJourneyRecord,
UserTagsResponse,
UserTagItem,
} from "./data";
import styles from "./index.module.scss";
// RMM评分辅助函数
const getRmmValueLevel = (totalScore: number): string => {
if (totalScore >= 12) return "高价值客户";
if (totalScore >= 8) return "中等价值客户";
if (totalScore >= 4) return "低价值客户";
return "潜在客户";
};
const getRmmColor = (totalScore: number): string => {
if (totalScore >= 12) return "danger";
if (totalScore >= 8) return "warning";
if (totalScore >= 4) return "primary";
return "default";
};
const TrafficPoolDetail: React.FC = () => {
const { wxid, userId } = useParams();
const [loading, setLoading] = useState(true);
const [user, setUser] = useState<ExtendedUserDetail | null>(null);
const [activeTab, setActiveTab] = useState("basic");
// 用户旅程相关状态
const [journeyLoading, setJourneyLoading] = useState(false);
const [journeyList, setJourneyList] = useState<UserJourneyRecord[]>([]);
const [journeyPage, setJourneyPage] = useState(1);
const [journeyTotal, setJourneyTotal] = useState(0);
const pageSize = 10;
// 用户标签相关状态
const [tagsLoading, setTagsLoading] = useState(false);
const [userTagsList, setUserTagsList] = useState<UserTagItem[]>([]);
const [wechatTagsList, setWechatTagsList] = useState<string[]>([]);
useEffect(() => {
if (!wxid) return;
setLoading(true);
getTrafficPoolDetail(wxid as string)
.then(res => {
// 直接使用API返回的数据结构
const extendedUser: ExtendedUserDetail = {
...res,
// 根据新数据结构构建userInfo
userInfo: {
nickname: res.nickname,
avatar: res.avatar,
wechatId: res.wechatId,
friendShip: {
totalFriend: res.source?.length || 0,
maleFriend: res.source?.filter(s => s.gender === 1).length || 0,
femaleFriend: res.source?.filter(s => s.gender === 2).length || 0,
unknowFriend: res.source?.filter(s => s.gender === 0).length || 0,
},
},
// 使用API返回的RMM数据
rfmScore: {
recency: res.rmm.r,
frequency: res.rmm.f,
monetary: res.rmm.m,
totalScore: res.rmm.r + res.rmm.f + res.rmm.m,
},
// 根据数据推断流量池信息
trafficPools: {
currentPool: res.total.isFriend ? "已添加好友池" : "待添加池",
availablePools: ["高价值客户池", "活跃用户池", "新用户池"],
},
// 基于数据生成用户标签
userTags: [
...(res.total.isFriend
? [
{
id: "friend",
name: "已添加好友",
color: "success",
type: "status",
},
]
: []),
...(res.total.money > 0
? [
{
id: "paid",
name: "付费用户",
color: "warning",
type: "value",
},
]
: []),
...(res.total.msg > 10
? [
{
id: "active",
name: "高频互动",
color: "primary",
type: "behavior",
},
]
: []),
...(res.source?.length > 1
? [
{
id: "multi",
name: "多设备用户",
color: "danger",
type: "device",
},
]
: []),
],
// 基于RMM评分生成价值标签
valueTags: [
{
id: "rmm",
name: getRmmValueLevel(res.rmm.r + res.rmm.f + res.rmm.m),
color: getRmmColor(res.rmm.r + res.rmm.f + res.rmm.m),
icon: "crown",
rfmScore: res.rmm.r + res.rmm.f + res.rmm.m,
valueLevel: getRmmValueLevel(res.rmm.r + res.rmm.f + res.rmm.m),
},
],
};
console.log("用户详情数据:", extendedUser);
setUser(extendedUser);
})
.finally(() => setLoading(false));
}, [wxid]);
// 获取用户旅程数据
const fetchUserJourney = async (page: number = 1) => {
if (!userId) return;
setJourneyLoading(true);
try {
const response = await getUserJourney({
page,
pageSize,
userId: userId,
});
if (page === 1) {
setJourneyList(response.list);
} else {
setJourneyList(prev => [...prev, ...response.list]);
}
setJourneyTotal(response.total);
setJourneyPage(page);
} catch (error) {
console.error("获取用户旅程失败:", error);
} finally {
setJourneyLoading(false);
}
};
// 获取用户标签数据
const fetchUserTags = async () => {
if (!userId) return;
setTagsLoading(true);
try {
const response: UserTagsResponse = await getUserTags(userId);
setUserTagsList(response.siteLabels || []);
setWechatTagsList(response.wechat || []);
} catch (error) {
console.error("获取用户标签失败:", error);
} finally {
setTagsLoading(false);
}
};
// 标签切换处理
const handleTabChange = (tab: string) => {
setActiveTab(tab);
if (tab === "journey" && journeyList.length === 0) {
fetchUserJourney(1);
}
if (tab === "tags" && userTagsList.length === 0) {
fetchUserTags();
}
};
const getJourneyTypeIcon = (type: number) => {
switch (type) {
case 0: // 浏览
return <EyeOutlined style={{ color: "#722ed1" }} />;
case 2: // 提交订单
return <FileTextOutlined style={{ color: "#52c41a" }} />;
case 3: // 注册
return <UserAddOutlined style={{ color: "#1677ff" }} />;
default:
return <MobileOutlined style={{ color: "#999" }} />;
}
};
const getJourneyTypeText = (type: number) => {
switch (type) {
case 0:
return "浏览行为";
case 2:
return "提交订单";
case 3:
return "注册行为";
default:
return "其他行为";
}
};
const formatDateTime = (dateTime: string) => {
try {
const date = new Date(dateTime);
return date.toLocaleString("zh-CN", {
year: "numeric",
month: "2-digit",
day: "2-digit",
hour: "2-digit",
minute: "2-digit",
});
} catch (error) {
return dateTime;
}
};
const getActionIcon = (type: string) => {
switch (type) {
case "click":
return <MobileOutlined style={{ color: "#1677ff" }} />;
case "view":
return <EyeOutlined style={{ color: "#722ed1" }} />;
case "purchase":
return <DollarOutlined style={{ color: "#52c41a" }} />;
default:
return <MobileOutlined style={{ color: "#999" }} />;
}
};
const getRestrictionLevelText = (level: number) => {
switch (level) {
case 1:
return "轻微";
case 2:
return "中等";
case 3:
return "严重";
default:
return "未知";
}
};
const getRestrictionLevelColor = (level: number) => {
switch (level) {
case 1:
return "warning";
case 2:
return "danger";
case 3:
return "danger";
default:
return "default";
}
};
const formatDate = (timestamp: number | null) => {
if (!timestamp) return "--";
try {
const date = new Date(timestamp * 1000);
return date.toLocaleDateString("zh-CN");
} catch (error) {
return "--";
}
};
// 获取标签颜色
const getTagColor = (index: number): string => {
const colors = ["primary", "success", "warning", "danger", "default"];
return colors[index % colors.length];
};
if (!user) {
return (
<Layout header={<NavCommon title="用户详情" />} loading={loading}>
<div className={styles.emptyState}>
<div className={styles.emptyText}></div>
</div>
</Layout>
);
}
return (
<Layout
loading={loading}
header={
<>
<NavCommon title="用户详情" />
{/* 用户基本信息 */}
<Card className={styles.userCard}>
<div className={styles.userInfo}>
<Avatar
src={user.avatar}
className={styles.avatar}
fallback={
<div className={styles.avatarFallback}>
<UserOutlined />
</div>
}
/>
<div className={styles.userDetails}>
<div className={styles.nickname}>{user.nickname}</div>
<div className={styles.wechatId}>{user.wechatId}</div>
<div className={styles.tags}>
{user.valueTags?.map(tag => (
<Tag
key={tag.id}
color={tag.color}
fill="outline"
className={styles.userTag}
>
<CrownOutlined />
{tag.name}
</Tag>
))}
{user.total.isFriend && (
<Tag
color="success"
fill="outline"
className={styles.userTag}
>
</Tag>
)}
</div>
</div>
</div>
</Card>
{/* 导航标签 */}
<div className={styles.tabNav}>
<div
className={`${styles.tabItem} ${
activeTab === "basic" ? styles.active : ""
}`}
onClick={() => handleTabChange("basic")}
>
</div>
<div
className={`${styles.tabItem} ${
activeTab === "journey" ? styles.active : ""
}`}
onClick={() => handleTabChange("journey")}
>
</div>
<div
className={`${styles.tabItem} ${
activeTab === "tags" ? styles.active : ""
}`}
onClick={() => handleTabChange("tags")}
>
</div>
</div>
</>
}
>
<div className={styles.container}>
{/* 内容区域 */}
<div className={styles.content}>
{activeTab === "basic" && (
<div className={styles.tabContent}>
{/* 关联信息 */}
<Card title="关联信息" className={styles.infoCard}>
<List>
<List.Item
extra={
user.source?.length
? `${user.source.length}个设备`
: "无设备"
}
>
</List.Item>
<List.Item extra={user.wechatId || "--"}></List.Item>
<List.Item extra={user.alias || "--"}></List.Item>
<List.Item
extra={
user.source?.[0]?.createTime
? formatDateTime(user.source[0].createTime)
: "--"
}
>
</List.Item>
<List.Item extra={user.lastMsgTime || "--"}>
</List.Item>
</List>
</Card>
{/* RFM评分 */}
{user.rfmScore && (
<Card title="RFM评分" className={styles.infoCard}>
<div className={styles.rfmGrid}>
<div className={styles.rfmItem}>
<div className={styles.rfmLabel}>(R)</div>
<div
className={styles.rfmValue}
style={{ color: "#1677ff" }}
>
{user.rfmScore.recency}
</div>
</div>
<div className={styles.rfmItem}>
<div className={styles.rfmLabel}>(F)</div>
<div
className={styles.rfmValue}
style={{ color: "#52c41a" }}
>
{user.rfmScore.frequency}
</div>
</div>
<div className={styles.rfmItem}>
<div className={styles.rfmLabel}>(M)</div>
<div
className={styles.rfmValue}
style={{ color: "#722ed1" }}
>
{user.rfmScore.monetary}
</div>
</div>
<div className={styles.rfmItem}>
<div className={styles.rfmLabel}></div>
<div
className={styles.rfmValue}
style={{ color: "#ff4d4f" }}
>
{user.rfmScore.totalScore}/15
</div>
</div>
</div>
</Card>
)}
{/* 流量池 */}
{user.trafficPools && (
<Card title="流量池" className={styles.infoCard}>
<div className={styles.poolSection}>
<div className={styles.currentPool}>
<span className={styles.poolLabel}></span>
<Tag color="primary" fill="outline">
{user.trafficPools.currentPool}
</Tag>
</div>
<div className={styles.availablePools}>
<span className={styles.poolLabel}></span>
{user.trafficPools.availablePools.map((pool, index) => (
<Tag key={index} color="default" fill="outline">
{pool}
</Tag>
))}
</div>
</div>
</Card>
)}
{/* 统计数据 */}
<Card title="统计数据" className={styles.infoCard}>
<div className={styles.statsGrid}>
<div className={styles.statItem}>
<div
className={styles.statValue}
style={{ color: "#52c41a" }}
>
¥{user.total.money || 0}
</div>
<div className={styles.statLabel}></div>
</div>
<div className={styles.statItem}>
<div
className={styles.statValue}
style={{ color: "#1677ff" }}
>
{user.total.msg || 0}
</div>
<div className={styles.statLabel}></div>
</div>
<div className={styles.statItem}>
<div
className={styles.statValue}
style={{ color: "#722ed1" }}
>
{user.total.percentage || "0"}%
</div>
<div className={styles.statLabel}></div>
</div>
<div className={styles.statItem}>
<div
className={styles.statValue}
style={{
color: user.total.isFriend ? "#52c41a" : "#999",
}}
>
{user.total.isFriend ? "已添加" : "未添加"}
</div>
<div className={styles.statLabel}></div>
</div>
</div>
</Card>
{/* 好友统计 */}
<Card title="好友统计" className={styles.infoCard}>
<div className={styles.statsGrid}>
<div className={styles.statItem}>
<div
className={styles.statValue}
style={{ color: "#1677ff" }}
>
{user.userInfo?.friendShip.totalFriend || 0}
</div>
<div className={styles.statLabel}></div>
</div>
<div className={styles.statItem}>
<div
className={styles.statValue}
style={{ color: "#1677ff" }}
>
{user.userInfo?.friendShip.maleFriend || 0}
</div>
<div className={styles.statLabel}></div>
</div>
<div className={styles.statItem}>
<div
className={styles.statValue}
style={{ color: "#eb2f96" }}
>
{user.userInfo?.friendShip.femaleFriend || 0}
</div>
<div className={styles.statLabel}></div>
</div>
<div className={styles.statItem}>
<div className={styles.statValue} style={{ color: "#999" }}>
{user.userInfo?.friendShip.unknowFriend || 0}
</div>
<div className={styles.statLabel}></div>
</div>
</div>
</Card>
{/* 限制记录 */}
<Card title="限制记录" className={styles.infoCard}>
{user.restrictions && user.restrictions.length > 0 ? (
<List>
{user.restrictions.map(restriction => (
<List.Item
key={restriction.id}
title={
<div className={styles.restrictionTitle}>
<span>{restriction.reason || "未知原因"}</span>
<Tag
color={getRestrictionLevelColor(
restriction.level,
)}
fill="outline"
className={styles.restrictionLevel}
>
{getRestrictionLevelText(restriction.level)}
</Tag>
</div>
}
description={
<div className={styles.restrictionContent}>
<span>ID: {restriction.id}</span>
{restriction.date && (
<span>
: {formatDate(restriction.date)}
</span>
)}
</div>
}
/>
))}
</List>
) : (
<div className={styles.emptyState}>
<div className={styles.emptyIcon}>
<UserOutlined style={{ fontSize: 48, color: "#ccc" }} />
</div>
<div className={styles.emptyText}></div>
<div className={styles.emptyDesc}>
</div>
</div>
)}
</Card>
</div>
)}
{activeTab === "journey" && (
<div className={styles.tabContent}>
<Card title="互动记录" className={styles.infoCard}>
{journeyLoading && journeyList.length === 0 ? (
<div className={styles.loadingContainer}>
<SpinLoading color="primary" style={{ fontSize: 24 }} />
<div className={styles.loadingText}>...</div>
</div>
) : journeyList.length === 0 ? (
<div className={styles.emptyState}>
<div className={styles.emptyIcon}>
<EyeOutlined style={{ fontSize: 48, color: "#ccc" }} />
</div>
<div className={styles.emptyText}></div>
<div className={styles.emptyDesc}>
</div>
</div>
) : (
<List>
{journeyList.map(record => (
<List.Item
key={record.id}
prefix={getJourneyTypeIcon(record.type)}
title={getJourneyTypeText(record.type)}
description={
<div className={styles.journeyItem}>
<span>{record.remark}</span>
<span className={styles.timestamp}>
{formatDateTime(record.createTime)}
</span>
</div>
}
/>
))}
{journeyLoading && journeyList.length > 0 && (
<div className={styles.loadingMore}>
<SpinLoading color="primary" style={{ fontSize: 16 }} />
<span>...</span>
</div>
)}
{!journeyLoading && journeyList.length < journeyTotal && (
<div className={styles.loadMoreBtn}>
<Button
size="small"
fill="outline"
onClick={() => fetchUserJourney(journeyPage + 1)}
>
</Button>
</div>
)}
</List>
)}
</Card>
</div>
)}
{activeTab === "tags" && (
<div className={styles.tabContent}>
{/* 站内标签 */}
<Card title="站内标签" className={styles.infoCard}>
{tagsLoading && userTagsList.length === 0 ? (
<div className={styles.loadingContainer}>
<SpinLoading color="primary" style={{ fontSize: 20 }} />
<div className={styles.loadingText}>...</div>
</div>
) : userTagsList.length === 0 ? (
<div className={styles.emptyState}>
<div className={styles.emptyIcon}>
<TagOutlined style={{ fontSize: 36, color: "#ccc" }} />
</div>
<div className={styles.emptyText}></div>
<div className={styles.emptyDesc}>
</div>
</div>
) : (
<div className={styles.tagsSection}>
{userTagsList.map((tag, index) => (
<Tag
key={tag.id}
color={getTagColor(index)}
fill="outline"
className={styles.tagItem}
>
{tag.name}
</Tag>
))}
</div>
)}
</Card>
{/* 微信标签 */}
<Card title="微信标签" className={styles.infoCard}>
{tagsLoading && wechatTagsList.length === 0 ? (
<div className={styles.loadingContainer}>
<SpinLoading color="primary" style={{ fontSize: 24 }} />
<div className={styles.loadingText}>...</div>
</div>
) : wechatTagsList.length === 0 ? (
<div className={styles.emptyState}>
<div className={styles.emptyIcon}>
<TagOutlined style={{ fontSize: 48, color: "#ccc" }} />
</div>
<div className={styles.emptyText}></div>
<div className={styles.emptyDesc}>
</div>
</div>
) : (
<div className={styles.tagsSection}>
{wechatTagsList.map((tag, index) => (
<Tag
key={index}
color="danger"
fill="outline"
className={styles.tagItem}
>
{tag}
</Tag>
))}
</div>
)}
</Card>
{/* 价值标签 */}
<Card title="价值标签" className={styles.infoCard}>
{user.valueTags && user.valueTags.length > 0 ? (
<div className={styles.valueTagsSection}>
{user.valueTags.map(tag => (
<div key={tag.id} className={styles.valueTagContainer}>
<div className={styles.valueTagRow}>
<Tag
color={tag.color}
fill="outline"
className={styles.tagItem}
>
{tag.icon === "crown" && <CrownOutlined />}
{tag.name}
</Tag>
<span className={styles.rfmScoreText}>
RFM总分: {tag.rfmScore}/15
</span>
</div>
<div className={styles.valueTagRow}>
<span className={styles.valueLevelLabel}>
:
</span>
<Tag color="danger" fill="outline">
{tag.valueLevel}
</Tag>
</div>
</div>
))}
</div>
) : (
<div className={styles.emptyState}>
<div className={styles.emptyIcon}>
<CrownOutlined style={{ fontSize: 48, color: "#ccc" }} />
</div>
<div className={styles.emptyText}></div>
<div className={styles.emptyDesc}>
</div>
</div>
)}
</Card>
</div>
)}
</div>
</div>
</Layout>
);
};
export default TrafficPoolDetail;

View File

@@ -0,0 +1,101 @@
# 新建流量包功能
## 功能概述
新建流量包功能是一个完整的用户群体管理工具,允许用户创建和管理基于特定条件的用户分组。
## 页面结构
### 主页面 (`index.tsx`)
- 包含三个标签页:基本信息、人群筛选、用户列表
- 使用 Tabs 组件进行页面切换
- 底部固定提交按钮
### 组件结构
#### 1. 基本信息组件 (`BasicInfo.tsx`)
- **流量包名称**:必填字段
- **描述**:可选字段
- **备注**:可选字段,支持多行输入
#### 2. 人群筛选组件 (`AudienceFilter.tsx`)
- **RFM分析**:展示最近消费、消费频率、消费金额
- **年龄层**:显示年龄范围
- **消费能力**:显示消费能力等级
- **标签筛选**预设的8个标签
- **自定义条件**:支持添加自定义筛选条件
- **方案推荐**提供6个预设方案
#### 3. 用户列表预览组件 (`UserListPreview.tsx`)
- 显示筛选后的用户列表
- 支持全选和批量操作
- 显示用户详细信息RFM评分、活跃度、消费金额等
- 支持单个用户移除
#### 4. 自定义条件弹窗 (`CustomConditionModal.tsx`)
- 支持10种不同的标签类型
- 根据标签类型显示不同的输入方式:
- 年龄层:两个数字输入框(范围)
- 其他标签:下拉选择框
- 支持条件的添加和删除
#### 5. 方案推荐弹窗 (`SchemeRecommendation.tsx`)
- 提供6个预设方案
- 高价值客户方案
- 新用户激活方案
- 用户留存方案
- 升单转化方案
- 价格敏感用户方案
- 忠诚客户维护方案
- 每个方案包含筛选条件和预估用户数量
#### 6. 条件列表组件 (`ConditionList.tsx`)
- 显示已添加的自定义条件
- 支持条件的删除和编辑
## 数据流
1. **基本信息** → 保存到 `formData` 状态
2. **筛选条件** → 保存到 `formData.filterConditions`
3. **生成用户列表** → 调用模拟API生成用户数据
4. **提交** → 将所有数据提交到后端
## 路由配置
- 路径:`/mine/traffic-pool/create`
- 组件:`CreateTrafficPackage`
- 权限:需要登录
## 使用流程
1. 填写基本信息(流量包名称必填)
2. 在人群筛选页面设置筛选条件:
- 使用预设标签
- 添加自定义条件
- 或选择预设方案
3. 点击"生成用户列表"查看筛选结果
4. 在用户列表页面预览和调整用户
5. 点击"创建流量包"完成创建
## 技术特点
- **模块化设计**:每个功能独立封装为组件
- **响应式布局**:适配移动端显示
- **状态管理**使用React Hooks管理复杂状态
- **用户体验**:提供丰富的交互反馈
- **数据模拟**:包含完整的模拟数据用于演示
## 扩展性
- 支持添加新的标签类型
- 支持添加新的预设方案
- 支持自定义筛选逻辑
- 支持导出用户列表
- 支持批量操作功能

View File

@@ -0,0 +1,75 @@
import request from "@/api/request";
// 创建流量包
export interface CreateTrafficPackageParams {
name: string;
description?: string;
remarks?: string;
filterConditions: any[];
userIds: string[];
}
export interface CreateTrafficPackageResponse {
id: string;
name: string;
success: boolean;
message: string;
}
export async function createTrafficPackage(
params: CreateTrafficPackageParams,
): Promise<CreateTrafficPackageResponse> {
return request("/v1/traffic/pool/create", params, "POST");
}
// 获取用户列表(根据筛选条件)
export interface GetUsersByFilterParams {
conditions: any[];
page?: number;
pageSize?: number;
}
export interface User {
id: string;
name: string;
avatar: string;
tags: string[];
rfmScore: number;
lastActive: string;
consumption: number;
}
export interface GetUsersByFilterResponse {
list: User[];
total: number;
}
export async function getUsersByFilter(
params: GetUsersByFilterParams,
): Promise<GetUsersByFilterResponse> {
return request("/v1/traffic/pool/users/filter", params, "POST");
}
// 获取预设方案列表
export interface PresetScheme {
id: string;
name: string;
description: string;
conditions: any[];
userCount: number;
color: string;
}
export async function getPresetSchemes(): Promise<PresetScheme[]> {
return request("/v1/traffic/pool/schemes", {}, "GET");
}
// 获取行业选项(固定筛选项)
export interface IndustryOption {
label: string;
value: string | number;
}
export async function getIndustryOptions(): Promise<IndustryOption[]> {
return request("/v1/traffic/pool/industries", {}, "GET");
}

View File

@@ -0,0 +1,121 @@
.container {
padding: 0;
}
.card {
border-radius: 8px;
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.04);
}
.header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 20px;
}
.title {
font-size: 16px;
font-weight: 600;
color: #333;
}
.schemeBtn {
display: flex;
align-items: center;
gap: 4px;
font-size: 12px;
padding: 4px 8px;
height: 28px;
}
.section {
margin-bottom: 24px;
}
.sectionTitle {
font-size: 14px;
font-weight: 500;
color: #333;
margin-bottom: 12px;
}
.rfmGrid {
display: grid;
grid-template-columns: 1fr 1fr 1fr;
gap: 12px;
}
.rfmItem {
background: #f8f9fa;
border-radius: 6px;
padding: 12px;
text-align: center;
}
.rfmLabel {
font-size: 12px;
color: #666;
margin-bottom: 4px;
}
.rfmValue {
font-size: 14px;
font-weight: 500;
color: #333;
}
.ageRange {
background: #f8f9fa;
border-radius: 6px;
padding: 12px;
text-align: center;
font-size: 14px;
color: #333;
}
.consumptionLevel {
display: flex;
justify-content: center;
}
.levelTag {
background: #52c41a;
color: white;
padding: 4px 12px;
border-radius: 12px;
font-size: 12px;
font-weight: 500;
}
.tagGrid {
display: grid;
grid-template-columns: repeat(2, 1fr);
gap: 8px;
}
.tag {
padding: 8px 12px;
border-radius: 16px;
color: white;
font-size: 12px;
text-align: center;
font-weight: 500;
}
.addConditionBtn {
width: 100%;
margin: 16px 0;
border-style: dashed;
border-color: #d9d9d9;
color: #666;
&:hover {
border-color: #1677ff;
color: #1677ff;
}
}
.generateBtn {
margin-top: 16px;
}

View File

@@ -0,0 +1,209 @@
import React, { useEffect, useState } from "react";
import { Card, Button } from "antd-mobile";
import { Select } from "antd";
import { EditSOutline } from "antd-mobile-icons";
import CustomConditionModal from "./CustomConditionModal";
import SchemeRecommendation from "./SchemeRecommendation";
import ConditionList from "./ConditionList";
import styles from "./AudienceFilter.module.scss";
import { getIndustryOptions, IndustryOption } from "../api";
interface FilterCondition {
id: string;
type: string;
label: string;
value: any;
operator?: string;
}
interface AudienceFilterProps {
conditions: FilterCondition[];
onChange: (conditions: FilterCondition[]) => void;
onGenerate: (users: any[]) => void;
}
const AudienceFilter: React.FC<AudienceFilterProps> = ({
conditions,
onChange,
onGenerate,
}) => {
const [showCustomModal, setShowCustomModal] = useState(false);
const [showSchemeModal, setShowSchemeModal] = useState(false);
const [industryOptions, setIndustryOptions] = useState<IndustryOption[]>([]);
const [selectedIndustry, setSelectedIndustry] = useState<
string | number | undefined
>(undefined);
// 加载行业选项(固定筛选项)
useEffect(() => {
getIndustryOptions()
.then(res => setIndustryOptions(res || []))
.catch(() => setIndustryOptions([]));
}, []);
const handleAddCondition = (condition: FilterCondition) => {
const newConditions = [...conditions, condition];
onChange(newConditions);
};
const handleRemoveCondition = (id: string) => {
const newConditions = conditions.filter(c => c.id !== id);
onChange(newConditions);
};
const handleUpdateCondition = (id: string, value: any) => {
const newConditions = conditions.map(c =>
c.id === id ? { ...c, value } : c,
);
onChange(newConditions);
};
const handleApplyScheme = (schemeConditions: FilterCondition[]) => {
onChange(schemeConditions);
setShowSchemeModal(false);
};
const handleGenerate = () => {
// 模拟生成用户数据
const mockUsers = generateMockUsers(conditions);
onGenerate(mockUsers);
};
return (
<div className={styles.container}>
<Card className={styles.card}>
<div className={styles.header}>
<div className={styles.title}></div>
<Button
size="small"
fill="outline"
onClick={() => setShowSchemeModal(true)}
className={styles.schemeBtn}
>
<EditSOutline />
</Button>
</div>
{/* 行业筛选(固定项,接口获取选项) */}
<div className={styles.section}>
<div className={styles.sectionTitle}></div>
<Select
style={{ width: "100%" }}
placeholder="选择行业"
value={selectedIndustry}
onChange={value => setSelectedIndustry(value)}
options={industryOptions.map(opt => ({
label: opt.label,
value: opt.value,
}))}
allowClear
/>
</div>
{/* 标签筛选 */}
<div className={styles.section}>
<div className={styles.sectionTitle}></div>
<div className={styles.tagGrid}>
{[
{ name: "高价值用户", color: "#1677ff" },
{ name: "新用户", color: "#52c41a" },
{ name: "活跃用户", color: "#faad14" },
{ name: "流失风险", color: "#eb2f96" },
{ name: "复购率高", color: "#722ed1" },
{ name: "高潜力", color: "#eb2f96" },
{ name: "已沉睡", color: "#bfbfbf" },
{ name: "价格敏感", color: "#13c2c2" },
].map((tag, index) => (
<div
key={index}
className={styles.tag}
style={{ backgroundColor: tag.color }}
>
{tag.name}
</div>
))}
</div>
</div>
{/* 自定义条件列表 */}
<ConditionList
conditions={conditions}
onRemove={handleRemoveCondition}
onUpdate={handleUpdateCondition}
/>
{/* 添加自定义条件 */}
<Button
fill="outline"
onClick={() => setShowCustomModal(true)}
className={styles.addConditionBtn}
>
+
</Button>
{/* 生成按钮 */}
<Button
color="primary"
block
onClick={() => {
// 将行业条件合并到自定义条件,便于统一生成
const mergedConditions =
selectedIndustry !== undefined
? [
...conditions,
{
id: `industry`,
type: "select",
label: "行业",
value: selectedIndustry,
},
]
: conditions;
const mockUsers = generateMockUsers(mergedConditions as any);
onGenerate(mockUsers);
}}
className={styles.generateBtn}
>
</Button>
</Card>
{/* 自定义条件弹窗 */}
<CustomConditionModal
visible={showCustomModal}
onClose={() => setShowCustomModal(false)}
onAdd={handleAddCondition}
/>
{/* 方案推荐弹窗 */}
<SchemeRecommendation
visible={showSchemeModal}
onClose={() => setShowSchemeModal(false)}
onApply={handleApplyScheme}
/>
</div>
);
};
// 模拟生成用户数据
const generateMockUsers = (conditions: FilterCondition[]) => {
const mockUsers = [];
const userCount = Math.floor(Math.random() * 1000) + 100; // 100-1100个用户
for (let i = 1; i <= userCount; i++) {
mockUsers.push({
id: `U${String(i).padStart(8, "0")}`,
name: `用户${i}`,
avatar: `https://api.dicebear.com/7.x/avataaars/svg?seed=${i}`,
tags: ["高价值用户", "活跃用户"],
rfmScore: Math.floor(Math.random() * 15) + 1,
lastActive: "7天内",
consumption: Math.floor(Math.random() * 5000) + 100,
});
}
return mockUsers;
};
export default AudienceFilter;

View File

@@ -0,0 +1,60 @@
.container {
padding: 0;
}
.card {
border-radius: 8px;
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.04);
}
.title {
font-size: 16px;
font-weight: 600;
color: #333;
margin-bottom: 20px;
}
.label {
color: #333;
font-size: 14px;
font-weight: 500;
}
.required {
color: #ff4d4f;
margin-left: 2px;
}
.input {
border: 1px solid #d9d9d9;
border-radius: 6px;
padding: 8px 12px;
font-size: 14px;
&:focus {
border-color: #1677ff;
box-shadow: 0 0 0 2px rgba(22, 119, 255, 0.1);
}
}
.textarea {
border: 1px solid #d9d9d9;
border-radius: 6px;
padding: 8px 12px;
font-size: 14px;
resize: vertical;
min-height: 80px;
&:focus {
border-color: #1677ff;
box-shadow: 0 0 0 2px rgba(22, 119, 255, 0.1);
}
}
:global(.adm-form-item) {
margin-bottom: 20px;
}
:global(.adm-form-item-label) {
margin-bottom: 8px;
}

View File

@@ -0,0 +1,65 @@
import React from "react";
import { Card, Form, Input } from "antd-mobile";
import styles from "./BasicInfo.module.scss";
interface BasicInfoProps {
data: {
name: string;
description: string;
remarks: string;
};
onChange: (data: any) => void;
}
const BasicInfo: React.FC<BasicInfoProps> = ({ data, onChange }) => {
const handleChange = (field: string, value: string) => {
onChange({ [field]: value });
};
return (
<div className={styles.container}>
<Card className={styles.card}>
<div className={styles.title}></div>
<Form layout="vertical">
<Form.Item
label={
<span className={styles.label}>
<span className={styles.required}>*</span>
</span>
}
required
>
<Input
placeholder="输入流量包名称"
value={data.name}
onChange={value => handleChange("name", value)}
className={styles.input}
/>
</Form.Item>
<Form.Item label={<span className={styles.label}></span>}>
<Input
placeholder="输入流量包描述"
value={data.description}
onChange={value => handleChange("description", value)}
className={styles.input}
/>
</Form.Item>
<Form.Item label={<span className={styles.label}></span>}>
<Input
placeholder="输入备注信息 (选填)"
value={data.remarks}
onChange={value => handleChange("remarks", value)}
className={styles.textarea}
rows={3}
/>
</Form.Item>
</Form>
</Card>
</div>
);
};
export default BasicInfo;

View File

@@ -0,0 +1,52 @@
.container {
margin-bottom: 24px;
}
.title {
font-size: 14px;
font-weight: 500;
color: #333;
margin-bottom: 12px;
}
.conditionList {
display: flex;
flex-direction: column;
gap: 8px;
}
.conditionItem {
display: flex;
justify-content: space-between;
align-items: center;
padding: 12px;
background: #f8f9fa;
border-radius: 6px;
border: 1px solid #e9ecef;
}
.conditionContent {
display: flex;
align-items: center;
gap: 8px;
}
.conditionLabel {
font-size: 14px;
color: #666;
}
.conditionValue {
font-size: 14px;
font-weight: 500;
color: #333;
}
.removeBtn {
color: #ff4d4f;
padding: 4px;
&:hover {
background-color: #fff2f0;
}
}

View File

@@ -0,0 +1,67 @@
import React from "react";
import { Button } from "antd-mobile";
import { DeleteOutline } from "antd-mobile-icons";
import styles from "./ConditionList.module.scss";
interface FilterCondition {
id: string;
type: string;
label: string;
value: any;
operator?: string;
}
interface ConditionListProps {
conditions: FilterCondition[];
onRemove: (id: string) => void;
onUpdate: (id: string, value: any) => void;
}
const ConditionList: React.FC<ConditionListProps> = ({
conditions,
onRemove,
onUpdate,
}) => {
const formatConditionValue = (condition: FilterCondition) => {
switch (condition.type) {
case "range":
return `${condition.value.min || 0}-${condition.value.max || 0}`;
case "select":
return condition.value;
default:
return condition.value;
}
};
if (conditions.length === 0) {
return null;
}
return (
<div className={styles.container}>
<div className={styles.title}></div>
<div className={styles.conditionList}>
{conditions.map(condition => (
<div key={condition.id} className={styles.conditionItem}>
<div className={styles.conditionContent}>
<span className={styles.conditionLabel}>{condition.label}:</span>
<span className={styles.conditionValue}>
{formatConditionValue(condition)}
</span>
</div>
<Button
size="small"
fill="none"
onClick={() => onRemove(condition.id)}
className={styles.removeBtn}
>
<DeleteOutline />
</Button>
</div>
))}
</div>
</div>
);
};
export default ConditionList;

View File

@@ -0,0 +1,80 @@
.container {
height: 100%;
display: flex;
flex-direction: column;
}
.header {
display: flex;
justify-content: space-between;
align-items: center;
padding: 16px;
border-bottom: 1px solid #f0f0f0;
}
.title {
font-size: 16px;
font-weight: 600;
color: #333;
}
.content {
flex: 1;
padding: 16px;
overflow-y: auto;
}
.section {
margin-bottom: 24px;
}
.sectionTitle {
font-size: 14px;
font-weight: 500;
color: #333;
margin-bottom: 12px;
}
.tagList {
display: grid;
grid-template-columns: repeat(2, 1fr);
gap: 8px;
}
.tagItem {
padding: 12px;
border: 1px solid #d9d9d9;
border-radius: 6px;
text-align: center;
font-size: 14px;
color: #333;
cursor: pointer;
transition: all 0.2s;
&:hover {
border-color: #1677ff;
color: #1677ff;
}
&.selected {
border-color: #1677ff;
background-color: #e6f7ff;
color: #1677ff;
}
}
.rangeInputs {
display: flex;
align-items: center;
gap: 12px;
}
.rangeSeparator {
color: #666;
font-weight: 500;
}
.footer {
padding: 16px;
border-top: 1px solid #f0f0f0;
}

View File

@@ -0,0 +1,242 @@
import React, { useState } from "react";
import { Popup, Form, Input, Selector, Button } from "antd-mobile";
import styles from "./CustomConditionModal.module.scss";
interface CustomConditionModalProps {
visible: boolean;
onClose: () => void;
onAdd: (condition: any) => void;
}
// 模拟标签数据
const mockTags = [
{ id: "age", name: "年龄层", type: "range", options: [] },
{
id: "consumption",
name: "消费能力",
type: "select",
options: [
{ label: "高", value: "high" },
{ label: "中", value: "medium" },
{ label: "低", value: "low" },
],
},
{
id: "gender",
name: "性别",
type: "select",
options: [
{ label: "男", value: "male" },
{ label: "女", value: "female" },
{ label: "未知", value: "unknown" },
],
},
{
id: "location",
name: "所在地区",
type: "select",
options: [
{ label: "厦门", value: "xiamen" },
{ label: "泉州", value: "quanzhou" },
{ label: "福州", value: "fuzhou" },
],
},
{
id: "source",
name: "客户来源",
type: "select",
options: [
{ label: "抖音", value: "douyin" },
{ label: "门店扫码", value: "store" },
{ label: "朋友推荐", value: "referral" },
{ label: "广告投放", value: "ad" },
],
},
{
id: "frequency",
name: "消费频率",
type: "select",
options: [
{ label: "高频(>3次/月)", value: "high" },
{ label: "中频", value: "medium" },
{ label: "低频", value: "low" },
],
},
{
id: "sensitivity",
name: "优惠敏感度",
type: "select",
options: [
{ label: "高", value: "high" },
{ label: "中", value: "medium" },
{ label: "低", value: "low" },
],
},
{
id: "category",
name: "品类偏好",
type: "select",
options: [
{ label: "护肤", value: "skincare" },
{ label: "茶饮", value: "tea" },
{ label: "宠物", value: "pet" },
{ label: "课程", value: "course" },
],
},
{
id: "repurchase",
name: "复购行为",
type: "select",
options: [
{ label: "有", value: "yes" },
{ label: "无", value: "no" },
],
},
{
id: "satisfaction",
name: "售后满意度",
type: "select",
options: [
{ label: "好评", value: "good" },
{ label: "一般", value: "average" },
{ label: "差评", value: "bad" },
],
},
];
const CustomConditionModal: React.FC<CustomConditionModalProps> = ({
visible,
onClose,
onAdd,
}) => {
const [selectedTag, setSelectedTag] = useState<any>(null);
const [conditionValue, setConditionValue] = useState<any>(null);
const handleTagSelect = (tag: any) => {
setSelectedTag(tag);
setConditionValue(null);
};
const handleValueChange = (value: any) => {
setConditionValue(value);
};
const handleSubmit = () => {
if (!selectedTag || !conditionValue) return;
const condition = {
id: `${selectedTag.id}_${Date.now()}`,
type: selectedTag.type,
label: selectedTag.name,
value: conditionValue,
};
onAdd(condition);
onClose();
setSelectedTag(null);
setConditionValue(null);
};
const renderValueInput = () => {
if (!selectedTag) return null;
switch (selectedTag.type) {
case "range":
return (
<div className={styles.rangeInputs}>
<Input
placeholder="最小年龄"
type="number"
onChange={value =>
setConditionValue(prev => ({ ...prev, min: value }))
}
/>
<span className={styles.rangeSeparator}>-</span>
<Input
placeholder="最大年龄"
type="number"
onChange={value =>
setConditionValue(prev => ({ ...prev, max: value }))
}
/>
</div>
);
case "select":
return (
<Selector
options={selectedTag.options}
value={conditionValue ? [conditionValue] : []}
onChange={value => handleValueChange(value[0])}
multiple={false}
/>
);
default:
return (
<Input
placeholder="请输入值"
value={conditionValue}
onChange={handleValueChange}
/>
);
}
};
return (
<Popup
visible={visible}
onMaskClick={onClose}
position="bottom"
bodyStyle={{ height: "70vh" }}
>
<div className={styles.container}>
<div className={styles.header}>
<div className={styles.title}></div>
<Button size="small" fill="none" onClick={onClose}>
</Button>
</div>
<div className={styles.content}>
<div className={styles.section}>
<div className={styles.sectionTitle}></div>
<div className={styles.tagList}>
{mockTags.map(tag => (
<div
key={tag.id}
className={`${styles.tagItem} ${
selectedTag?.id === tag.id ? styles.selected : ""
}`}
onClick={() => handleTagSelect(tag)}
>
{tag.name}
</div>
))}
</div>
</div>
{selectedTag && (
<div className={styles.section}>
<div className={styles.sectionTitle}></div>
{renderValueInput()}
</div>
)}
</div>
<div className={styles.footer}>
<Button
color="primary"
block
disabled={!selectedTag || !conditionValue}
onClick={handleSubmit}
>
</Button>
</div>
</div>
</Popup>
);
};
export default CustomConditionModal;

View File

@@ -0,0 +1,92 @@
.container {
height: 100%;
display: flex;
flex-direction: column;
}
.header {
display: flex;
justify-content: space-between;
align-items: center;
padding: 16px;
border-bottom: 1px solid #f0f0f0;
}
.title {
font-size: 16px;
font-weight: 600;
color: #333;
}
.content {
flex: 1;
padding: 16px;
overflow-y: auto;
}
.schemeList {
display: flex;
flex-direction: column;
gap: 16px;
}
.schemeCard {
border-radius: 8px;
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.04);
}
.schemeHeader {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 8px;
}
.schemeName {
font-size: 16px;
font-weight: 600;
color: #333;
}
.schemeBadge {
color: white;
padding: 4px 8px;
border-radius: 12px;
font-size: 12px;
font-weight: 500;
}
.schemeDescription {
font-size: 14px;
color: #666;
margin-bottom: 12px;
line-height: 1.4;
}
.schemeConditions {
margin-bottom: 16px;
}
.conditionsTitle {
font-size: 12px;
color: #999;
margin-bottom: 8px;
}
.conditionsList {
display: flex;
flex-wrap: wrap;
gap: 6px;
}
.conditionTag {
background: #f0f0f0;
color: #666;
padding: 2px 8px;
border-radius: 10px;
font-size: 12px;
}
.applyBtn {
width: 100%;
}

View File

@@ -0,0 +1,201 @@
import React from "react";
import { Popup, Card, Button } from "antd-mobile";
import styles from "./SchemeRecommendation.module.scss";
interface FilterCondition {
id: string;
type: string;
label: string;
value: any;
operator?: string;
}
interface SchemeRecommendationProps {
visible: boolean;
onClose: () => void;
onApply: (conditions: FilterCondition[]) => void;
}
// 预设方案数据
const presetSchemes = [
{
id: "high_value",
name: "高价值客户方案",
description: "针对高消费、高活跃度的优质客户",
conditions: [
{ id: "consumption_1", type: "select", label: "消费能力", value: "high" },
{ id: "frequency_1", type: "select", label: "消费频率", value: "high" },
{
id: "satisfaction_1",
type: "select",
label: "售后满意度",
value: "good",
},
],
userCount: 1250,
color: "#1677ff",
},
{
id: "new_user",
name: "新用户激活方案",
description: "针对新注册用户,提高首次消费转化",
conditions: [
{
id: "age_2",
type: "range",
label: "年龄层",
value: { min: 18, max: 35 },
},
{ id: "source_2", type: "select", label: "客户来源", value: "douyin" },
{ id: "frequency_2", type: "select", label: "消费频率", value: "low" },
],
userCount: 3200,
color: "#52c41a",
},
{
id: "retention",
name: "用户留存方案",
description: "针对有流失风险的客户,进行召回激活",
conditions: [
{ id: "frequency_3", type: "select", label: "消费频率", value: "low" },
{
id: "satisfaction_3",
type: "select",
label: "售后满意度",
value: "average",
},
{ id: "repurchase_3", type: "select", label: "复购行为", value: "no" },
],
userCount: 890,
color: "#faad14",
},
{
id: "upsell",
name: "升单转化方案",
description: "针对有升单潜力的客户,推荐高价值产品",
conditions: [
{
id: "consumption_4",
type: "select",
label: "消费能力",
value: "medium",
},
{ id: "frequency_4", type: "select", label: "消费频率", value: "medium" },
{
id: "category_4",
type: "select",
label: "品类偏好",
value: "skincare",
},
],
userCount: 1560,
color: "#722ed1",
},
{
id: "price_sensitive",
name: "价格敏感用户方案",
description: "针对对价格敏感的用户,提供优惠活动",
conditions: [
{
id: "sensitivity_5",
type: "select",
label: "优惠敏感度",
value: "high",
},
{ id: "consumption_5", type: "select", label: "消费能力", value: "low" },
{ id: "frequency_5", type: "select", label: "消费频率", value: "low" },
],
userCount: 2100,
color: "#eb2f96",
},
{
id: "loyal_customer",
name: "忠诚客户维护方案",
description: "针对高忠诚度客户提供VIP服务",
conditions: [
{ id: "frequency_6", type: "select", label: "消费频率", value: "high" },
{ id: "repurchase_6", type: "select", label: "复购行为", value: "yes" },
{
id: "satisfaction_6",
type: "select",
label: "售后满意度",
value: "good",
},
],
userCount: 680,
color: "#13c2c2",
},
];
const SchemeRecommendation: React.FC<SchemeRecommendationProps> = ({
visible,
onClose,
onApply,
}) => {
const handleApplyScheme = (scheme: any) => {
onApply(scheme.conditions);
};
return (
<Popup
visible={visible}
onMaskClick={onClose}
position="bottom"
bodyStyle={{ height: "80vh" }}
>
<div className={styles.container}>
<div className={styles.header}>
<div className={styles.title}></div>
<Button size="small" fill="none" onClick={onClose}>
</Button>
</div>
<div className={styles.content}>
<div className={styles.schemeList}>
{presetSchemes.map(scheme => (
<Card key={scheme.id} className={styles.schemeCard}>
<div className={styles.schemeHeader}>
<div className={styles.schemeName}>{scheme.name}</div>
<div
className={styles.schemeBadge}
style={{ backgroundColor: scheme.color }}
>
{scheme.userCount}
</div>
</div>
<div className={styles.schemeDescription}>
{scheme.description}
</div>
<div className={styles.schemeConditions}>
<div className={styles.conditionsTitle}></div>
<div className={styles.conditionsList}>
{scheme.conditions.map((condition, index) => (
<span key={index} className={styles.conditionTag}>
{condition.label}: {condition.value}
</span>
))}
</div>
</div>
<Button
size="small"
color="primary"
fill="outline"
onClick={() => handleApplyScheme(scheme)}
className={styles.applyBtn}
>
</Button>
</Card>
))}
</div>
</div>
</div>
</Popup>
);
};
export default SchemeRecommendation;

View File

@@ -0,0 +1,126 @@
.container {
padding: 0;
}
.card {
border-radius: 8px;
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.04);
}
.header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 16px;
}
.title {
font-size: 16px;
font-weight: 600;
color: #333;
}
.userCount {
font-size: 14px;
color: #1677ff;
font-weight: 500;
}
.batchActions {
display: flex;
justify-content: space-between;
align-items: center;
padding: 12px 0;
border-bottom: 1px solid #f0f0f0;
margin-bottom: 16px;
}
.selectAllCheckbox {
font-size: 14px;
color: #333;
}
.removeSelectedBtn {
font-size: 12px;
padding: 4px 8px;
height: 28px;
}
.userList {
display: flex;
flex-direction: column;
gap: 12px;
}
.userItem {
display: flex;
align-items: flex-start;
gap: 12px;
padding: 12px;
background: #f8f9fa;
border-radius: 8px;
border: 1px solid #e9ecef;
}
.userCheckbox {
margin-top: 4px;
}
.userAvatar {
flex-shrink: 0;
}
.userInfo {
flex: 1;
min-width: 0;
}
.userName {
font-size: 16px;
font-weight: 600;
color: #333;
margin-bottom: 4px;
}
.userId {
font-size: 12px;
color: #666;
margin-bottom: 8px;
}
.userTags {
display: flex;
flex-wrap: wrap;
gap: 4px;
margin-bottom: 8px;
}
.tag {
background: #e6f7ff;
color: #1677ff;
padding: 2px 6px;
border-radius: 8px;
font-size: 10px;
font-weight: 500;
}
.userStats {
display: flex;
flex-wrap: wrap;
gap: 12px;
}
.statItem {
font-size: 12px;
color: #666;
}
.removeBtn {
color: #ff4d4f;
padding: 4px;
flex-shrink: 0;
&:hover {
background-color: #fff2f0;
}
}

View File

@@ -0,0 +1,155 @@
import React, { useState } from "react";
import { Card, Avatar, Button, Checkbox, Empty } from "antd-mobile";
import { DeleteOutline } from "antd-mobile-icons";
import styles from "./UserListPreview.module.scss";
interface User {
id: string;
name: string;
avatar: string;
tags: string[];
rfmScore: number;
lastActive: string;
consumption: number;
}
interface UserListPreviewProps {
users: User[];
onRemoveUser: (userId: string) => void;
}
const UserListPreview: React.FC<UserListPreviewProps> = ({
users,
onRemoveUser,
}) => {
const [selectedUsers, setSelectedUsers] = useState<string[]>([]);
const handleSelectAll = (checked: boolean) => {
if (checked) {
setSelectedUsers(users.map(user => user.id));
} else {
setSelectedUsers([]);
}
};
const handleSelectUser = (userId: string, checked: boolean) => {
if (checked) {
setSelectedUsers(prev => [...prev, userId]);
} else {
setSelectedUsers(prev => prev.filter(id => id !== userId));
}
};
const handleRemoveSelected = () => {
selectedUsers.forEach(userId => onRemoveUser(userId));
setSelectedUsers([]);
};
const getRfmLevel = (score: number) => {
if (score >= 12) return { level: "高价值", color: "#ff4d4f" };
if (score >= 8) return { level: "中等价值", color: "#faad14" };
if (score >= 4) return { level: "低价值", color: "#52c41a" };
return { level: "潜在客户", color: "#bfbfbf" };
};
if (users.length === 0) {
return (
<div className={styles.container}>
<Card className={styles.card}>
<Empty description="暂无用户数据" />
</Card>
</div>
);
}
return (
<div className={styles.container}>
<Card className={styles.card}>
<div className={styles.header}>
<div className={styles.title}></div>
<div className={styles.userCount}> {users.length} </div>
</div>
{users.length > 0 && (
<div className={styles.batchActions}>
<Checkbox
checked={
selectedUsers.length === users.length && users.length > 0
}
onChange={handleSelectAll}
className={styles.selectAllCheckbox}
>
</Checkbox>
{selectedUsers.length > 0 && (
<Button
size="small"
color="danger"
fill="outline"
onClick={handleRemoveSelected}
className={styles.removeSelectedBtn}
>
({selectedUsers.length})
</Button>
)}
</div>
)}
<div className={styles.userList}>
{users.map(user => {
const rfmInfo = getRfmLevel(user.rfmScore);
return (
<div key={user.id} className={styles.userItem}>
<Checkbox
checked={selectedUsers.includes(user.id)}
onChange={checked => handleSelectUser(user.id, checked)}
className={styles.userCheckbox}
/>
<Avatar src={user.avatar} className={styles.userAvatar} />
<div className={styles.userInfo}>
<div className={styles.userName}>{user.name}</div>
<div className={styles.userId}>ID: {user.id}</div>
<div className={styles.userTags}>
{user.tags.map((tag, index) => (
<span key={index} className={styles.tag}>
{tag}
</span>
))}
</div>
<div className={styles.userStats}>
<span className={styles.statItem}>
RFM:{" "}
<span style={{ color: rfmInfo.color }}>
{rfmInfo.level}
</span>
</span>
<span className={styles.statItem}>
: {user.lastActive}
</span>
<span className={styles.statItem}>
: ¥{user.consumption}
</span>
</div>
</div>
<Button
size="small"
fill="none"
onClick={() => onRemoveUser(user.id)}
className={styles.removeBtn}
>
<DeleteOutline />
</Button>
</div>
);
})}
</div>
</Card>
</div>
);
};
export default UserListPreview;

View File

@@ -0,0 +1,49 @@
.tabsContainer {
background: #fff;
border-bottom: 1px solid #f0f0f0;
}
.tabs {
:global(.adm-tabs-header) {
border-bottom: none;
}
:global(.adm-tabs-tab) {
font-size: 14px;
padding: 12px 16px;
}
:global(.adm-tabs-tab-active) {
color: #1677ff;
font-weight: 500;
}
}
.content {
padding: 16px;
min-height: calc(100vh - 200px);
}
.footer {
padding: 16px;
background: #fff;
border-top: 1px solid #f0f0f0;
}
.buttonGroup {
display: flex;
gap: 12px;
align-items: center;
}
.prevButton {
flex: 1;
}
.nextButton {
flex: 1;
}
.submitButton {
flex: 1;
}

View File

@@ -0,0 +1,180 @@
import React, { useState } from "react";
import { Button } from "antd-mobile";
import Layout from "@/components/Layout/Layout";
import NavCommon from "@/components/NavCommon";
import BasicInfo from "./components/BasicInfo";
import AudienceFilter from "./components/AudienceFilter";
import UserListPreview from "./components/UserListPreview";
import styles from "./index.module.scss";
import StepIndicator from "@/components/StepIndicator";
const CreateTrafficPackage: React.FC = () => {
const [currentStep, setCurrentStep] = useState(1); // 1 基础信息 2 人群筛选 3 用户列表
const [formData, setFormData] = useState({
// 基本信息
name: "",
description: "",
remarks: "",
// 筛选条件
filterConditions: [],
// 用户列表
filteredUsers: [],
});
const steps = [
{ id: 1, title: "basic", subtitle: "基本信息" },
{ id: 2, title: "filter", subtitle: "人群筛选" },
{ id: 3, title: "users", subtitle: "预览" },
];
const handleBasicInfoChange = (data: any) => {
setFormData(prev => ({ ...prev, ...data }));
};
const handleFilterChange = (conditions: any[]) => {
setFormData(prev => ({ ...prev, filterConditions: conditions }));
};
const handleGenerateUsers = (users: any[]) => {
setFormData(prev => ({ ...prev, filteredUsers: users }));
setCurrentStep(3);
};
// 初始化模拟数据
React.useEffect(() => {
if (currentStep === 3 && formData.filteredUsers.length === 0) {
const mockUsers = [
{
id: "U00000001",
name: "张三",
avatar: "https://api.dicebear.com/7.x/avataaars/svg?seed=1",
tags: ["高价值用户", "活跃用户"],
rfmScore: 12,
lastActive: "7天内",
consumption: 2500,
},
{
id: "U00000002",
name: "李四",
avatar: "https://api.dicebear.com/7.x/avataaars/svg?seed=2",
tags: ["新用户", "价格敏感"],
rfmScore: 6,
lastActive: "3天内",
consumption: 800,
},
{
id: "U00000003",
name: "王五",
avatar: "https://api.dicebear.com/7.x/avataaars/svg?seed=3",
tags: ["复购率高", "高潜力"],
rfmScore: 14,
lastActive: "1天内",
consumption: 3200,
},
{
id: "U00000004",
name: "赵六",
avatar: "https://api.dicebear.com/7.x/avataaars/svg?seed=4",
tags: ["已沉睡", "流失风险"],
rfmScore: 3,
lastActive: "30天内",
consumption: 200,
},
{
id: "U00000005",
name: "钱七",
avatar: "https://api.dicebear.com/7.x/avataaars/svg?seed=5",
tags: ["高价值用户", "复购率高"],
rfmScore: 15,
lastActive: "2天内",
consumption: 4500,
},
];
setFormData(prev => ({ ...prev, filteredUsers: mockUsers }));
}
}, [currentStep, formData.filteredUsers.length]);
const handleSubmit = () => {
// 提交逻辑
console.log("提交数据:", formData);
};
const canSubmit = formData.name && formData.filterConditions.length > 0;
const renderFooter = () => {
return (
<div className={styles.footer}>
<div className={styles.buttonGroup}>
{currentStep > 1 && (
<Button
className={styles.prevButton}
onClick={() => setCurrentStep(s => Math.max(1, s - 1))}
>
</Button>
)}
{currentStep < 3 ? (
<Button
color="primary"
className={styles.nextButton}
onClick={() => setCurrentStep(s => Math.min(3, s + 1))}
>
</Button>
) : (
<Button
color="primary"
className={styles.submitButton}
disabled={!canSubmit}
onClick={handleSubmit}
>
</Button>
)}
</div>
</div>
);
};
return (
<Layout
header={
<>
<NavCommon title="新建流量包" />
<StepIndicator currentStep={currentStep} steps={steps} />
</>
}
footer={renderFooter()}
>
<div className={styles.content}>
{currentStep === 1 && (
<BasicInfo data={formData} onChange={handleBasicInfoChange} />
)}
{currentStep === 2 && (
<AudienceFilter
conditions={formData.filterConditions}
onChange={handleFilterChange}
onGenerate={handleGenerateUsers}
/>
)}
{currentStep === 3 && (
<UserListPreview
users={formData.filteredUsers}
onRemoveUser={userId => {
setFormData(prev => ({
...prev,
filteredUsers: prev.filteredUsers.filter(
(user: any) => user.id !== userId,
),
}));
}}
/>
)}
</div>
</Layout>
);
};
export default CreateTrafficPackage;

View File

@@ -1,34 +0,0 @@
import request from "@/api/request";
// 获取流量池列表
export function fetchTrafficPoolList(params: {
page?: number;
pageSize?: number;
keyword?: string;
}) {
return request("/v1/traffic/pool", params, "GET");
}
export async function fetchScenarioOptions() {
return request("/v1/plan/scenes", {}, "GET");
}
export async function fetchPackageOptions() {
return request("/v1/traffic/pool/getPackage", {}, "GET");
}
export async function addPackage(params: {
type: string; // 类型 1搜索 2选择用户 3文件上传
addPackageId?: number;
addStatus?: number;
deviceId?: string;
keyword?: string;
packageId?: number;
packageName?: number; // 添加的流量池名称
tableFile?: number;
taskId?: number; // 任务id j及场景获客id
userIds?: number[];
userValue?: number;
}) {
return request("/v1/traffic/pool/addPackage", params, "POST");
}

View File

@@ -1,65 +0,0 @@
.listWrap {
padding: 12px;
}
.cardContent {
display: flex;
align-items: center;
gap: 12px;
position: relative;
}
.checkbox {
position: absolute;
top: 0;
left: 0;
}
.cardWrap {
background: #fff;
padding: 16px;
border-radius: 8px;
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.04);
margin-bottom: 12px;
}
.card {
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

@@ -24,3 +24,8 @@ export async function getPackage(params: {
}): Promise<PackageList> {
return request("/v1/traffic/pool/getPackage", params, "GET");
}
// 删除数据包
export async function deletePackage(id: number): Promise<{ success: boolean }> {
return request("/v1/traffic/pool/deletePackage", { id }, "POST");
}

View File

@@ -14,10 +14,33 @@
}
.cardBody {
padding: 16px;
padding: 16px 16px 16px 16px;
display: flex;
align-items: flex-start;
gap: 12px;
position: relative;
}
/* 三点菜单按钮 */
.menuButton {
position: absolute;
right: 8px;
top: 8px;
z-index: 10;
background: rgba(255, 255, 255, 0.9);
border: 1px solid #f0f0f0;
border-radius: 4px;
padding: 4px 8px;
font-size: 14px;
color: #666;
cursor: pointer;
transition: all 0.2s;
&:hover {
background: #fff;
border-color: #d9d9d9;
color: #333;
}
}
/* 左侧图片区域 */

View File

@@ -4,13 +4,14 @@ import {
SearchOutlined,
ReloadOutlined,
PlusOutlined,
MoreOutlined,
} from "@ant-design/icons";
import { Input, Button, Pagination, Card } from "antd";
import { Input, Button, Pagination, Dropdown, message } from "antd";
import styles from "./index.module.scss";
import { Empty } from "antd-mobile";
import { useNavigate } from "react-router-dom";
import NavCommon from "@/components/NavCommon";
import { getPackage } from "./api";
import { getPackage, deletePackage } from "./api";
import type { Package, PackageList } from "./api";
// 分组图标映射
@@ -81,6 +82,19 @@ const TrafficPoolList: React.FC = () => {
fetchData();
};
const handleDelete = async (id: number, name: string) => {
try {
// eslint-disable-next-line no-alert
if (!confirm(`确认删除数据包“${name}”吗?`)) return;
await deletePackage(id);
message.success("已删除");
handleRefresh();
} catch (e) {
console.error(e);
message.error("删除失败");
}
};
useEffect(() => {
const fetchData = async () => {
setLoading(true);
@@ -117,8 +131,7 @@ const TrafficPoolList: React.FC = () => {
size="small"
icon={<PlusOutlined />}
onClick={() => {
// 新建分组逻辑
console.log("新建分组");
navigate("/mine/traffic-pool/create");
}}
>
@@ -164,64 +177,94 @@ const TrafficPoolList: React.FC = () => {
) : (
<div>
{list.map(item => (
<div
key={item.id}
className={styles.cardCompact}
onClick={() => {
navigate(`/mine/traffic-pool/info/${item.id}`);
}}
>
<div key={item.id} className={styles.cardCompact}>
<div className={styles.cardBody}>
{/* 左侧图片区域(优先展示 pic缺省时使用假头像 */}
<div
className={styles.imageBox}
style={{ background: getGroupColor(item.type) }}
className={styles.menuButton}
onClick={e => e.stopPropagation()}
>
{item.pic ? (
<img
src={item.pic}
alt={item.name}
style={{
width: "100%",
height: "100%",
objectFit: "cover",
}}
/>
) : (
<span
style={{
fontSize: 24,
fontWeight: "bold",
color: "#333",
}}
>
{getGroupIcon(item.type, item.name)}
</span>
)}
<Dropdown
menu={{
items: [
{
key: "preview",
label: "预览用户",
onClick: () =>
navigate(
`/mine/traffic-pool/userList/${item.id}`,
),
},
{
key: "delete",
danger: true,
label: "删除数据包",
onClick: () => handleDelete(item.id, item.name),
},
],
}}
trigger={["click"]}
>
<MoreOutlined />
</Dropdown>
</div>
{/* 右侧仅展示选中字段 */}
<div className={styles.contentArea}>
{/* 标题与人数 */}
<div className={styles.titleRow}>
<div className={styles.title}>{item.name}</div>
<div className={styles.timeTag}>{item.num}</div>
<div
style={{ display: "flex", gap: 10 }}
onClick={() =>
navigate(`/mine/traffic-pool/userList/${item.id}`)
}
>
{/* 左侧图片区域(优先展示 pic缺省时使用假头像 */}
<div
className={styles.imageBox}
style={{ background: getGroupColor(item.type) }}
>
{item.pic ? (
<img
src={item.pic}
alt={item.name}
style={{
width: "100%",
height: "100%",
objectFit: "cover",
}}
/>
) : (
<span
style={{
fontSize: 24,
fontWeight: "bold",
color: "#333",
}}
>
{getGroupIcon(item.type, item.name)}
</span>
)}
</div>
{/* RFM 汇总 */}
<div className={styles.ratingRow}>
<span className={styles.rating}>RFM{item.RFM}</span>
<span className={styles.sales}>
R{item.R} F{item.F} M{item.M}
</span>
</div>
{/* 右侧仅展示选中字段 */}
<div className={styles.contentArea}>
{/* 标题与人数 */}
<div className={styles.titleRow}>
<div className={styles.title}>{item.name}</div>
<div className={styles.timeTag}>{item.num}</div>
</div>
{/* 类型与创建时间 */}
<div className={styles.deliveryInfo}>
<span>
: {item.type === 0 ? "自定义" : "系统分组"}
</span>
<span>:{item.createTime}</span>
{/* RFM 汇总 */}
<div className={styles.ratingRow}>
<span className={styles.rating}>RFM{item.RFM}</span>
<span className={styles.sales}>
R{item.R} F{item.F} M{item.M}
</span>
</div>
{/* 类型与创建时间 */}
<div className={styles.deliveryInfo}>
<span>
: {item.type === 0 ? "自定义" : "系统分组"}
</span>
<span>:{item.createTime || "-"}</span>
</div>
</div>
</div>
</div>

View File

@@ -0,0 +1,11 @@
import request from "@/api/request";
// 获取流量包用户列表
export function fetchTrafficPoolList(params: {
page?: number;
pageSize?: number;
keyword?: string;
packageId?: string;
}) {
return request("/v1/traffic/pool/users", params, "GET");
}

View File

@@ -25,27 +25,3 @@ export interface TrafficPoolUserListResponse {
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";
// 获客场景类型
export interface ScenarioOption {
id: string;
name: string;
}

View File

@@ -0,0 +1,40 @@
.listWrap {
padding: 16px;
}
.cardWrap {
margin-bottom: 12px;
}
.card {
background: #fff;
border-radius: 8px;
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.04);
overflow: hidden;
transition: all 0.2s;
&:hover {
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.08);
}
}
.cardContent {
display: flex;
align-items: flex-start;
padding: 16px;
gap: 12px;
}
.title {
font-size: 16px;
font-weight: 600;
color: #333;
margin-bottom: 8px;
}
.desc {
font-size: 14px;
color: #666;
margin-bottom: 4px;
line-height: 1.4;
}

View File

@@ -1,19 +1,22 @@
import React, { useEffect, useState } from "react";
import React, { useCallback, useEffect, useState } from "react";
import { useParams, useNavigate } from "react-router-dom";
import Layout from "@/components/Layout/Layout";
import { SearchOutlined, ReloadOutlined } from "@ant-design/icons";
import { Input, Button, Pagination } from "antd";
import styles from "./index.module.scss";
import { Empty, Avatar } from "antd-mobile";
import { useNavigate } from "react-router-dom";
import NavCommon from "@/components/NavCommon";
import { fetchTrafficPoolList } from "./api";
import type { TrafficPoolUser } from "./data";
const defaultAvatar =
"https://cdn.jsdelivr.net/gh/maokaka/static/avatar-default.png";
const TrafficPoolList: React.FC = () => {
const TrafficPoolUserList: React.FC = () => {
const { id } = useParams<{ id: string }>();
const navigate = useNavigate();
// 基础状态
const [loading, setLoading] = useState(false);
const [list, setList] = useState<TrafficPoolUser[]>([]);
const [page, setPage] = useState(1);
@@ -21,53 +24,80 @@ const TrafficPoolList: React.FC = () => {
const [total, setTotal] = useState(0);
const [search, setSearch] = useState("");
const handleSearch = (value: string) => {
setSearch(value);
setPage(1);
// 获取列表
const getList = async (customParams?: any) => {
setLoading(true);
try {
const params: any = {
page,
pageSize,
keyword: search,
packageId: id, // 根据流量包ID筛选用户
...customParams, // 允许传入自定义参数覆盖
};
const res = await fetchTrafficPoolList(params);
setList(res.list || []);
setTotal(res.total || 0);
} catch (error) {
// 忽略请求过于频繁的错误,避免页面崩溃
if (error !== "请求过于频繁,请稍后再试") {
console.error("获取列表失败:", error);
}
} finally {
setLoading(false);
}
};
// 搜索防抖处理
const [searchInput, setSearchInput] = useState(search);
const debouncedSearch = useCallback(() => {
const timer = setTimeout(() => {
setSearch(searchInput);
// 搜索时重置到第一页并请求列表
setPage(1);
getList({ keyword: searchInput, page: 1 });
}, 500); // 500ms 防抖延迟
return () => clearTimeout(timer);
}, [searchInput]);
useEffect(() => {
const fetchData = async () => {
setLoading(true);
try {
const params = {
page,
pageSize,
keyword: search,
};
const cleanup = debouncedSearch();
return cleanup;
}, [debouncedSearch]);
const res = await fetchTrafficPoolList(params);
setList(res.list || []);
setTotal(res.total || 0);
} catch (error) {
console.error("获取列表失败:", error);
} finally {
setLoading(false);
}
};
const handSearch = (value: string) => {
setSearchInput(value);
debouncedSearch();
};
fetchData();
}, [page, pageSize, search]);
// 初始加载和参数变化时重新获取数据
useEffect(() => {
getList();
}, [page, pageSize, search, id]);
return (
<Layout
loading={loading}
header={
<>
<NavCommon title="流量池用户列表" />
<NavCommon title="用户列表" />
{/* 搜索栏 */}
<div className="search-bar">
<div className="search-input-wrapper">
<Input
placeholder="搜索用户"
value={search}
onChange={e => handleSearch(e.target.value)}
value={searchInput}
onChange={e => handSearch(e.target.value)}
prefix={<SearchOutlined />}
allowClear
size="large"
/>
</div>
<Button
onClick={() => setPage(1)}
onClick={() => getList()}
loading={loading}
size="large"
icon={<ReloadOutlined />}
@@ -82,14 +112,17 @@ const TrafficPoolList: React.FC = () => {
pageSize={pageSize}
total={total}
showSizeChanger={false}
onChange={setPage}
onChange={newPage => {
setPage(newPage);
getList({ page: newPage });
}}
/>
</div>
}
>
<div className={styles.listWrap}>
{list.length === 0 && !loading ? (
<Empty description="暂无数据" />
<Empty description="暂无用户数据" />
) : (
<div>
{list.map(item => (
@@ -139,4 +172,4 @@ const TrafficPoolList: React.FC = () => {
);
};
export default TrafficPoolList;
export default TrafficPoolUserList;

View File

@@ -2,8 +2,9 @@ import Mine from "@/pages/mobile/mine/main/index";
import Devices from "@/pages/mobile/mine/devices/index";
import DeviceDetail from "@/pages/mobile/mine/devices/DeviceDetail";
import TrafficPool from "@/pages/mobile/mine/traffic-pool/list/index";
import TrafficPoolItem from "@/pages/mobile/mine/traffic-pool/info/index";
import TrafficPoolDetail from "@/pages/mobile/mine/traffic-pool/detail/index";
import TrafficPool2 from "@/pages/mobile/mine/traffic-pool/poolList1/index";
import TrafficPoolUserList from "@/pages/mobile/mine/traffic-pool/userList/index";
import CreateTrafficPackage from "@/pages/mobile/mine/traffic-pool/form/index";
import WechatAccounts from "@/pages/mobile/mine/wechat-accounts/list/index";
import WechatAccountDetail from "@/pages/mobile/mine/wechat-accounts/detail/index";
import Recharge from "@/pages/mobile/mine/recharge/index";
@@ -13,7 +14,6 @@ import SecuritySetting from "@/pages/mobile/mine/setting/SecuritySetting";
import About from "@/pages/mobile/mine/setting/About";
import Privacy from "@/pages/mobile/mine/setting/Privacy";
import UserSetting from "@/pages/mobile/mine/setting/UserSetting";
const routes = [
{
path: "/mine",
@@ -36,16 +36,20 @@ const routes = [
element: <TrafficPool />,
auth: true,
},
//流量池详情页面
{
path: "/mine/traffic-pool/info/:id",
element: <TrafficPoolItem />,
path: "/mine/traffic-pool/list2",
element: <TrafficPool2 />,
auth: true,
},
//流量池列表详情页面
//新建流量包页面
{
path: "/mine/traffic-pool/detail/:wxid/:userId",
element: <TrafficPoolDetail />,
path: "/mine/traffic-pool/create",
element: <CreateTrafficPackage />,
auth: true,
},
{
path: "/mine/traffic-pool/userList/:id",
element: <TrafficPoolUserList />,
auth: true,
},