移除流量池详情及相关组件:删除流量池详情页面、API、数据类型和样式文件,简化代码结构,提升可维护性和用户体验。
This commit is contained in:
@@ -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");
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
101
Cunkebao/src/pages/mobile/mine/traffic-pool/form/README.md
Normal file
101
Cunkebao/src/pages/mobile/mine/traffic-pool/form/README.md
Normal 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管理复杂状态
|
||||
- **用户体验**:提供丰富的交互反馈
|
||||
- **数据模拟**:包含完整的模拟数据用于演示
|
||||
|
||||
## 扩展性
|
||||
|
||||
- 支持添加新的标签类型
|
||||
- 支持添加新的预设方案
|
||||
- 支持自定义筛选逻辑
|
||||
- 支持导出用户列表
|
||||
- 支持批量操作功能
|
||||
75
Cunkebao/src/pages/mobile/mine/traffic-pool/form/api.ts
Normal file
75
Cunkebao/src/pages/mobile/mine/traffic-pool/form/api.ts
Normal 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");
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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;
|
||||
@@ -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%;
|
||||
}
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
}
|
||||
180
Cunkebao/src/pages/mobile/mine/traffic-pool/form/index.tsx
Normal file
180
Cunkebao/src/pages/mobile/mine/traffic-pool/form/index.tsx
Normal 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;
|
||||
@@ -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");
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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");
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
/* 左侧图片区域 */
|
||||
|
||||
@@ -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>
|
||||
|
||||
11
Cunkebao/src/pages/mobile/mine/traffic-pool/userList/api.ts
Normal file
11
Cunkebao/src/pages/mobile/mine/traffic-pool/userList/api.ts
Normal 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");
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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;
|
||||
@@ -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,
|
||||
},
|
||||
|
||||
|
||||
Reference in New Issue
Block a user