存客宝新版流量池提交
This commit is contained in:
@@ -2,24 +2,37 @@ import request from "@/api/request";
|
||||
|
||||
// 请求参数接口
|
||||
export interface Request {
|
||||
keyword: string;
|
||||
keyword?: string;
|
||||
/**
|
||||
* 条数
|
||||
*/
|
||||
limit: string;
|
||||
limit?: string;
|
||||
pageSize?: string;
|
||||
/**
|
||||
* 分页
|
||||
*/
|
||||
page: string;
|
||||
page?: string;
|
||||
[property: string]: any;
|
||||
}
|
||||
|
||||
// 获取流量池包列表
|
||||
// ===== V2 API =====
|
||||
|
||||
/**
|
||||
* 获取流量池分组列表 (V2)
|
||||
* 用于获取流量池包/分组列表
|
||||
*/
|
||||
export function getPoolPackages(params: Request) {
|
||||
return request("/v1/traffic/pool/getPackage", params, "GET");
|
||||
const v2Params = {
|
||||
page: params.page ? Number(params.page) : 1,
|
||||
pageSize: params.limit ? Number(params.limit) : (params.pageSize ? Number(params.pageSize) : 20),
|
||||
keyword: params.keyword || "",
|
||||
};
|
||||
return request("/v1/traffic/pool/v2/groups", v2Params, "GET");
|
||||
}
|
||||
|
||||
// 保留原接口以兼容现有代码
|
||||
/**
|
||||
* 获取流量池用户列表 (V2)
|
||||
*/
|
||||
export function getPoolList(params: {
|
||||
page?: string;
|
||||
pageSize?: string;
|
||||
@@ -27,8 +40,46 @@ export function getPoolList(params: {
|
||||
addStatus?: string;
|
||||
deviceId?: string;
|
||||
packageId?: string;
|
||||
groupId?: string;
|
||||
userValue?: string;
|
||||
[property: string]: any;
|
||||
}) {
|
||||
return request("/v1/traffic/pool", params, "GET");
|
||||
const v2Params = {
|
||||
page: params.page ? Number(params.page) : 1,
|
||||
pageSize: params.pageSize ? Number(params.pageSize) : 20,
|
||||
keyword: params.keyword || "",
|
||||
groupId: params.groupId || params.packageId || "",
|
||||
};
|
||||
return request("/v1/traffic/pool/v2/group/members", v2Params, "GET");
|
||||
}
|
||||
|
||||
/**
|
||||
* 删除流量池分组 (V2)
|
||||
*/
|
||||
export function deletePackage(id: number | string) {
|
||||
return request("/v1/traffic/pool/v2/group/delete", { groupId: id }, "DELETE");
|
||||
}
|
||||
|
||||
/**
|
||||
* 创建流量池分组 (V2)
|
||||
*/
|
||||
export function createPackage(params: {
|
||||
name: string;
|
||||
description?: string;
|
||||
ruleType?: number;
|
||||
ruleConfig?: any;
|
||||
}) {
|
||||
return request("/v1/traffic/pool/v2/group/create", {
|
||||
groupName: params.name,
|
||||
description: params.description || "",
|
||||
ruleType: params.ruleType || 0,
|
||||
ruleConfig: params.ruleConfig || null,
|
||||
}, "POST");
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取流量池分组详情 (V2)
|
||||
*/
|
||||
export function getPackageDetail(groupId: number | string) {
|
||||
return request("/v1/traffic/pool/v2/group/detail", { groupId }, "GET");
|
||||
}
|
||||
|
||||
@@ -41,7 +41,7 @@ export default function SelectionPopup({
|
||||
PoolSelectionItem[]
|
||||
>([]);
|
||||
|
||||
// 获取流量池包列表API
|
||||
// 获取流量池分组列表API (V2)
|
||||
const fetchPoolPackages = async (page: number, keyword: string = "") => {
|
||||
setLoading(true);
|
||||
try {
|
||||
@@ -52,13 +52,44 @@ export default function SelectionPopup({
|
||||
};
|
||||
|
||||
const response = await getPoolPackages(params);
|
||||
if (response && response.list) {
|
||||
setPoolPackages(response.list);
|
||||
setTotalItems(response.total || 0);
|
||||
setTotalPages(Math.ceil((response.total || 0) / 20));
|
||||
console.log("getPoolPackages response:", response);
|
||||
// request 函数会自动提取 data 字段,所以 response 应该是数组
|
||||
// 但为了兼容,也处理可能的包装格式
|
||||
let groupsList: any[] = [];
|
||||
if (Array.isArray(response)) {
|
||||
groupsList = response;
|
||||
} else if (response?.data && Array.isArray(response.data)) {
|
||||
groupsList = response.data;
|
||||
} else if (response?.list && Array.isArray(response.list)) {
|
||||
groupsList = response.list;
|
||||
}
|
||||
console.log("groupsList:", groupsList);
|
||||
|
||||
if (groupsList.length > 0) {
|
||||
// 适配 V2 API 返回格式
|
||||
const formattedList = groupsList.map((item: any) => ({
|
||||
id: item.id,
|
||||
name: item.groupName || item.name || `分组${item.id}`,
|
||||
description: item.description || "",
|
||||
createTime: item.createTime
|
||||
? (typeof item.createTime === 'number'
|
||||
? new Date(item.createTime * 1000).toLocaleString('zh-CN')
|
||||
: String(item.createTime).split(' ')[0])
|
||||
: "",
|
||||
num: item.memberCount || item.num || 0,
|
||||
isSystem: item.isSystem,
|
||||
ruleType: item.ruleType,
|
||||
}));
|
||||
setPoolPackages(formattedList);
|
||||
setTotalItems(formattedList.length);
|
||||
setTotalPages(Math.ceil(formattedList.length / 20));
|
||||
} else {
|
||||
setPoolPackages([]);
|
||||
setTotalItems(0);
|
||||
setTotalPages(1);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("获取流量池包列表失败:", error);
|
||||
console.error("获取流量池分组列表失败:", error);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
|
||||
38
Cunkebao/src/components/StepIndicator/index.module.scss
Normal file
38
Cunkebao/src/components/StepIndicator/index.module.scss
Normal file
@@ -0,0 +1,38 @@
|
||||
.container {
|
||||
padding: 20px 30px 12px;
|
||||
background: #fff;
|
||||
}
|
||||
|
||||
.steps {
|
||||
--adm-color-primary: #007aff;
|
||||
}
|
||||
|
||||
:global(.adm-steps-item-title) {
|
||||
font-size: 13px;
|
||||
font-weight: 600;
|
||||
color: #9ca3af;
|
||||
margin-top: 4px;
|
||||
}
|
||||
|
||||
:global(.adm-steps-item-active .adm-steps-item-title) {
|
||||
color: #111827;
|
||||
}
|
||||
|
||||
:global(.adm-steps-item-icon) {
|
||||
width: 24px;
|
||||
height: 24px;
|
||||
font-size: 13px;
|
||||
line-height: 24px;
|
||||
border-width: 2px;
|
||||
}
|
||||
|
||||
:global(.adm-steps-item-active .adm-steps-item-icon) {
|
||||
background-color: #007aff;
|
||||
border-color: #007aff;
|
||||
box-shadow: 0 0 0 4px rgba(0, 122, 255, 0.15);
|
||||
}
|
||||
|
||||
:global(.adm-steps-item-finish .adm-steps-item-icon) {
|
||||
background-color: #007aff;
|
||||
border-color: #007aff;
|
||||
}
|
||||
@@ -1,5 +1,6 @@
|
||||
import React from "react";
|
||||
import { Steps } from "antd-mobile";
|
||||
import styles from "./index.module.scss";
|
||||
|
||||
interface StepIndicatorProps {
|
||||
currentStep: number;
|
||||
@@ -11,28 +12,13 @@ const StepIndicator: React.FC<StepIndicatorProps> = ({
|
||||
steps,
|
||||
}) => {
|
||||
return (
|
||||
<div style={{ overflowX: "auto", padding: "30px 0px", background: "#fff" }}>
|
||||
<Steps current={currentStep - 1}>
|
||||
{steps.map((step, idx) => (
|
||||
<div className={styles.container}>
|
||||
<Steps current={currentStep - 1} className={styles.steps}>
|
||||
{steps.map((step) => (
|
||||
<Steps.Step
|
||||
key={step.id}
|
||||
title={step.subtitle}
|
||||
icon={
|
||||
<div
|
||||
style={{
|
||||
width: 24,
|
||||
height: 24,
|
||||
borderRadius: 12,
|
||||
backgroundColor: idx < currentStep ? "#1677ff" : "#cccccc",
|
||||
color: "#fff",
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
justifyContent: "center",
|
||||
}}
|
||||
>
|
||||
{step.id}
|
||||
</div>
|
||||
}
|
||||
className={styles.step}
|
||||
/>
|
||||
))}
|
||||
</Steps>
|
||||
|
||||
@@ -1,13 +1,23 @@
|
||||
import { getPoolDetail, getPoolTags, addTag, removeTag, updatePool } from "../api";
|
||||
import { getPoolTags, addTag, removeTag, updatePool } from "../api";
|
||||
import request from "@/api/request";
|
||||
|
||||
export { getPoolDetail, getPoolTags, addTag, removeTag, updatePool };
|
||||
export { getPoolTags, addTag, removeTag, updatePool };
|
||||
|
||||
// 获取用户详情(根据 poolCompanyId)
|
||||
export async function fetchUserDetail(id: number) {
|
||||
return getPoolDetail(id);
|
||||
return request("/v1/traffic/pool/v2/detail", { id }, "GET", {
|
||||
timeout: 0, // 去除超时限制
|
||||
}, 0); // debounceGap 设置为 0,避免防抖拦截
|
||||
}
|
||||
|
||||
// 更新用户信息
|
||||
export async function updateUserInfo(id: number, data: any) {
|
||||
return updatePool(id, data);
|
||||
}
|
||||
|
||||
// 更新用户RFM评分
|
||||
export async function updateRfm(identifier: string) {
|
||||
return request("/v1/traffic/pool/v2/calculate-rfm", { identifier }, "POST", {
|
||||
timeout: 0, // 去除超时限制
|
||||
});
|
||||
}
|
||||
|
||||
@@ -1,522 +1,313 @@
|
||||
.detailWrap {
|
||||
padding: 12px;
|
||||
background: #f5f5f5;
|
||||
.container {
|
||||
min-height: 100vh;
|
||||
background-color: #f6f7f9;
|
||||
padding-bottom: 40px;
|
||||
}
|
||||
|
||||
.section {
|
||||
.loadingContainer {
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
min-height: 200px;
|
||||
}
|
||||
|
||||
// 头部核心卡片
|
||||
.headerCard {
|
||||
background: linear-gradient(135deg, #1677ff 0%, #4096ff 100%);
|
||||
padding: 32px 16px 48px;
|
||||
color: #fff;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 16px;
|
||||
|
||||
.avatar {
|
||||
border: 3px solid rgba(255, 255, 255, 0.3);
|
||||
border-radius: 50%;
|
||||
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.1);
|
||||
}
|
||||
|
||||
.userInfo {
|
||||
flex: 1;
|
||||
|
||||
.nickname {
|
||||
font-size: 22px;
|
||||
font-weight: 600;
|
||||
margin-bottom: 6px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.tags {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 6px;
|
||||
margin-top: 8px;
|
||||
|
||||
.levelBadge {
|
||||
background: rgba(255, 255, 255, 0.2);
|
||||
padding: 2px 8px;
|
||||
border-radius: 4px;
|
||||
font-size: 12px;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 通用卡片容器
|
||||
.card {
|
||||
margin: -24px 12px 16px;
|
||||
background: #fff;
|
||||
border-radius: 12px;
|
||||
margin-bottom: 12px;
|
||||
overflow: hidden;
|
||||
border-radius: 16px;
|
||||
padding: 20px;
|
||||
box-shadow: 0 4px 16px rgba(0, 0, 0, 0.04);
|
||||
position: relative;
|
||||
z-index: 1;
|
||||
|
||||
&.noOverlap {
|
||||
margin-top: 16px;
|
||||
}
|
||||
}
|
||||
|
||||
.sectionTitle {
|
||||
font-size: 15px;
|
||||
font-size: 17px;
|
||||
font-weight: 600;
|
||||
color: #333;
|
||||
padding: 12px 16px;
|
||||
border-bottom: 1px solid #f0f0f0;
|
||||
color: #1a1a1a;
|
||||
margin-bottom: 16px;
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
|
||||
.icon {
|
||||
font-size: 16px;
|
||||
}
|
||||
|
||||
.sourceCount {
|
||||
font-size: 12px;
|
||||
font-weight: normal;
|
||||
color: #999;
|
||||
margin-left: 4px;
|
||||
.titleLine {
|
||||
display: inline-block;
|
||||
width: 4px;
|
||||
height: 16px;
|
||||
background: #1677ff;
|
||||
border-radius: 2px;
|
||||
margin-right: 8px;
|
||||
vertical-align: middle;
|
||||
}
|
||||
}
|
||||
|
||||
.sectionContent {
|
||||
padding: 12px 16px;
|
||||
}
|
||||
|
||||
// 用户基本信息卡片
|
||||
.userCard {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
padding: 16px;
|
||||
// 基础信息列表
|
||||
.infoGrid {
|
||||
display: grid;
|
||||
grid-template-columns: 1fr 1fr;
|
||||
gap: 16px;
|
||||
}
|
||||
|
||||
.userInfo {
|
||||
flex: 1;
|
||||
|
||||
.userName {
|
||||
font-size: 18px;
|
||||
font-weight: 600;
|
||||
color: #333;
|
||||
margin-bottom: 4px;
|
||||
}
|
||||
|
||||
.userMeta {
|
||||
font-size: 13px;
|
||||
color: #999;
|
||||
line-height: 1.8;
|
||||
}
|
||||
}
|
||||
|
||||
// RFM 评分
|
||||
.rfmCard {
|
||||
display: flex;
|
||||
justify-content: space-around;
|
||||
padding: 16px 0;
|
||||
}
|
||||
|
||||
.rfmItem {
|
||||
text-align: center;
|
||||
|
||||
.rfmValue {
|
||||
font-size: 24px;
|
||||
font-weight: 600;
|
||||
color: #1890ff;
|
||||
}
|
||||
|
||||
.rfmLabel {
|
||||
font-size: 12px;
|
||||
color: #999;
|
||||
margin-top: 4px;
|
||||
}
|
||||
}
|
||||
|
||||
.rfmTotal {
|
||||
.rfmValue {
|
||||
color: #ff6b00;
|
||||
}
|
||||
}
|
||||
|
||||
// 信息列表
|
||||
.infoList {
|
||||
.infoItem {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
padding: 12px 0;
|
||||
border-bottom: 1px solid #f5f5f5;
|
||||
|
||||
&:last-child {
|
||||
border-bottom: none;
|
||||
}
|
||||
|
||||
.label {
|
||||
font-size: 14px;
|
||||
color: #666;
|
||||
font-size: 13px;
|
||||
color: #8c8c8c;
|
||||
margin-bottom: 4px;
|
||||
}
|
||||
|
||||
.value {
|
||||
font-size: 14px;
|
||||
color: #333;
|
||||
max-width: 60%;
|
||||
text-align: right;
|
||||
word-break: break-all;
|
||||
font-size: 15px;
|
||||
color: #262626;
|
||||
font-weight: 500;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 标签分组
|
||||
// 统计面板
|
||||
.statsGrid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(3, 1fr);
|
||||
gap: 12px;
|
||||
|
||||
.statBox {
|
||||
background: #f8fbff;
|
||||
padding: 12px;
|
||||
border-radius: 12px;
|
||||
text-align: center;
|
||||
|
||||
.statVal {
|
||||
display: block;
|
||||
font-size: 18px;
|
||||
font-weight: bold;
|
||||
color: #1677ff;
|
||||
}
|
||||
.statLabel {
|
||||
font-size: 12px;
|
||||
color: #595959;
|
||||
margin-top: 4px;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 标签样式重构
|
||||
.tagGroups {
|
||||
padding: 12px 16px;
|
||||
}
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 12px;
|
||||
|
||||
.tagGroup {
|
||||
margin-bottom: 12px;
|
||||
.tagGroup {
|
||||
.tagGroupTitle {
|
||||
font-size: 12px;
|
||||
color: #8c8c8c;
|
||||
margin-bottom: 8px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
}
|
||||
|
||||
&:last-child {
|
||||
margin-bottom: 0;
|
||||
.tagsList {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 8px;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.tagGroupTitle {
|
||||
.tagBase {
|
||||
padding: 4px 10px;
|
||||
border-radius: 6px;
|
||||
font-size: 13px;
|
||||
color: #666;
|
||||
margin-bottom: 8px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
|
||||
.tagTypeIcon {
|
||||
font-size: 14px;
|
||||
}
|
||||
}
|
||||
|
||||
.tagList {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.tag {
|
||||
padding: 4px 12px;
|
||||
background: #e6f7ff;
|
||||
color: #1890ff;
|
||||
border-radius: 12px;
|
||||
font-size: 12px;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
}
|
||||
|
||||
.tagWechat {
|
||||
background: #f6ffed;
|
||||
color: #52c41a;
|
||||
}
|
||||
|
||||
.tagSite {
|
||||
background: #e6f7ff;
|
||||
color: #1890ff;
|
||||
@extend .tagBase;
|
||||
background: #e6f4ff;
|
||||
color: #0958d9;
|
||||
border: 1px solid #91caff;
|
||||
}
|
||||
|
||||
.tagAi {
|
||||
background: #fff7e6;
|
||||
color: #fa8c16;
|
||||
@extend .tagBase;
|
||||
background: #f9f0ff;
|
||||
color: #531dab;
|
||||
border: 1px solid #d3adf7;
|
||||
}
|
||||
|
||||
.tagScore {
|
||||
font-size: 10px;
|
||||
opacity: 0.8;
|
||||
padding: 1px 4px;
|
||||
background: rgba(0, 0, 0, 0.1);
|
||||
border-radius: 8px;
|
||||
.tagWechat {
|
||||
@extend .tagBase;
|
||||
background: #f6ffed;
|
||||
color: #389e0d;
|
||||
border: 1px solid #b7eb8f;
|
||||
}
|
||||
|
||||
.emptyTag {
|
||||
color: #999;
|
||||
font-size: 13px;
|
||||
padding: 12px 16px;
|
||||
}
|
||||
|
||||
// 标题操作区域
|
||||
.titleActions {
|
||||
margin-left: auto;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
// 操作按钮
|
||||
.actionBtn {
|
||||
font-size: 13px;
|
||||
font-weight: normal;
|
||||
color: #1890ff;
|
||||
cursor: pointer;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
|
||||
&:active {
|
||||
opacity: 0.7;
|
||||
}
|
||||
|
||||
&.loading {
|
||||
color: #999;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
}
|
||||
|
||||
// 编辑按钮(保留兼容)
|
||||
.editBtn {
|
||||
margin-left: auto;
|
||||
font-size: 13px;
|
||||
font-weight: normal;
|
||||
color: #1890ff;
|
||||
cursor: pointer;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
|
||||
&:active {
|
||||
opacity: 0.7;
|
||||
}
|
||||
}
|
||||
|
||||
// 标签类型提示
|
||||
.tagTypeHint {
|
||||
font-size: 11px;
|
||||
color: #999;
|
||||
font-weight: normal;
|
||||
margin-left: 4px;
|
||||
}
|
||||
|
||||
// 可编辑标签
|
||||
.tagEditable {
|
||||
// 轨迹样式
|
||||
.journeyList {
|
||||
position: relative;
|
||||
padding-right: 20px;
|
||||
}
|
||||
padding-left: 20px;
|
||||
|
||||
.tagRemove {
|
||||
position: absolute;
|
||||
right: 4px;
|
||||
top: 50%;
|
||||
transform: translateY(-50%);
|
||||
font-size: 12px;
|
||||
color: #999;
|
||||
cursor: pointer;
|
||||
&::before {
|
||||
content: '';
|
||||
position: absolute;
|
||||
left: 4px;
|
||||
top: 10px;
|
||||
bottom: 10px;
|
||||
width: 1px;
|
||||
background: #f0f0f0;
|
||||
}
|
||||
|
||||
&:hover {
|
||||
color: #ff4d4f;
|
||||
.journeyItem {
|
||||
position: relative;
|
||||
padding-bottom: 20px;
|
||||
|
||||
&::after {
|
||||
content: '';
|
||||
position: absolute;
|
||||
left: -20px;
|
||||
top: 6px;
|
||||
width: 9px;
|
||||
height: 9px;
|
||||
border-radius: 50%;
|
||||
background: #bfbfbf;
|
||||
border: 2px solid #fff;
|
||||
}
|
||||
|
||||
&:first-child::after {
|
||||
background: #1677ff;
|
||||
box-shadow: 0 0 0 4px rgba(22, 119, 255, 0.1);
|
||||
}
|
||||
|
||||
.journeyHeader {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
margin-bottom: 4px;
|
||||
|
||||
.jTitle {
|
||||
font-weight: 600;
|
||||
font-size: 15px;
|
||||
}
|
||||
.jTime {
|
||||
font-size: 12px;
|
||||
color: #bfbfbf;
|
||||
}
|
||||
}
|
||||
.jContent {
|
||||
font-size: 14px;
|
||||
color: #595959;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 标签编辑弹窗
|
||||
.tagModal {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
.tagModalHeader {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
padding: 16px;
|
||||
border-bottom: 1px solid #f0f0f0;
|
||||
}
|
||||
|
||||
.tagModalTitle {
|
||||
font-size: 16px;
|
||||
font-weight: 600;
|
||||
color: #333;
|
||||
}
|
||||
|
||||
.tagModalClose {
|
||||
font-size: 24px;
|
||||
color: #999;
|
||||
cursor: pointer;
|
||||
line-height: 1;
|
||||
|
||||
&:active {
|
||||
opacity: 0.7;
|
||||
}
|
||||
}
|
||||
|
||||
.tagModalLoading {
|
||||
flex: 1;
|
||||
.sourceIconText {
|
||||
width: 24px;
|
||||
height: 24px;
|
||||
background: #f0f5ff;
|
||||
color: #1677ff;
|
||||
border-radius: 4px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.tagModalContent {
|
||||
flex: 1;
|
||||
overflow-y: auto;
|
||||
padding: 0 16px;
|
||||
}
|
||||
|
||||
.tagDefineItem {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 2px;
|
||||
}
|
||||
|
||||
.tagDefineName {
|
||||
font-size: 14px;
|
||||
color: #333;
|
||||
}
|
||||
|
||||
.tagDefineDesc {
|
||||
font-size: 12px;
|
||||
color: #999;
|
||||
font-weight: bold;
|
||||
}
|
||||
|
||||
.tagModalFooter {
|
||||
padding: 16px;
|
||||
border-top: 1px solid #f0f0f0;
|
||||
}
|
||||
|
||||
// 行为轨迹
|
||||
.behaviorList {
|
||||
max-height: 300px;
|
||||
overflow-y: auto;
|
||||
}
|
||||
|
||||
.behaviorItem {
|
||||
.ownerList {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
padding: 12px 0;
|
||||
border-bottom: 1px solid #f5f5f5;
|
||||
gap: 12px;
|
||||
flex-wrap: wrap;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
margin-top: 6px;
|
||||
padding: 4px 8px;
|
||||
background: #fafafa;
|
||||
border-radius: 4px;
|
||||
|
||||
&:last-child {
|
||||
border-bottom: none;
|
||||
.ownerLabel {
|
||||
font-size: 12px;
|
||||
color: #8c8c8c;
|
||||
}
|
||||
|
||||
.behaviorIcon {
|
||||
width: 32px;
|
||||
height: 32px;
|
||||
border-radius: 50%;
|
||||
background: #e6f7ff;
|
||||
color: #1890ff;
|
||||
.ownerItem {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
font-size: 14px;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.behaviorContent {
|
||||
flex: 1;
|
||||
|
||||
.behaviorTitle {
|
||||
font-size: 14px;
|
||||
color: #333;
|
||||
margin-bottom: 4px;
|
||||
}
|
||||
|
||||
.behaviorTime {
|
||||
font-size: 12px;
|
||||
color: #999;
|
||||
}
|
||||
gap: 4px;
|
||||
font-size: 12px;
|
||||
color: #595959;
|
||||
}
|
||||
}
|
||||
|
||||
.emptyBehavior {
|
||||
color: #999;
|
||||
font-size: 13px;
|
||||
text-align: center;
|
||||
padding: 24px 0;
|
||||
}
|
||||
|
||||
// 来源信息
|
||||
.sourceList {
|
||||
max-height: 300px; // 固定高度
|
||||
overflow-y: auto; // 可滚动
|
||||
padding-right: 4px; // 为滚动条留出空间
|
||||
|
||||
// 滚动条样式
|
||||
&::-webkit-scrollbar {
|
||||
width: 4px;
|
||||
}
|
||||
|
||||
&::-webkit-scrollbar-track {
|
||||
background: #f5f5f5;
|
||||
border-radius: 2px;
|
||||
}
|
||||
|
||||
&::-webkit-scrollbar-thumb {
|
||||
background: #d9d9d9;
|
||||
border-radius: 2px;
|
||||
|
||||
&:hover {
|
||||
background: #bfbfbf;
|
||||
}
|
||||
}
|
||||
|
||||
// 展开状态:不限制高度
|
||||
&.sourceListExpanded {
|
||||
max-height: 500px; // 展开时更大的高度
|
||||
}
|
||||
|
||||
.sourceItem {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
padding: 12px 0;
|
||||
border-bottom: 1px solid #f5f5f5;
|
||||
gap: 12px;
|
||||
|
||||
&:last-child {
|
||||
border-bottom: none;
|
||||
}
|
||||
|
||||
.sourceIcon {
|
||||
width: 36px;
|
||||
height: 36px;
|
||||
border-radius: 8px;
|
||||
background: #f0f0f0;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
font-size: 16px;
|
||||
flex-shrink: 0;
|
||||
margin-top: 2px;
|
||||
}
|
||||
|
||||
.sourceInfo {
|
||||
flex: 1;
|
||||
|
||||
.sourceName {
|
||||
font-size: 14px;
|
||||
color: #333;
|
||||
margin-bottom: 4px;
|
||||
line-height: 1.4;
|
||||
}
|
||||
|
||||
.chatroomName {
|
||||
color: #1890ff;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.sourceId {
|
||||
font-size: 12px;
|
||||
color: #888;
|
||||
margin-bottom: 2px;
|
||||
|
||||
.idValue {
|
||||
color: #666;
|
||||
font-family: monospace;
|
||||
word-break: break-all;
|
||||
}
|
||||
}
|
||||
|
||||
.chatroomOwners {
|
||||
font-size: 12px;
|
||||
color: #666;
|
||||
margin-bottom: 4px;
|
||||
line-height: 1.6;
|
||||
|
||||
.ownerTag {
|
||||
color: #52c41a;
|
||||
font-weight: 500;
|
||||
}
|
||||
}
|
||||
|
||||
.sourceTime {
|
||||
font-size: 12px;
|
||||
color: #999;
|
||||
}
|
||||
}
|
||||
|
||||
.firstBadge {
|
||||
font-size: 10px;
|
||||
background: #ff4d4f;
|
||||
color: #fff;
|
||||
padding: 2px 6px;
|
||||
border-radius: 4px;
|
||||
flex-shrink: 0;
|
||||
margin-top: 2px;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 搜索框包装器
|
||||
.searchBarWrapper {
|
||||
margin-bottom: 12px;
|
||||
padding: 0 4px;
|
||||
}
|
||||
|
||||
// 显示更多按钮
|
||||
.showMoreBtn {
|
||||
text-align: center;
|
||||
padding: 12px 16px;
|
||||
color: #1890ff;
|
||||
// 底部按钮
|
||||
.loadMore {
|
||||
width: 100%;
|
||||
margin-top: 12px;
|
||||
border-radius: 8px;
|
||||
height: 36px;
|
||||
font-size: 14px;
|
||||
cursor: pointer;
|
||||
border-top: 1px solid #f0f0f0;
|
||||
margin-top: 8px;
|
||||
user-select: none;
|
||||
}
|
||||
|
||||
&:active {
|
||||
opacity: 0.7;
|
||||
background: #f5f5f5;
|
||||
}
|
||||
|
||||
&.loading {
|
||||
color: #999;
|
||||
cursor: not-allowed;
|
||||
.searchWrapper {
|
||||
margin-bottom: 16px;
|
||||
:global(.adm-search-bar) {
|
||||
--background: #f5f5f5;
|
||||
}
|
||||
}
|
||||
|
||||
.titleActions {
|
||||
display: flex;
|
||||
gap: 12px;
|
||||
|
||||
.actionBtn {
|
||||
font-size: 13px;
|
||||
color: #1677ff;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
}
|
||||
}
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -1,56 +1,75 @@
|
||||
import request from "@/api/request";
|
||||
import { createGroup, type RuleCondition, type RuleConfig } from "../api";
|
||||
|
||||
// 创建流量包
|
||||
export interface CreateTrafficPackageParams {
|
||||
name: string;
|
||||
groupName: string;
|
||||
description?: string;
|
||||
remarks?: string;
|
||||
filterConditions: any[];
|
||||
userIds: string[];
|
||||
groupIcon?: string;
|
||||
groupColor?: string;
|
||||
ruleType: number; // 1=动态规则,2=手动添加
|
||||
ruleConfig?: RuleConfig;
|
||||
memberIds?: number[]; // 手动添加时的成员ID列表
|
||||
}
|
||||
|
||||
export interface CreateTrafficPackageResponse {
|
||||
id: string;
|
||||
name: string;
|
||||
success: boolean;
|
||||
message: string;
|
||||
id: number;
|
||||
groupName: string;
|
||||
}
|
||||
|
||||
export async function createTrafficPackage(
|
||||
params: CreateTrafficPackageParams,
|
||||
): Promise<CreateTrafficPackageResponse> {
|
||||
return request("/v1/traffic/pool/create", params, "POST");
|
||||
// 调用V2接口创建分组
|
||||
return createGroup(params);
|
||||
}
|
||||
|
||||
// 获取用户列表(根据筛选条件)
|
||||
export interface GetUsersByFilterParams {
|
||||
conditions: any[];
|
||||
ruleConfig: RuleConfig & { keyword?: string };
|
||||
page?: number;
|
||||
pageSize?: number;
|
||||
}
|
||||
|
||||
export interface User {
|
||||
id: string;
|
||||
name: string;
|
||||
avatar: string;
|
||||
id: number;
|
||||
identifier: string;
|
||||
nickname: string | null;
|
||||
avatar: string | null;
|
||||
wechatId: string | null;
|
||||
wechatAlias: string | null;
|
||||
phone: string | null;
|
||||
region: string;
|
||||
gender: number;
|
||||
rfmScore: {
|
||||
R: number;
|
||||
F: number;
|
||||
M: number;
|
||||
total: number;
|
||||
};
|
||||
tags: string[];
|
||||
rfmScore: number;
|
||||
lastActive: string;
|
||||
consumption: number;
|
||||
lastInteractTime: number;
|
||||
totalMsgCount: number;
|
||||
totalOrderAmount: string;
|
||||
lifecycle: number;
|
||||
intentionLevel: number;
|
||||
}
|
||||
|
||||
export interface GetUsersByFilterResponse {
|
||||
list: User[];
|
||||
total: number;
|
||||
page: number;
|
||||
pageSize: number;
|
||||
}
|
||||
|
||||
export async function getUsersByFilter(
|
||||
params: GetUsersByFilterParams,
|
||||
): Promise<GetUsersByFilterResponse> {
|
||||
return request("/v1/traffic/pool/users/filter", params, "POST");
|
||||
// 使用 POST 请求,因为 ruleConfig 是复杂对象
|
||||
return request("/v1/traffic/pool/v2/preview-users", params, "POST");
|
||||
}
|
||||
|
||||
// 获取预设方案列表
|
||||
// 获取预设方案列表(基于系统分组)
|
||||
export interface PresetScheme {
|
||||
id: string;
|
||||
name: string;
|
||||
@@ -61,70 +80,58 @@ export interface PresetScheme {
|
||||
}
|
||||
|
||||
export async function getPresetSchemes(): Promise<PresetScheme[]> {
|
||||
// 模拟数据
|
||||
return new Promise(resolve => {
|
||||
setTimeout(() => {
|
||||
resolve([
|
||||
{
|
||||
id: "scheme_1",
|
||||
name: "高价值客户方案",
|
||||
description: "针对高消费、高活跃度的客户群体",
|
||||
conditions: [
|
||||
{ id: "rfm_high", type: "rfm", label: "RFM评分", value: "high" },
|
||||
{
|
||||
id: "consumption_high",
|
||||
type: "consumption",
|
||||
label: "消费能力",
|
||||
value: "high",
|
||||
},
|
||||
],
|
||||
userCount: 1250,
|
||||
color: "#ff4d4f",
|
||||
},
|
||||
{
|
||||
id: "scheme_2",
|
||||
name: "新用户激活方案",
|
||||
description: "针对新注册用户的激活策略",
|
||||
conditions: [
|
||||
{ id: "new_user", type: "tag", label: "新用户", value: true },
|
||||
{
|
||||
id: "low_activity",
|
||||
type: "activity",
|
||||
label: "活跃度",
|
||||
value: "low",
|
||||
},
|
||||
],
|
||||
userCount: 890,
|
||||
color: "#52c41a",
|
||||
},
|
||||
{
|
||||
id: "scheme_3",
|
||||
name: "流失挽回方案",
|
||||
description: "针对流失风险用户的挽回策略",
|
||||
conditions: [
|
||||
{ id: "churn_risk", type: "tag", label: "流失风险", value: true },
|
||||
{
|
||||
id: "last_active",
|
||||
type: "time",
|
||||
label: "最后活跃",
|
||||
value: "30天前",
|
||||
},
|
||||
],
|
||||
userCount: 567,
|
||||
color: "#faad14",
|
||||
},
|
||||
]);
|
||||
}, 500);
|
||||
});
|
||||
// return request("/v1/traffic/pool/schemes", {}, "GET");
|
||||
try {
|
||||
// 获取所有分组作为推荐方案
|
||||
const groups = await request("/v1/traffic/pool/v2/groups", {}, "GET");
|
||||
|
||||
// 去重(根据 id)并过滤掉没有规则配置的分组
|
||||
const uniqueGroups: any[] = [];
|
||||
const seenIds = new Set<number>();
|
||||
|
||||
for (const group of groups) {
|
||||
if (!seenIds.has(group.id)) {
|
||||
seenIds.add(group.id);
|
||||
uniqueGroups.push(group);
|
||||
}
|
||||
}
|
||||
|
||||
// 转换为PresetScheme格式,优先显示系统分组
|
||||
const sortedGroups = uniqueGroups.sort((a: any, b: any) => {
|
||||
// 系统分组排前面
|
||||
if (a.isSystem !== b.isSystem) return b.isSystem - a.isSystem;
|
||||
// 同类型按 id 排序
|
||||
return a.id - b.id;
|
||||
});
|
||||
|
||||
return sortedGroups.map((group: any) => ({
|
||||
id: String(group.id),
|
||||
name: group.groupName,
|
||||
description: group.description || (group.isSystem ? `系统预设:${group.groupName}` : `自定义:${group.groupName}`),
|
||||
conditions: group.ruleConfig?.conditions || [],
|
||||
userCount: group.memberCount || 0,
|
||||
color: group.groupColor || (group.isSystem ? "#1677ff" : "#52c41a"),
|
||||
}));
|
||||
} catch (error) {
|
||||
console.error("获取预设方案失败:", error);
|
||||
// 返回空数组或默认方案
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
// 获取行业选项(固定筛选项)
|
||||
export interface IndustryOption {
|
||||
// 获取筛选字段元数据
|
||||
export interface FilterField {
|
||||
field: string;
|
||||
label: string;
|
||||
value: string | number;
|
||||
type: 'select' | 'input' | 'number' | 'province' | 'friend_search';
|
||||
options?: { label: string; value: any }[];
|
||||
placeholder?: string;
|
||||
}
|
||||
|
||||
export async function getIndustryOptions(): Promise<IndustryOption[]> {
|
||||
return request("/v1/traffic/pool/industries", {}, "GET");
|
||||
export async function getFilterFields(): Promise<FilterField[]> {
|
||||
return request("/v1/traffic/pool/v2/filter-fields", {}, "GET");
|
||||
}
|
||||
|
||||
// 兼容旧接口名称
|
||||
export async function getIndustryOptions(): Promise<FilterField[]> {
|
||||
return getFilterFields();
|
||||
}
|
||||
|
||||
@@ -1,129 +1,123 @@
|
||||
.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;
|
||||
}
|
||||
|
||||
.schemeRow {
|
||||
display: flex;
|
||||
gap: 12px;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.addSchemeBtn {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
font-size: 12px;
|
||||
padding: 4px 8px;
|
||||
height: 32px;
|
||||
white-space: nowrap;
|
||||
flex-shrink: 0;
|
||||
padding: 12px;
|
||||
}
|
||||
|
||||
.section {
|
||||
margin-bottom: 24px;
|
||||
background: #fff;
|
||||
border-radius: 16px;
|
||||
padding: 20px;
|
||||
margin-bottom: 16px;
|
||||
border: 1px solid rgba(0, 0, 0, 0.05);
|
||||
box-shadow: 0 4px 15px rgba(0, 0, 0, 0.02);
|
||||
}
|
||||
|
||||
.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 {
|
||||
font-size: 16px;
|
||||
font-weight: 700;
|
||||
color: #1a1a1a;
|
||||
margin-bottom: 16px;
|
||||
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);
|
||||
align-items: center;
|
||||
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;
|
||||
&::before {
|
||||
content: '';
|
||||
width: 3px;
|
||||
height: 14px;
|
||||
background: #007aff;
|
||||
border-radius: 2px;
|
||||
}
|
||||
}
|
||||
|
||||
.generateBtn {
|
||||
margin-top: 16px;
|
||||
.selectWrapper {
|
||||
:global(.ant-select-selector) {
|
||||
border-radius: 12px !important;
|
||||
background: #f9fafb !important;
|
||||
border: 1px solid transparent !important;
|
||||
height: 48px !important;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
padding: 0 16px !important;
|
||||
|
||||
&:hover, &:focus {
|
||||
border-color: #007aff !important;
|
||||
background: #fff !important;
|
||||
}
|
||||
}
|
||||
|
||||
:global(.ant-select-selection-placeholder) {
|
||||
line-height: 48px !important;
|
||||
color: #9ca3af !important;
|
||||
}
|
||||
|
||||
:global(.ant-select-selection-item) {
|
||||
line-height: 48px !important;
|
||||
font-weight: 600;
|
||||
color: #111827;
|
||||
}
|
||||
}
|
||||
|
||||
.tagGrid {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
.tagItem {
|
||||
background: #f3f4f6;
|
||||
color: #4b5563;
|
||||
padding: 8px 16px;
|
||||
border-radius: 10px;
|
||||
font-size: 13px;
|
||||
font-weight: 600;
|
||||
transition: all 0.2s ease;
|
||||
|
||||
&:active {
|
||||
background: #e5e7eb;
|
||||
transform: scale(0.96);
|
||||
}
|
||||
}
|
||||
|
||||
.buttonGroup {
|
||||
display: flex;
|
||||
gap: 12px;
|
||||
margin-top: 12px;
|
||||
}
|
||||
|
||||
.addBtn {
|
||||
flex: 1;
|
||||
height: 48px;
|
||||
border-radius: 12px;
|
||||
border: 1px dashed #d1d5db;
|
||||
color: #007aff;
|
||||
background: #f0f7ff;
|
||||
font-weight: 700;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
transition: all 0.2s;
|
||||
|
||||
&:active {
|
||||
background: #e0efff;
|
||||
border-color: #007aff;
|
||||
}
|
||||
}
|
||||
|
||||
.friendBtn {
|
||||
flex: 1;
|
||||
height: 48px;
|
||||
border-radius: 12px;
|
||||
border: 1px dashed #d1d5db;
|
||||
color: #52c41a;
|
||||
background: #f6ffed;
|
||||
font-weight: 700;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
transition: all 0.2s;
|
||||
|
||||
&:active {
|
||||
background: #e6ffe0;
|
||||
border-color: #52c41a;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,23 +1,35 @@
|
||||
import React, { useEffect, useState } from "react";
|
||||
import { Card, Button } from "antd-mobile";
|
||||
import { Button } from "antd-mobile";
|
||||
import { Select } from "antd";
|
||||
import { PlusOutlined } from "@ant-design/icons";
|
||||
import { AddOutline, UserAddOutline } from "antd-mobile-icons";
|
||||
import CustomConditionModal from "./CustomConditionModal";
|
||||
import FriendSearchModal from "./FriendSearchModal";
|
||||
import ConditionList from "./ConditionList";
|
||||
import styles from "./AudienceFilter.module.scss";
|
||||
import {
|
||||
getIndustryOptions,
|
||||
getPresetSchemes,
|
||||
IndustryOption,
|
||||
FilterField,
|
||||
PresetScheme,
|
||||
} from "../api";
|
||||
|
||||
interface Friend {
|
||||
id: string;
|
||||
name: string;
|
||||
avatar: string;
|
||||
wechatId: string;
|
||||
tags: string[];
|
||||
}
|
||||
|
||||
interface FilterCondition {
|
||||
id: string;
|
||||
type: string;
|
||||
label: string;
|
||||
value: any;
|
||||
operator?: string;
|
||||
field?: string;
|
||||
displayValue?: string;
|
||||
friends?: Friend[]; // 选中的好友列表
|
||||
}
|
||||
|
||||
interface AudienceFilterProps {
|
||||
@@ -30,165 +42,137 @@ const AudienceFilter: React.FC<AudienceFilterProps> = ({
|
||||
onChange,
|
||||
}) => {
|
||||
const [showCustomModal, setShowCustomModal] = useState(false);
|
||||
const [industryOptions, setIndustryOptions] = useState<IndustryOption[]>([]);
|
||||
const [showFriendModal, setShowFriendModal] = useState(false);
|
||||
const [industryOptions, setIndustryOptions] = useState<FilterField[]>([]);
|
||||
const [presetSchemes, setPresetSchemes] = useState<PresetScheme[]>([]);
|
||||
const [selectedIndustry, setSelectedIndustry] = useState<
|
||||
string | number | undefined
|
||||
>(undefined);
|
||||
const [selectedScheme, setSelectedScheme] = useState<string | undefined>(
|
||||
undefined,
|
||||
);
|
||||
const [selectedScheme, setSelectedScheme] = useState<string | undefined>(undefined);
|
||||
|
||||
// 加载行业选项和方案列表
|
||||
useEffect(() => {
|
||||
getIndustryOptions()
|
||||
.then(res => setIndustryOptions(res || []))
|
||||
.catch(() => setIndustryOptions([]));
|
||||
|
||||
getPresetSchemes()
|
||||
.then(res => setPresetSchemes(res || []))
|
||||
.catch(() => setPresetSchemes([]));
|
||||
getIndustryOptions().then(res => setIndustryOptions(res || []));
|
||||
getPresetSchemes().then(res => setPresetSchemes(res || []));
|
||||
}, []);
|
||||
|
||||
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 handleSchemeChange = (schemeId: string) => {
|
||||
setSelectedScheme(schemeId);
|
||||
if (schemeId) {
|
||||
// 找到选中的方案并应用其条件
|
||||
const scheme = presetSchemes.find(s => s.id === schemeId);
|
||||
if (scheme) {
|
||||
onChange(scheme.conditions);
|
||||
}
|
||||
} else {
|
||||
// 清空方案选择时,清空条件
|
||||
onChange([]);
|
||||
}
|
||||
};
|
||||
|
||||
const handleAddScheme = () => {
|
||||
// 这里可以打开添加方案的弹窗或跳转到方案管理页面
|
||||
console.log("添加新方案");
|
||||
// 获取已选中的好友(从已有条件中提取)
|
||||
const getSelectedFriends = (): Friend[] => {
|
||||
const friendCondition = conditions.find(c => c.field === 'friendIds');
|
||||
return friendCondition?.friends || [];
|
||||
};
|
||||
|
||||
// 处理好友选择确认
|
||||
const handleFriendsConfirm = (friends: Friend[]) => {
|
||||
// 移除旧的好友条件
|
||||
const newConditions = conditions.filter(c => c.field !== 'friendIds');
|
||||
|
||||
if (friends.length > 0) {
|
||||
// 添加新的好友条件
|
||||
const friendCondition: FilterCondition = {
|
||||
id: 'friendIds',
|
||||
type: 'field',
|
||||
field: 'friendIds',
|
||||
label: '指定好友',
|
||||
operator: 'in',
|
||||
value: friends.map(f => f.id),
|
||||
displayValue: `已选 ${friends.length} 人`,
|
||||
friends: friends,
|
||||
};
|
||||
newConditions.push(friendCondition);
|
||||
}
|
||||
|
||||
onChange(newConditions);
|
||||
};
|
||||
|
||||
// 处理条件添加(排除好友搜索类型,因为有专门的按钮)
|
||||
const handleAddCondition = (condition: FilterCondition) => {
|
||||
// 如果是搜索好友条件,打开好友选择弹窗
|
||||
if (condition.field === 'keyword' && condition.fieldType === 'friend_search') {
|
||||
setShowFriendModal(true);
|
||||
return;
|
||||
}
|
||||
onChange([...conditions, condition]);
|
||||
};
|
||||
|
||||
return (
|
||||
<div className={styles.container}>
|
||||
<Card className={styles.card}>
|
||||
<div className={styles.header}>
|
||||
<div className={styles.title}>人群筛选</div>
|
||||
<div className={styles.section}>
|
||||
<div className={styles.sectionTitle}>人群方案</div>
|
||||
<div className={styles.selectWrapper}>
|
||||
<Select
|
||||
style={{ width: "100%" }}
|
||||
placeholder="选择预设方案"
|
||||
value={selectedScheme}
|
||||
onChange={handleSchemeChange}
|
||||
options={presetSchemes.map(scheme => ({
|
||||
label: `${scheme.name} (${scheme.userCount}人)`,
|
||||
value: scheme.id,
|
||||
}))}
|
||||
allowClear
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 方案推荐选择 */}
|
||||
<div className={styles.section}>
|
||||
<div className={styles.sectionTitle}>方案推荐</div>
|
||||
<div className={styles.schemeRow}>
|
||||
<Select
|
||||
style={{ flex: 1 }}
|
||||
placeholder="选择预设方案"
|
||||
value={selectedScheme}
|
||||
onChange={handleSchemeChange}
|
||||
options={presetSchemes.map(scheme => ({
|
||||
label: `${scheme.name} (${scheme.userCount}人)`,
|
||||
value: scheme.id,
|
||||
}))}
|
||||
allowClear
|
||||
/>
|
||||
<Button
|
||||
size="small"
|
||||
fill="outline"
|
||||
onClick={handleAddScheme}
|
||||
className={styles.addSchemeBtn}
|
||||
>
|
||||
<PlusOutlined />
|
||||
添加方案
|
||||
</Button>
|
||||
</div>
|
||||
<div className={styles.section}>
|
||||
<div className={styles.sectionTitle}>自定义条件</div>
|
||||
<ConditionList
|
||||
conditions={conditions}
|
||||
onRemove={(condition) => {
|
||||
const newConditions = conditions.filter(c =>
|
||||
c.field ? c.field !== condition.field : c.id !== condition.id
|
||||
);
|
||||
onChange(newConditions);
|
||||
}}
|
||||
/>
|
||||
<div className={styles.buttonGroup}>
|
||||
<Button
|
||||
fill="none"
|
||||
className={styles.addBtn}
|
||||
onClick={() => setShowCustomModal(true)}
|
||||
>
|
||||
<AddOutline style={{ marginRight: 4 }} /> 添加筛选条件
|
||||
</Button>
|
||||
<Button
|
||||
fill="none"
|
||||
className={styles.friendBtn}
|
||||
onClick={() => setShowFriendModal(true)}
|
||||
>
|
||||
<UserAddOutline style={{ marginRight: 4 }} /> 选择好友
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 条件筛选区域 - 当未选择方案时显示 */}
|
||||
{!selectedScheme && (
|
||||
<>
|
||||
{/* 行业筛选(固定项,接口获取选项) */}
|
||||
<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}>
|
||||
{["高价值", "新客", "活跃", "待唤醒", "价格敏感", "忠诚"].map((tag, idx) => (
|
||||
<div key={idx} className={styles.tagItem}>{tag}</div>
|
||||
))}
|
||||
</div>
|
||||
</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>
|
||||
</>
|
||||
)}
|
||||
</Card>
|
||||
|
||||
{/* 自定义条件弹窗 */}
|
||||
<CustomConditionModal
|
||||
visible={showCustomModal}
|
||||
onClose={() => setShowCustomModal(false)}
|
||||
onAdd={handleAddCondition}
|
||||
/>
|
||||
|
||||
<FriendSearchModal
|
||||
visible={showFriendModal}
|
||||
onClose={() => setShowFriendModal(false)}
|
||||
onConfirm={handleFriendsConfirm}
|
||||
existingConditions={conditions.filter(c => c.field !== 'friendIds')}
|
||||
selectedFriends={getSelectedFriends()}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -1,60 +1,75 @@
|
||||
.container {
|
||||
padding: 0;
|
||||
padding: 16px;
|
||||
background: #f8fafc;
|
||||
}
|
||||
|
||||
.card {
|
||||
border-radius: 8px;
|
||||
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.04);
|
||||
background: #fff;
|
||||
border-radius: 12px;
|
||||
box-shadow: 0 1px 3px rgba(0, 0, 0, 0.1);
|
||||
padding: 16px;
|
||||
margin-bottom: 16px;
|
||||
box-sizing: border-box;
|
||||
width: 100%;
|
||||
overflow: hidden;
|
||||
|
||||
// 输入框样式覆盖
|
||||
:global(.adm-input), :global(.adm-text-area) {
|
||||
--font-size: 14px;
|
||||
background: #f8fafc !important;
|
||||
border: 1px solid #e2e8f0 !important;
|
||||
border-radius: 8px !important;
|
||||
padding: 10px 12px !important;
|
||||
transition: all 0.2s;
|
||||
|
||||
&:focus-within {
|
||||
border-color: #3b82f6 !important;
|
||||
box-shadow: 0 0 0 2px rgba(59, 130, 246, 0.1) !important;
|
||||
background: #fff !important;
|
||||
}
|
||||
}
|
||||
|
||||
:global(.adm-input-element), :global(.adm-text-area-element) {
|
||||
background: transparent !important;
|
||||
border: none !important;
|
||||
box-shadow: none !important;
|
||||
padding: 0 !important;
|
||||
}
|
||||
|
||||
:global(.adm-input-element::placeholder), :global(.adm-text-area-element::placeholder) {
|
||||
color: #cbd5e1;
|
||||
}
|
||||
}
|
||||
|
||||
.title {
|
||||
font-size: 16px;
|
||||
.sectionTitle {
|
||||
font-size: 17px;
|
||||
font-weight: 600;
|
||||
color: #333;
|
||||
margin-bottom: 20px;
|
||||
margin-bottom: 18px;
|
||||
color: #222;
|
||||
}
|
||||
|
||||
.label {
|
||||
color: #333;
|
||||
font-size: 14px;
|
||||
font-size: 15px;
|
||||
font-weight: 500;
|
||||
margin-bottom: 8px;
|
||||
color: #222;
|
||||
display: block;
|
||||
}
|
||||
|
||||
.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);
|
||||
}
|
||||
color: #ef4444;
|
||||
margin-left: 4px;
|
||||
}
|
||||
|
||||
:global(.adm-form-item) {
|
||||
padding: 0;
|
||||
margin-bottom: 20px;
|
||||
|
||||
&::after {
|
||||
display: none;
|
||||
}
|
||||
}
|
||||
|
||||
:global(.adm-form-item-label) {
|
||||
margin-bottom: 8px;
|
||||
padding-bottom: 8px;
|
||||
}
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import React from "react";
|
||||
import { Card, Form, Input } from "antd-mobile";
|
||||
import { Form, Input, TextArea } from "antd-mobile";
|
||||
import styles from "./BasicInfo.module.scss";
|
||||
|
||||
interface BasicInfoProps {
|
||||
@@ -18,46 +18,49 @@ const BasicInfo: React.FC<BasicInfoProps> = ({ data, onChange }) => {
|
||||
|
||||
return (
|
||||
<div className={styles.container}>
|
||||
<Card className={styles.card}>
|
||||
<div className={styles.title}>基本信息</div>
|
||||
<div className={styles.card}>
|
||||
<div className={styles.sectionTitle}>计划设置</div>
|
||||
|
||||
<Form layout="vertical">
|
||||
<Form.Item
|
||||
label={
|
||||
<span className={styles.label}>
|
||||
流量包名称<span className={styles.required}>*</span>
|
||||
计划名称<span className={styles.required}>*</span>
|
||||
</span>
|
||||
}
|
||||
required
|
||||
>
|
||||
<Input
|
||||
placeholder="输入流量包名称"
|
||||
placeholder="请输入计划名称"
|
||||
value={data.name}
|
||||
onChange={value => handleChange("name", value)}
|
||||
className={styles.input}
|
||||
clearable
|
||||
/>
|
||||
</Form.Item>
|
||||
|
||||
<Form.Item label={<span className={styles.label}>描述</span>}>
|
||||
<Form.Item
|
||||
label={<span className={styles.label}>计划描述</span>}
|
||||
>
|
||||
<Input
|
||||
placeholder="输入流量包描述"
|
||||
placeholder="请输入计划描述 (可选)"
|
||||
value={data.description}
|
||||
onChange={value => handleChange("description", value)}
|
||||
className={styles.input}
|
||||
clearable
|
||||
/>
|
||||
</Form.Item>
|
||||
|
||||
<Form.Item label={<span className={styles.label}>备注</span>}>
|
||||
<Input
|
||||
placeholder="输入备注信息 (选填)"
|
||||
<Form.Item
|
||||
label={<span className={styles.label}>详细备注</span>}
|
||||
>
|
||||
<TextArea
|
||||
placeholder="请输入备注说明..."
|
||||
value={data.remarks}
|
||||
onChange={value => handleChange("remarks", value)}
|
||||
className={styles.textarea}
|
||||
rows={3}
|
||||
rows={4}
|
||||
autoSize={{ minRows: 4, maxRows: 8 }}
|
||||
/>
|
||||
</Form.Item>
|
||||
</Form>
|
||||
</Card>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -1,52 +1,49 @@
|
||||
.container {
|
||||
margin-bottom: 24px;
|
||||
}
|
||||
|
||||
.title {
|
||||
font-size: 14px;
|
||||
font-weight: 500;
|
||||
color: #333;
|
||||
margin-bottom: 12px;
|
||||
}
|
||||
|
||||
.conditionList {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 8px;
|
||||
margin-top: 16px;
|
||||
padding: 0 4px;
|
||||
}
|
||||
|
||||
.conditionItem {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
padding: 12px;
|
||||
background: #f8f9fa;
|
||||
border-radius: 6px;
|
||||
border: 1px solid #e9ecef;
|
||||
padding: 14px 16px;
|
||||
background: #fff;
|
||||
border-radius: 12px;
|
||||
border: 1px solid #f3f4f6;
|
||||
margin-bottom: 10px;
|
||||
box-shadow: 0 2px 6px rgba(0, 0, 0, 0.02);
|
||||
transition: all 0.2s ease;
|
||||
|
||||
&:active {
|
||||
background: #f9fafb;
|
||||
transform: scale(0.98);
|
||||
}
|
||||
}
|
||||
|
||||
.conditionContent {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
flex: 1;
|
||||
font-size: 15px;
|
||||
}
|
||||
|
||||
.conditionLabel {
|
||||
font-size: 14px;
|
||||
color: #666;
|
||||
}
|
||||
|
||||
.conditionValue {
|
||||
font-size: 14px;
|
||||
.label {
|
||||
color: #6b7280;
|
||||
margin-right: 10px;
|
||||
font-weight: 500;
|
||||
color: #333;
|
||||
}
|
||||
|
||||
.value {
|
||||
color: #111827;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.removeBtn {
|
||||
color: #ff4d4f;
|
||||
padding: 4px;
|
||||
color: #9ca3af;
|
||||
padding: 6px;
|
||||
--font-size: 20px;
|
||||
transition: color 0.2s;
|
||||
|
||||
&:hover {
|
||||
background-color: #fff2f0;
|
||||
&:active {
|
||||
color: #ef4444;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,65 +1,44 @@
|
||||
import React from "react";
|
||||
import { Button } from "antd-mobile";
|
||||
import { DeleteOutline } from "antd-mobile-icons";
|
||||
import { CloseCircleOutline } from "antd-mobile-icons";
|
||||
import styles from "./ConditionList.module.scss";
|
||||
|
||||
interface FilterCondition {
|
||||
id: string;
|
||||
id?: string;
|
||||
type: string;
|
||||
field?: string;
|
||||
label: string;
|
||||
value: any;
|
||||
operator?: string;
|
||||
displayValue?: any;
|
||||
}
|
||||
|
||||
interface ConditionListProps {
|
||||
conditions: FilterCondition[];
|
||||
onRemove: (id: string) => void;
|
||||
onUpdate: (id: string, value: any) => void;
|
||||
onRemove: (condition: 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;
|
||||
}
|
||||
const ConditionList: React.FC<ConditionListProps> = ({ conditions, onRemove }) => {
|
||||
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}>
|
||||
{conditions.map((condition, index) => (
|
||||
<div key={condition.id || condition.field || index} className={styles.conditionItem}>
|
||||
<div className={styles.conditionContent}>
|
||||
<span className={styles.conditionLabel}>{condition.label}:</span>
|
||||
<span className={styles.conditionValue}>
|
||||
{formatConditionValue(condition)}
|
||||
<span className={styles.label}>{condition.label}</span>
|
||||
<span className={styles.value}>
|
||||
{condition.displayValue || condition.value}
|
||||
</span>
|
||||
</div>
|
||||
<Button
|
||||
size="small"
|
||||
fill="none"
|
||||
onClick={() => onRemove(condition.id)}
|
||||
className={styles.removeBtn}
|
||||
onClick={() => onRemove(condition)}
|
||||
>
|
||||
<DeleteOutline />
|
||||
<CloseCircleOutline />
|
||||
</Button>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -2,79 +2,247 @@
|
||||
height: 100%;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
background: #f4f7f9;
|
||||
}
|
||||
|
||||
.header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
padding: 16px;
|
||||
border-bottom: 1px solid #f0f0f0;
|
||||
padding: 20px 24px;
|
||||
background: #fff;
|
||||
border-bottom: 1px solid rgba(0, 0, 0, 0.05);
|
||||
}
|
||||
|
||||
.title {
|
||||
font-size: 16px;
|
||||
font-weight: 600;
|
||||
color: #333;
|
||||
font-size: 18px;
|
||||
font-weight: 700;
|
||||
color: #1a1a1a;
|
||||
}
|
||||
|
||||
.content {
|
||||
flex: 1;
|
||||
padding: 16px;
|
||||
padding: 20px;
|
||||
overflow-y: auto;
|
||||
}
|
||||
|
||||
.section {
|
||||
margin-bottom: 24px;
|
||||
background: #fff;
|
||||
padding: 20px;
|
||||
border-radius: 16px;
|
||||
border: 1px solid #f3f4f6;
|
||||
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.02);
|
||||
}
|
||||
|
||||
.sectionTitle {
|
||||
font-size: 14px;
|
||||
font-weight: 500;
|
||||
color: #333;
|
||||
margin-bottom: 12px;
|
||||
font-size: 15px;
|
||||
font-weight: 700;
|
||||
color: #4b5563;
|
||||
margin-bottom: 16px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
|
||||
&::before {
|
||||
content: '';
|
||||
width: 3px;
|
||||
height: 14px;
|
||||
background: #007aff;
|
||||
border-radius: 2px;
|
||||
}
|
||||
}
|
||||
|
||||
.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;
|
||||
.tagItem {
|
||||
padding: 14px;
|
||||
border: 1px solid #f3f4f6;
|
||||
border-radius: 12px;
|
||||
text-align: center;
|
||||
font-size: 14px;
|
||||
color: #4b5563;
|
||||
background: #f9fafb;
|
||||
font-weight: 600;
|
||||
transition: all 0.2s ease;
|
||||
|
||||
&:active {
|
||||
background: #f3f4f6;
|
||||
transform: scale(0.98);
|
||||
}
|
||||
|
||||
&.selected {
|
||||
border-color: #007aff;
|
||||
background-color: #f0f7ff;
|
||||
color: #007aff;
|
||||
box-shadow: 0 4px 12px rgba(0, 122, 255, 0.15);
|
||||
}
|
||||
}
|
||||
|
||||
.footer {
|
||||
padding: 16px;
|
||||
border-top: 1px solid #f0f0f0;
|
||||
padding: 16px 24px;
|
||||
padding-bottom: calc(16px + env(safe-area-inset-bottom));
|
||||
background: #fff;
|
||||
border-top: 1px solid #f3f4f6;
|
||||
}
|
||||
|
||||
.submitBtn {
|
||||
height: 50px;
|
||||
border-radius: 12px;
|
||||
font-size: 16px;
|
||||
font-weight: 700;
|
||||
background: #007aff;
|
||||
box-shadow: 0 4px 12px rgba(0, 122, 255, 0.2);
|
||||
}
|
||||
|
||||
// 钻取式地区选择器
|
||||
.regionDrillDown {
|
||||
height: 100%;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
background: #fff;
|
||||
}
|
||||
|
||||
.regionDrillHeader {
|
||||
padding: 16px 20px;
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
border-bottom: 1px solid #f5f5f5;
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
.regionDrillTitle {
|
||||
font-size: 17px;
|
||||
color: #1a1a1a;
|
||||
}
|
||||
|
||||
.backBtn {
|
||||
color: #007aff;
|
||||
font-weight: 700;
|
||||
padding: 0;
|
||||
--font-size: 15px;
|
||||
}
|
||||
|
||||
.closeBtn {
|
||||
color: #999;
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
.regionDrillContent {
|
||||
flex: 1;
|
||||
overflow-y: auto;
|
||||
}
|
||||
|
||||
.flatList {
|
||||
--adm-font-size-main: 16px;
|
||||
|
||||
:global(.adm-list-item-content) {
|
||||
padding: 14px 0;
|
||||
}
|
||||
|
||||
:global(.adm-list-item) {
|
||||
--active-background-color: #f9fafb;
|
||||
}
|
||||
}
|
||||
|
||||
.allProvinceOption {
|
||||
:global(.adm-list-item-content-main) {
|
||||
color: #007aff;
|
||||
font-weight: 700;
|
||||
}
|
||||
}
|
||||
|
||||
.listDivider {
|
||||
padding: 10px 20px;
|
||||
background: #f4f7f9;
|
||||
font-size: 12px;
|
||||
color: #94a3b8;
|
||||
font-weight: 700;
|
||||
text-transform: uppercase;
|
||||
}
|
||||
|
||||
// 统一输入控件样式
|
||||
.inputWrapper {
|
||||
background: #f9fafb;
|
||||
border-radius: 12px;
|
||||
padding: 14px 16px;
|
||||
border: 1px solid transparent;
|
||||
transition: all 0.2s ease;
|
||||
|
||||
&:focus-within {
|
||||
background: #fff;
|
||||
border-color: #007aff;
|
||||
box-shadow: 0 0 0 4px rgba(0, 122, 255, 0.1);
|
||||
}
|
||||
|
||||
:global(.adm-input) {
|
||||
--font-size: 16px;
|
||||
}
|
||||
}
|
||||
|
||||
// 触发器样式
|
||||
.nativeTrigger {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
padding: 16px;
|
||||
background: #f9fafb;
|
||||
border-radius: 12px;
|
||||
font-size: 15px;
|
||||
border: 1px solid transparent;
|
||||
transition: all 0.2s ease;
|
||||
|
||||
&:active {
|
||||
background: #f1f5f9;
|
||||
transform: scale(0.99);
|
||||
}
|
||||
}
|
||||
|
||||
.triggerLabel {
|
||||
color: #64748b;
|
||||
font-weight: 600;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.triggerValueActive {
|
||||
flex: 1;
|
||||
color: #1e293b;
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
.triggerPlaceholder {
|
||||
flex: 1;
|
||||
color: #cbd5e1;
|
||||
}
|
||||
|
||||
.triggerArrow {
|
||||
color: #94a3b8;
|
||||
font-size: 20px;
|
||||
margin-left: 4px;
|
||||
}
|
||||
|
||||
// 强制覆盖边框
|
||||
:global(.adm-input-element), :global(.adm-text-area-element) {
|
||||
border: none !important;
|
||||
background: transparent !important;
|
||||
outline: none !important;
|
||||
box-shadow: none !important;
|
||||
padding: 0 !important;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.searchTip {
|
||||
margin-top: 8px;
|
||||
font-size: 12px;
|
||||
color: #94a3b8;
|
||||
padding-left: 4px;
|
||||
}
|
||||
|
||||
:global(.adm-selector) {
|
||||
--border-radius: 12px;
|
||||
--padding: 12px 16px;
|
||||
}
|
||||
|
||||
@@ -1,6 +1,144 @@
|
||||
import React, { useState } from "react";
|
||||
import { Popup, Form, Input, Selector, Button } from "antd-mobile";
|
||||
import React, { useEffect, useState } from "react";
|
||||
import { Popup, Button, Input, Selector, List } from "antd-mobile";
|
||||
import styles from "./CustomConditionModal.module.scss";
|
||||
import { getIndustryOptions, FilterField } from "../api";
|
||||
|
||||
// 省市数据
|
||||
const areaData = [
|
||||
{ label: '北京', value: '110000', children: [{ label: '北京市', value: '110100' }] },
|
||||
{ label: '天津', value: '120000', children: [{ label: '天津市', value: '120100' }] },
|
||||
{ label: '河北', value: '130000', children: [
|
||||
{ label: '石家庄市', value: '130100' }, { label: '唐山市', value: '130200' },
|
||||
{ label: '秦皇岛市', value: '130300' }, { label: '邯郸市', value: '130400' },
|
||||
{ label: '邢台市', value: '130500' }, { label: '保定市', value: '130600' },
|
||||
{ label: '张家口市', value: '130700' }, { label: '承德市', value: '130800' },
|
||||
{ label: '沧州市', value: '130900' }, { label: '廊坊市', value: '131000' },
|
||||
]},
|
||||
{ label: '山西', value: '140000', children: [
|
||||
{ label: '太原市', value: '140100' }, { label: '大同市', value: '140200' },
|
||||
{ label: '阳泉市', value: '140300' }, { label: '长治市', value: '140400' },
|
||||
{ label: '晋城市', value: '140500' }, { label: '朔州市', value: '140600' },
|
||||
]},
|
||||
{ label: '内蒙古', value: '150000', children: [
|
||||
{ label: '呼和浩特市', value: '150100' }, { label: '包头市', value: '150200' },
|
||||
{ label: '乌海市', value: '150300' }, { label: '赤峰市', value: '150400' },
|
||||
]},
|
||||
{ label: '辽宁', value: '210000', children: [
|
||||
{ label: '沈阳市', value: '210100' }, { label: '大连市', value: '210200' },
|
||||
{ label: '鞍山市', value: '210300' }, { label: '抚顺市', value: '210400' },
|
||||
]},
|
||||
{ label: '吉林', value: '220000', children: [
|
||||
{ label: '长春市', value: '220100' }, { label: '吉林市', value: '220200' },
|
||||
{ label: '四平市', value: '220300' }, { label: '辽源市', value: '220400' },
|
||||
]},
|
||||
{ label: '黑龙江', value: '230000', children: [
|
||||
{ label: '哈尔滨市', value: '230100' }, { label: '齐齐哈尔市', value: '230200' },
|
||||
{ label: '鸡西市', value: '230300' }, { label: '鹤岗市', value: '230400' },
|
||||
]},
|
||||
{ label: '上海', value: '310000', children: [{ label: '上海市', value: '310100' }] },
|
||||
{ label: '江苏', value: '320000', children: [
|
||||
{ label: '南京市', value: '320100' }, { label: '无锡市', value: '320200' },
|
||||
{ label: '徐州市', value: '320300' }, { label: '常州市', value: '320400' },
|
||||
{ label: '苏州市', value: '320500' }, { label: '南通市', value: '320600' },
|
||||
]},
|
||||
{ label: '浙江', value: '330000', children: [
|
||||
{ label: '杭州市', value: '330100' }, { label: '宁波市', value: '330200' },
|
||||
{ label: '温州市', value: '330300' }, { label: '嘉兴市', value: '330400' },
|
||||
{ label: '湖州市', value: '330500' }, { label: '绍兴市', value: '330600' },
|
||||
]},
|
||||
{ label: '安徽', value: '340000', children: [
|
||||
{ label: '合肥市', value: '340100' }, { label: '芜湖市', value: '340200' },
|
||||
{ label: '蚌埠市', value: '340300' }, { label: '淮南市', value: '340400' },
|
||||
]},
|
||||
{ label: '福建', value: '350000', children: [
|
||||
{ label: '福州市', value: '350100' }, { label: '厦门市', value: '350200' },
|
||||
{ label: '莆田市', value: '350300' }, { label: '三明市', value: '350400' },
|
||||
{ label: '泉州市', value: '350500' }, { label: '漳州市', value: '350600' },
|
||||
]},
|
||||
{ label: '江西', value: '360000', children: [
|
||||
{ label: '南昌市', value: '360100' }, { label: '景德镇市', value: '360200' },
|
||||
{ label: '萍乡市', value: '360300' }, { label: '九江市', value: '360400' },
|
||||
]},
|
||||
{ label: '山东', value: '370000', children: [
|
||||
{ label: '济南市', value: '370100' }, { label: '青岛市', value: '370200' },
|
||||
{ label: '淄博市', value: '370300' }, { label: '枣庄市', value: '370400' },
|
||||
{ label: '东营市', value: '370500' }, { label: '烟台市', value: '370600' },
|
||||
]},
|
||||
{ label: '河南', value: '410000', children: [
|
||||
{ label: '郑州市', value: '410100' }, { label: '开封市', value: '410200' },
|
||||
{ label: '洛阳市', value: '410300' }, { label: '平顶山市', value: '410400' },
|
||||
{ label: '安阳市', value: '410500' }, { label: '鹤壁市', value: '410600' },
|
||||
{ label: '新乡市', value: '410700' }, { label: '焦作市', value: '410800' },
|
||||
{ label: '濮阳市', value: '410900' }, { label: '许昌市', value: '411000' },
|
||||
{ label: '漯河市', value: '411100' }, { label: '三门峡市', value: '411200' },
|
||||
{ label: '南阳市', value: '411300' }, { label: '商丘市', value: '411400' },
|
||||
{ label: '信阳市', value: '411500' }, { label: '周口市', value: '411600' },
|
||||
{ label: '驻马店市', value: '411700' },
|
||||
]},
|
||||
{ label: '湖北', value: '420000', children: [
|
||||
{ label: '武汉市', value: '420100' }, { label: '黄石市', value: '420200' },
|
||||
{ label: '十堰市', value: '420300' }, { label: '宜昌市', value: '420500' },
|
||||
]},
|
||||
{ label: '湖南', value: '430000', children: [
|
||||
{ label: '长沙市', value: '430100' }, { label: '株洲市', value: '430200' },
|
||||
{ label: '湘潭市', value: '430300' }, { label: '衡阳市', value: '430400' },
|
||||
]},
|
||||
{ label: '广东', value: '440000', children: [
|
||||
{ label: '广州市', value: '440100' }, { label: '韶关市', value: '440200' },
|
||||
{ label: '深圳市', value: '440300' }, { label: '珠海市', value: '440400' },
|
||||
{ label: '汕头市', value: '440500' }, { label: '佛山市', value: '440600' },
|
||||
{ label: '江门市', value: '440700' }, { label: '湛江市', value: '440800' },
|
||||
{ label: '茂名市', value: '440900' }, { label: '肇庆市', value: '441200' },
|
||||
{ label: '惠州市', value: '441300' }, { label: '梅州市', value: '441400' },
|
||||
{ label: '汕尾市', value: '441500' }, { label: '河源市', value: '441600' },
|
||||
{ label: '阳江市', value: '441700' }, { label: '清远市', value: '441800' },
|
||||
{ label: '东莞市', value: '441900' }, { label: '中山市', value: '442000' },
|
||||
]},
|
||||
{ label: '广西', value: '450000', children: [
|
||||
{ label: '南宁市', value: '450100' }, { label: '柳州市', value: '450200' },
|
||||
{ label: '桂林市', value: '450300' }, { label: '梧州市', value: '450400' },
|
||||
]},
|
||||
{ label: '海南', value: '460000', children: [
|
||||
{ label: '海口市', value: '460100' }, { label: '三亚市', value: '460200' },
|
||||
]},
|
||||
{ label: '重庆', value: '500000', children: [{ label: '重庆市', value: '500100' }] },
|
||||
{ label: '四川', value: '510000', children: [
|
||||
{ label: '成都市', value: '510100' }, { label: '自贡市', value: '510300' },
|
||||
{ label: '攀枝花市', value: '510400' }, { label: '泸州市', value: '510500' },
|
||||
{ label: '德阳市', value: '510600' }, { label: '绵阳市', value: '510700' },
|
||||
]},
|
||||
{ label: '贵州', value: '520000', children: [
|
||||
{ label: '贵阳市', value: '520100' }, { label: '六盘水市', value: '520200' },
|
||||
{ label: '遵义市', value: '520300' }, { label: '安顺市', value: '520400' },
|
||||
]},
|
||||
{ label: '云南', value: '530000', children: [
|
||||
{ label: '昆明市', value: '530100' }, { label: '曲靖市', value: '530300' },
|
||||
{ label: '玉溪市', value: '530400' }, { label: '保山市', value: '530500' },
|
||||
]},
|
||||
{ label: '西藏', value: '540000', children: [
|
||||
{ label: '拉萨市', value: '540100' }, { label: '日喀则市', value: '540200' },
|
||||
]},
|
||||
{ label: '陕西', value: '610000', children: [
|
||||
{ label: '西安市', value: '610100' }, { label: '铜川市', value: '610200' },
|
||||
{ label: '宝鸡市', value: '610300' }, { label: '咸阳市', value: '610400' },
|
||||
]},
|
||||
{ label: '甘肃', value: '620000', children: [
|
||||
{ label: '兰州市', value: '620100' }, { label: '嘉峪关市', value: '620200' },
|
||||
{ label: '金昌市', value: '620300' }, { label: '白银市', value: '620400' },
|
||||
]},
|
||||
{ label: '青海', value: '630000', children: [
|
||||
{ label: '西宁市', value: '630100' }, { label: '海东市', value: '630200' },
|
||||
]},
|
||||
{ label: '宁夏', value: '640000', children: [
|
||||
{ label: '银川市', value: '640100' }, { label: '石嘴山市', value: '640200' },
|
||||
]},
|
||||
{ label: '新疆', value: '650000', children: [
|
||||
{ label: '乌鲁木齐市', value: '650100' }, { label: '克拉玛依市', value: '650200' },
|
||||
]},
|
||||
{ label: '台湾', value: '710000', children: [{ label: '台北市', value: '710100' }] },
|
||||
{ label: '香港', value: '810000', children: [{ label: '香港特别行政区', value: '810100' }] },
|
||||
{ label: '澳门', value: '820000', children: [{ label: '澳门特别行政区', value: '820100' }] },
|
||||
];
|
||||
|
||||
interface CustomConditionModalProps {
|
||||
visible: boolean;
|
||||
@@ -8,111 +146,31 @@ interface CustomConditionModalProps {
|
||||
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 [fields, setFields] = useState<FilterField[]>([]);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [selectedTag, setSelectedTag] = useState<FilterField | null>(null);
|
||||
const [conditionValue, setConditionValue] = useState<any>(null);
|
||||
|
||||
const handleTagSelect = (tag: any) => {
|
||||
// 地区选择器状态
|
||||
const [regionVisible, setRegionVisible] = useState(false);
|
||||
const [regionStep, setRegionStep] = useState<'province' | 'city'>('province');
|
||||
const [tempProvince, setTempProvince] = useState<any>(null);
|
||||
|
||||
useEffect(() => {
|
||||
if (visible) {
|
||||
setLoading(true);
|
||||
getIndustryOptions()
|
||||
.then(res => setFields(res || []))
|
||||
.finally(() => setLoading(false));
|
||||
}
|
||||
}, [visible]);
|
||||
|
||||
const handleTagSelect = (tag: FilterField) => {
|
||||
setSelectedTag(tag);
|
||||
setConditionValue(null);
|
||||
};
|
||||
@@ -122,13 +180,29 @@ const CustomConditionModal: React.FC<CustomConditionModalProps> = ({
|
||||
};
|
||||
|
||||
const handleSubmit = () => {
|
||||
if (!selectedTag || !conditionValue) return;
|
||||
if (!selectedTag || (conditionValue === null || conditionValue === undefined || conditionValue === '')) return;
|
||||
|
||||
let displayValue = conditionValue;
|
||||
let operator = '=';
|
||||
|
||||
if (selectedTag.type === 'select' && selectedTag.options) {
|
||||
const option = selectedTag.options.find((opt: any) => opt.value === conditionValue);
|
||||
displayValue = option ? option.label : conditionValue;
|
||||
} else if (selectedTag.type === 'number') {
|
||||
operator = '>=';
|
||||
} else if (selectedTag.type === 'friend_search') {
|
||||
operator = 'like';
|
||||
displayValue = `包含"${conditionValue}"`;
|
||||
}
|
||||
|
||||
const condition = {
|
||||
id: `${selectedTag.id}_${Date.now()}`,
|
||||
type: selectedTag.type,
|
||||
label: selectedTag.name,
|
||||
type: "field",
|
||||
field: selectedTag.field,
|
||||
operator: operator,
|
||||
value: conditionValue,
|
||||
displayValue: displayValue,
|
||||
label: selectedTag.label,
|
||||
fieldType: selectedTag.type,
|
||||
};
|
||||
|
||||
onAdd(condition);
|
||||
@@ -137,48 +211,161 @@ const CustomConditionModal: React.FC<CustomConditionModalProps> = ({
|
||||
setConditionValue(null);
|
||||
};
|
||||
|
||||
const renderRegionPicker = () => {
|
||||
return (
|
||||
<Popup
|
||||
visible={regionVisible}
|
||||
onMaskClick={() => setRegionVisible(false)}
|
||||
position="bottom"
|
||||
bodyStyle={{ height: '70vh', borderTopLeftRadius: '20px', borderTopRightRadius: '20px' }}
|
||||
>
|
||||
<div className={styles.regionDrillDown}>
|
||||
<div className={styles.regionDrillHeader}>
|
||||
{regionStep === 'city' ? (
|
||||
<Button
|
||||
fill="none"
|
||||
size="small"
|
||||
onClick={() => setRegionStep('province')}
|
||||
className={styles.backBtn}
|
||||
>
|
||||
← 返回省份
|
||||
</Button>
|
||||
) : (
|
||||
<span className={styles.regionDrillTitle}>选择地区</span>
|
||||
)}
|
||||
<Button fill="none" size="small" onClick={() => setRegionVisible(false)} className={styles.closeBtn}>取消</Button>
|
||||
</div>
|
||||
|
||||
<div className={styles.regionDrillContent}>
|
||||
{regionStep === 'province' ? (
|
||||
<List className={styles.flatList}>
|
||||
{areaData.map(item => (
|
||||
<List.Item
|
||||
key={item.value}
|
||||
onClick={() => {
|
||||
const provinceName = item.label.replace(/省$/, '');
|
||||
setTempProvince({ ...item, label: provinceName });
|
||||
setRegionStep('city');
|
||||
}}
|
||||
arrow={true}
|
||||
>
|
||||
{item.label}
|
||||
</List.Item>
|
||||
))}
|
||||
</List>
|
||||
) : (
|
||||
<List className={styles.flatList}>
|
||||
<List.Item
|
||||
onClick={() => {
|
||||
handleValueChange(tempProvince.label);
|
||||
setRegionVisible(false);
|
||||
}}
|
||||
className={styles.allProvinceOption}
|
||||
arrow={false}
|
||||
>
|
||||
<span className={styles.primaryText}>选择全省:{tempProvince.label}</span>
|
||||
</List.Item>
|
||||
|
||||
<div className={styles.listDivider}>选择城市</div>
|
||||
|
||||
{tempProvince.children?.map((item: any) => (
|
||||
<List.Item
|
||||
key={item.value}
|
||||
onClick={() => {
|
||||
const cityName = item.label.replace(/市$/, '');
|
||||
handleValueChange(`${tempProvince.label} ${cityName}`);
|
||||
setRegionVisible(false);
|
||||
}}
|
||||
arrow={false}
|
||||
>
|
||||
{item.label}
|
||||
</List.Item>
|
||||
))}
|
||||
</List>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</Popup>
|
||||
);
|
||||
};
|
||||
|
||||
const renderValueInput = () => {
|
||||
if (!selectedTag) return null;
|
||||
|
||||
switch (selectedTag.type) {
|
||||
case "range":
|
||||
case "friend_search":
|
||||
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 style={{ marginTop: '12px' }}>
|
||||
<div className={styles.inputWrapper}>
|
||||
<Input
|
||||
placeholder={selectedTag.placeholder || "输入昵称、微信号、手机号搜索"}
|
||||
value={conditionValue}
|
||||
onChange={handleValueChange}
|
||||
clearable
|
||||
/>
|
||||
</div>
|
||||
<div className={styles.searchTip}>
|
||||
输入关键词后,将筛选出匹配的好友
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
case "province":
|
||||
return (
|
||||
<div style={{ marginTop: '12px' }}>
|
||||
<div
|
||||
className={styles.nativeTrigger}
|
||||
onClick={() => {
|
||||
setRegionVisible(true);
|
||||
setRegionStep('province');
|
||||
}}
|
||||
>
|
||||
<span className={styles.triggerLabel}>地区:</span>
|
||||
<span className={conditionValue ? styles.triggerValueActive : styles.triggerPlaceholder}>
|
||||
{conditionValue || '请选择'}
|
||||
</span>
|
||||
<span className={styles.triggerArrow}>›</span>
|
||||
</div>
|
||||
{renderRegionPicker()}
|
||||
</div>
|
||||
);
|
||||
|
||||
case "select":
|
||||
return (
|
||||
<Selector
|
||||
options={selectedTag.options}
|
||||
value={conditionValue ? [conditionValue] : []}
|
||||
onChange={value => handleValueChange(value[0])}
|
||||
multiple={false}
|
||||
columns={2}
|
||||
options={selectedTag.options || []}
|
||||
value={[conditionValue]}
|
||||
onChange={v => handleValueChange(v[0])}
|
||||
style={{
|
||||
'--border-radius': '12px',
|
||||
'--padding': '12px 16px',
|
||||
}}
|
||||
/>
|
||||
);
|
||||
|
||||
case "number":
|
||||
return (
|
||||
<div className={styles.inputWrapper}>
|
||||
<Input
|
||||
placeholder={`请输入${selectedTag.label}数值`}
|
||||
type="number"
|
||||
value={conditionValue}
|
||||
onChange={handleValueChange}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
|
||||
case "input":
|
||||
default:
|
||||
return (
|
||||
<Input
|
||||
placeholder="请输入值"
|
||||
value={conditionValue}
|
||||
onChange={handleValueChange}
|
||||
/>
|
||||
<div className={styles.inputWrapper}>
|
||||
<Input
|
||||
placeholder={`请输入${selectedTag.label}`}
|
||||
value={conditionValue}
|
||||
onChange={handleValueChange}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
};
|
||||
@@ -188,37 +375,43 @@ const CustomConditionModal: React.FC<CustomConditionModalProps> = ({
|
||||
visible={visible}
|
||||
onMaskClick={onClose}
|
||||
position="bottom"
|
||||
bodyStyle={{ height: "70vh" }}
|
||||
bodyStyle={{ height: "80vh", borderTopLeftRadius: '24px', borderTopRightRadius: '24px' }}
|
||||
>
|
||||
<div className={styles.container}>
|
||||
<div className={styles.header}>
|
||||
<div className={styles.title}>添加自定义条件</div>
|
||||
<Button size="small" fill="none" onClick={onClose}>
|
||||
<div className={styles.title}>添加筛选条件</div>
|
||||
<Button size="small" fill="none" onClick={onClose} style={{ color: '#666' }}>
|
||||
取消
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<div className={styles.content}>
|
||||
<div className={styles.section}>
|
||||
<div className={styles.sectionTitle}>选择标签</div>
|
||||
<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}
|
||||
{loading ? (
|
||||
<div style={{ textAlign: "center", padding: "40px", color: "#9ca3af" }}>
|
||||
加载中...
|
||||
</div>
|
||||
))}
|
||||
) : (
|
||||
fields.map(field => (
|
||||
<div
|
||||
key={field.field}
|
||||
className={`${styles.tagItem} ${
|
||||
selectedTag?.field === field.field ? styles.selected : ""
|
||||
}`}
|
||||
onClick={() => handleTagSelect(field)}
|
||||
>
|
||||
{field.label}
|
||||
</div>
|
||||
))
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{selectedTag && (
|
||||
<div className={styles.section}>
|
||||
<div className={styles.sectionTitle}>设置条件</div>
|
||||
<div className={styles.sectionTitle}>设定条件</div>
|
||||
{renderValueInput()}
|
||||
</div>
|
||||
)}
|
||||
@@ -228,10 +421,11 @@ const CustomConditionModal: React.FC<CustomConditionModalProps> = ({
|
||||
<Button
|
||||
color="primary"
|
||||
block
|
||||
disabled={!selectedTag || !conditionValue}
|
||||
disabled={!selectedTag || (conditionValue === null || conditionValue === undefined || conditionValue === '')}
|
||||
onClick={handleSubmit}
|
||||
className={styles.submitBtn}
|
||||
>
|
||||
添加条件
|
||||
确认添加
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -0,0 +1,153 @@
|
||||
.container {
|
||||
height: 100%;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
background: #fff;
|
||||
}
|
||||
|
||||
.header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
padding: 16px 20px;
|
||||
border-bottom: 1px solid #f5f5f5;
|
||||
}
|
||||
|
||||
.title {
|
||||
font-size: 18px;
|
||||
font-weight: 700;
|
||||
color: #1a1a1a;
|
||||
}
|
||||
|
||||
.searchWrapper {
|
||||
padding: 12px 16px;
|
||||
background: #f9fafb;
|
||||
|
||||
:global(.adm-search-bar) {
|
||||
--background: #fff;
|
||||
--border-radius: 12px;
|
||||
--height: 44px;
|
||||
}
|
||||
}
|
||||
|
||||
.selectedBar {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
padding: 8px 16px;
|
||||
background: #f0f7ff;
|
||||
border-bottom: 1px solid #e0efff;
|
||||
}
|
||||
|
||||
.selectedCount {
|
||||
font-size: 14px;
|
||||
color: #007aff;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.listHeader {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
padding: 12px 16px;
|
||||
background: #fff;
|
||||
border-bottom: 1px solid #f3f4f6;
|
||||
}
|
||||
|
||||
.totalCount {
|
||||
font-size: 13px;
|
||||
color: #9ca3af;
|
||||
}
|
||||
|
||||
.friendList {
|
||||
flex: 1;
|
||||
overflow-y: auto;
|
||||
padding: 8px 16px;
|
||||
}
|
||||
|
||||
.loadingContainer {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
padding: 60px 0;
|
||||
gap: 12px;
|
||||
color: #9ca3af;
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
.friendItem {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
padding: 12px;
|
||||
gap: 12px;
|
||||
background: #fff;
|
||||
border-radius: 12px;
|
||||
margin-bottom: 8px;
|
||||
border: 1px solid #f3f4f6;
|
||||
transition: all 0.2s ease;
|
||||
|
||||
&:active {
|
||||
background: #f9fafb;
|
||||
}
|
||||
|
||||
&.selected {
|
||||
background: #f0f7ff;
|
||||
border-color: #007aff;
|
||||
}
|
||||
}
|
||||
|
||||
.avatar {
|
||||
--size: 44px;
|
||||
border-radius: 10px;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.friendInfo {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.friendName {
|
||||
font-size: 15px;
|
||||
font-weight: 600;
|
||||
color: #1a1a1a;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.wechatId {
|
||||
font-size: 12px;
|
||||
color: #9ca3af;
|
||||
margin-top: 2px;
|
||||
}
|
||||
|
||||
.tags {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 4px;
|
||||
margin-top: 4px;
|
||||
}
|
||||
|
||||
.tag {
|
||||
font-size: 10px;
|
||||
padding: 2px 6px;
|
||||
background: #f3f4f6;
|
||||
color: #6b7280;
|
||||
border-radius: 4px;
|
||||
}
|
||||
|
||||
.footer {
|
||||
padding: 16px;
|
||||
padding-bottom: calc(16px + env(safe-area-inset-bottom));
|
||||
background: #fff;
|
||||
border-top: 1px solid #f3f4f6;
|
||||
}
|
||||
|
||||
.confirmBtn {
|
||||
height: 48px;
|
||||
border-radius: 12px;
|
||||
font-size: 16px;
|
||||
font-weight: 600;
|
||||
}
|
||||
@@ -0,0 +1,248 @@
|
||||
import React, { useState, useRef, useEffect, useCallback } from "react";
|
||||
import { Popup, Button, SearchBar, Checkbox, Avatar, SpinLoading, Empty } from "antd-mobile";
|
||||
import styles from "./FriendSearchModal.module.scss";
|
||||
import { getUsersByFilter } from "../api";
|
||||
|
||||
interface Friend {
|
||||
id: string;
|
||||
name: string;
|
||||
avatar: string;
|
||||
wechatId: string;
|
||||
tags: string[];
|
||||
}
|
||||
|
||||
interface FriendSearchModalProps {
|
||||
visible: boolean;
|
||||
onClose: () => void;
|
||||
onConfirm: (friends: Friend[]) => void;
|
||||
existingConditions: any[]; // 已有的筛选条件
|
||||
selectedFriends?: Friend[]; // 已选中的好友
|
||||
}
|
||||
|
||||
const FriendSearchModal: React.FC<FriendSearchModalProps> = ({
|
||||
visible,
|
||||
onClose,
|
||||
onConfirm,
|
||||
existingConditions,
|
||||
selectedFriends = [],
|
||||
}) => {
|
||||
const [keyword, setKeyword] = useState("");
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [friends, setFriends] = useState<Friend[]>([]);
|
||||
const [selected, setSelected] = useState<Friend[]>(selectedFriends);
|
||||
const [total, setTotal] = useState(0);
|
||||
const debounceTimer = useRef<NodeJS.Timeout | null>(null);
|
||||
|
||||
// 初始化已选中的好友
|
||||
useEffect(() => {
|
||||
if (visible) {
|
||||
setSelected(selectedFriends);
|
||||
// 自动加载一批数据
|
||||
searchFriends("");
|
||||
}
|
||||
}, [visible, selectedFriends]);
|
||||
|
||||
// 搜索好友
|
||||
const searchFriends = useCallback(async (searchKeyword: string) => {
|
||||
setLoading(true);
|
||||
try {
|
||||
// 构造 ruleConfig,包含已有条件
|
||||
const conditions = existingConditions.filter(c => c.field !== 'friendIds'); // 排除已有的好友ID条件
|
||||
|
||||
const ruleConfig: any = {
|
||||
logic: "AND",
|
||||
conditions: conditions,
|
||||
};
|
||||
|
||||
// 添加搜索关键词
|
||||
if (searchKeyword) {
|
||||
ruleConfig.keyword = searchKeyword;
|
||||
}
|
||||
|
||||
const result = await getUsersByFilter({
|
||||
ruleConfig,
|
||||
page: 1,
|
||||
pageSize: 50,
|
||||
});
|
||||
|
||||
// 转换数据格式
|
||||
const friendList: Friend[] = result.list.map((user: any) => ({
|
||||
id: String(user.id),
|
||||
name: user.nickname || user.wechatId || user.identifier,
|
||||
avatar: user.avatar || `https://api.dicebear.com/7.x/avataaars/svg?seed=${user.id}`,
|
||||
wechatId: user.wechatId || user.wechatAlias || "",
|
||||
tags: Array.isArray(user.tags)
|
||||
? user.tags.map((tag: any) => typeof tag === 'string' ? tag : tag.tagName || '').slice(0, 2)
|
||||
: [],
|
||||
}));
|
||||
|
||||
setFriends(friendList);
|
||||
setTotal(result.total);
|
||||
} catch (error) {
|
||||
console.error("搜索好友失败:", error);
|
||||
setFriends([]);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}, [existingConditions]);
|
||||
|
||||
// 搜索防抖
|
||||
const handleSearch = (value: string) => {
|
||||
setKeyword(value);
|
||||
|
||||
if (debounceTimer.current) {
|
||||
clearTimeout(debounceTimer.current);
|
||||
}
|
||||
|
||||
debounceTimer.current = setTimeout(() => {
|
||||
searchFriends(value);
|
||||
}, 500);
|
||||
};
|
||||
|
||||
// 切换选中状态
|
||||
const toggleSelect = (friend: Friend) => {
|
||||
setSelected(prev => {
|
||||
const exists = prev.find(f => f.id === friend.id);
|
||||
if (exists) {
|
||||
return prev.filter(f => f.id !== friend.id);
|
||||
} else {
|
||||
return [...prev, friend];
|
||||
}
|
||||
});
|
||||
};
|
||||
|
||||
// 全选当前页
|
||||
const selectAll = () => {
|
||||
const newSelected = [...selected];
|
||||
friends.forEach(friend => {
|
||||
if (!newSelected.find(f => f.id === friend.id)) {
|
||||
newSelected.push(friend);
|
||||
}
|
||||
});
|
||||
setSelected(newSelected);
|
||||
};
|
||||
|
||||
// 取消全选
|
||||
const deselectAll = () => {
|
||||
const friendIds = friends.map(f => f.id);
|
||||
setSelected(prev => prev.filter(f => !friendIds.includes(f.id)));
|
||||
};
|
||||
|
||||
// 确认选择
|
||||
const handleConfirm = () => {
|
||||
onConfirm(selected);
|
||||
onClose();
|
||||
};
|
||||
|
||||
// 清理
|
||||
useEffect(() => {
|
||||
return () => {
|
||||
if (debounceTimer.current) {
|
||||
clearTimeout(debounceTimer.current);
|
||||
}
|
||||
};
|
||||
}, []);
|
||||
|
||||
const allCurrentPageSelected = friends.length > 0 && friends.every(f => selected.find(s => s.id === f.id));
|
||||
|
||||
return (
|
||||
<Popup
|
||||
visible={visible}
|
||||
onMaskClick={onClose}
|
||||
position="bottom"
|
||||
bodyStyle={{ height: "85vh", borderTopLeftRadius: '24px', borderTopRightRadius: '24px' }}
|
||||
>
|
||||
<div className={styles.container}>
|
||||
<div className={styles.header}>
|
||||
<div className={styles.title}>选择好友</div>
|
||||
<Button size="small" fill="none" onClick={onClose} style={{ color: '#666' }}>
|
||||
取消
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<div className={styles.searchWrapper}>
|
||||
<SearchBar
|
||||
placeholder="搜索昵称、微信号、手机号..."
|
||||
value={keyword}
|
||||
onChange={handleSearch}
|
||||
onClear={() => {
|
||||
setKeyword("");
|
||||
searchFriends("");
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{selected.length > 0 && (
|
||||
<div className={styles.selectedBar}>
|
||||
<span className={styles.selectedCount}>已选 {selected.length} 人</span>
|
||||
<Button size="mini" fill="none" onClick={() => setSelected([])}>
|
||||
清空
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className={styles.listHeader}>
|
||||
<Checkbox
|
||||
checked={allCurrentPageSelected}
|
||||
onChange={checked => checked ? selectAll() : deselectAll()}
|
||||
>
|
||||
全选当前页
|
||||
</Checkbox>
|
||||
<span className={styles.totalCount}>共 {total} 人</span>
|
||||
</div>
|
||||
|
||||
<div className={styles.friendList}>
|
||||
{loading ? (
|
||||
<div className={styles.loadingContainer}>
|
||||
<SpinLoading style={{ "--size": "32px" }} />
|
||||
<span>搜索中...</span>
|
||||
</div>
|
||||
) : friends.length === 0 ? (
|
||||
<Empty description={keyword ? "未找到匹配的好友" : "暂无数据"} />
|
||||
) : (
|
||||
friends.map(friend => {
|
||||
const isSelected = !!selected.find(f => f.id === friend.id);
|
||||
return (
|
||||
<div
|
||||
key={friend.id}
|
||||
className={`${styles.friendItem} ${isSelected ? styles.selected : ''}`}
|
||||
onClick={() => toggleSelect(friend)}
|
||||
>
|
||||
<Checkbox checked={isSelected} />
|
||||
<Avatar src={friend.avatar} className={styles.avatar} />
|
||||
<div className={styles.friendInfo}>
|
||||
<div className={styles.friendName}>{friend.name}</div>
|
||||
{friend.wechatId && (
|
||||
<div className={styles.wechatId}>{friend.wechatId}</div>
|
||||
)}
|
||||
{friend.tags.length > 0 && (
|
||||
<div className={styles.tags}>
|
||||
{friend.tags.map((tag, idx) => (
|
||||
<span key={idx} className={styles.tag}>{tag}</span>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className={styles.footer}>
|
||||
<Button
|
||||
color="primary"
|
||||
block
|
||||
disabled={selected.length === 0}
|
||||
onClick={handleConfirm}
|
||||
className={styles.confirmBtn}
|
||||
>
|
||||
确认选择 ({selected.length})
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</Popup>
|
||||
);
|
||||
};
|
||||
|
||||
export default FriendSearchModal;
|
||||
@@ -1,49 +1,60 @@
|
||||
.container {
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
.card {
|
||||
border-radius: 8px;
|
||||
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.04);
|
||||
padding: 12px;
|
||||
}
|
||||
|
||||
.header {
|
||||
padding: 16px 8px;
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
|
||||
.title {
|
||||
font-size: 16px;
|
||||
font-weight: 600;
|
||||
color: #333;
|
||||
font-size: 18px;
|
||||
font-weight: 700;
|
||||
color: #1a1a1a;
|
||||
}
|
||||
|
||||
.userCount {
|
||||
font-size: 14px;
|
||||
color: #1677ff;
|
||||
font-weight: 500;
|
||||
color: #007aff;
|
||||
font-weight: 600;
|
||||
background: rgba(0, 122, 255, 0.1);
|
||||
padding: 4px 12px;
|
||||
border-radius: 20px;
|
||||
}
|
||||
|
||||
.batchActions {
|
||||
.searchWrapper {
|
||||
margin-bottom: 12px;
|
||||
|
||||
:global(.adm-search-bar) {
|
||||
--background: #f5f5f5;
|
||||
--border-radius: 12px;
|
||||
--height: 44px;
|
||||
--padding-left: 12px;
|
||||
}
|
||||
}
|
||||
|
||||
.loadingContainer {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
padding: 60px 0;
|
||||
gap: 12px;
|
||||
color: #8e8e93;
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
.batchBar {
|
||||
padding: 12px 16px;
|
||||
background: #fff;
|
||||
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;
|
||||
border-radius: 12px;
|
||||
margin-bottom: 12px;
|
||||
border: 1px solid #f3f4f6;
|
||||
}
|
||||
|
||||
.userList {
|
||||
@@ -55,18 +66,24 @@
|
||||
.userItem {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
padding: 16px;
|
||||
gap: 12px;
|
||||
padding: 12px;
|
||||
background: #f8f9fa;
|
||||
border-radius: 8px;
|
||||
border: 1px solid #e9ecef;
|
||||
background: #fff;
|
||||
border-radius: 16px;
|
||||
border: 1px solid #f3f4f6;
|
||||
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.02);
|
||||
transition: all 0.2s ease;
|
||||
|
||||
&:active {
|
||||
background: #f9fafb;
|
||||
transform: scale(0.98);
|
||||
}
|
||||
}
|
||||
|
||||
.userCheckbox {
|
||||
margin-top: 4px;
|
||||
}
|
||||
|
||||
.userAvatar {
|
||||
.avatar {
|
||||
--size: 48px;
|
||||
border-radius: 12px;
|
||||
box-shadow: 0 4px 10px rgba(0, 0, 0, 0.05);
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
@@ -75,52 +92,80 @@
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.userName {
|
||||
font-size: 16px;
|
||||
font-weight: 600;
|
||||
color: #333;
|
||||
.nameRow {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
margin-bottom: 4px;
|
||||
}
|
||||
|
||||
.userId {
|
||||
font-size: 12px;
|
||||
color: #666;
|
||||
margin-bottom: 8px;
|
||||
.userName {
|
||||
font-size: 16px;
|
||||
font-weight: 700;
|
||||
color: #111827;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.userTags {
|
||||
.badge {
|
||||
font-size: 10px;
|
||||
padding: 2px 8px;
|
||||
border-radius: 6px;
|
||||
font-weight: 700;
|
||||
text-transform: uppercase;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.metaRow {
|
||||
font-size: 13px;
|
||||
color: #6b7280;
|
||||
font-weight: 500;
|
||||
margin-bottom: 6px;
|
||||
}
|
||||
|
||||
.tagsRow {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 4px;
|
||||
margin-bottom: 8px;
|
||||
gap: 6px;
|
||||
}
|
||||
|
||||
.tag {
|
||||
background: #e6f7ff;
|
||||
color: #1677ff;
|
||||
padding: 2px 6px;
|
||||
border-radius: 8px;
|
||||
font-size: 10px;
|
||||
.tagItem {
|
||||
font-size: 11px;
|
||||
padding: 2px 8px;
|
||||
background: #f0f7ff;
|
||||
color: #007aff;
|
||||
border-radius: 4px;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.userStats {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.statItem {
|
||||
font-size: 12px;
|
||||
color: #666;
|
||||
.tagMore {
|
||||
font-size: 11px;
|
||||
padding: 2px 8px;
|
||||
background: #f5f5f5;
|
||||
color: #8e8e93;
|
||||
border-radius: 4px;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.removeBtn {
|
||||
color: #ff4d4f;
|
||||
padding: 4px;
|
||||
color: #d1d5db;
|
||||
padding: 8px;
|
||||
--font-size: 22px;
|
||||
transition: color 0.2s;
|
||||
flex-shrink: 0;
|
||||
|
||||
&:hover {
|
||||
background-color: #fff2f0;
|
||||
&:active {
|
||||
color: #ef4444;
|
||||
}
|
||||
}
|
||||
|
||||
.loadingMore {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 8px;
|
||||
padding: 16px 0;
|
||||
color: #8e8e93;
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import React, { useState } from "react";
|
||||
import { Card, Avatar, Button, Checkbox, Empty } from "antd-mobile";
|
||||
import { DeleteOutline } from "antd-mobile-icons";
|
||||
import React, { useState, useRef, useEffect } from "react";
|
||||
import { Avatar, Button, Checkbox, Empty, SearchBar, InfiniteScroll, SpinLoading } from "antd-mobile";
|
||||
import { CloseCircleOutline } from "antd-mobile-icons";
|
||||
import styles from "./UserListPreview.module.scss";
|
||||
|
||||
interface User {
|
||||
@@ -15,139 +15,176 @@ interface User {
|
||||
|
||||
interface UserListPreviewProps {
|
||||
users: User[];
|
||||
total?: number;
|
||||
loading?: boolean;
|
||||
hasMore?: boolean;
|
||||
onRemoveUser: (userId: string) => void;
|
||||
onSearch?: (keyword: string) => void;
|
||||
onLoadMore?: () => Promise<void>;
|
||||
}
|
||||
|
||||
const UserListPreview: React.FC<UserListPreviewProps> = ({
|
||||
users,
|
||||
total = 0,
|
||||
loading = false,
|
||||
hasMore = false,
|
||||
onRemoveUser,
|
||||
onSearch,
|
||||
onLoadMore,
|
||||
}) => {
|
||||
const [selectedUsers, setSelectedUsers] = useState<string[]>([]);
|
||||
const [searchKeyword, setSearchKeyword] = useState("");
|
||||
const debounceTimer = useRef<NodeJS.Timeout | null>(null);
|
||||
|
||||
const handleSelectAll = (checked: boolean) => {
|
||||
if (checked) {
|
||||
setSelectedUsers(users.map(user => user.id));
|
||||
} else {
|
||||
setSelectedUsers([]);
|
||||
// 搜索防抖
|
||||
const handleSearchChange = (value: string) => {
|
||||
setSearchKeyword(value);
|
||||
|
||||
if (debounceTimer.current) {
|
||||
clearTimeout(debounceTimer.current);
|
||||
}
|
||||
|
||||
debounceTimer.current = setTimeout(() => {
|
||||
onSearch?.(value);
|
||||
}, 500);
|
||||
};
|
||||
|
||||
const handleSelectUser = (userId: string, checked: boolean) => {
|
||||
if (checked) {
|
||||
setSelectedUsers(prev => [...prev, userId]);
|
||||
} else {
|
||||
setSelectedUsers(prev => prev.filter(id => id !== userId));
|
||||
}
|
||||
// 清理定时器
|
||||
useEffect(() => {
|
||||
return () => {
|
||||
if (debounceTimer.current) {
|
||||
clearTimeout(debounceTimer.current);
|
||||
}
|
||||
};
|
||||
}, []);
|
||||
|
||||
const getRfmBadge = (score: number) => {
|
||||
if (score >= 12) return { text: "CORE", color: "#ff3b30", bg: "#fff2f1" };
|
||||
if (score >= 8) return { text: "HIGH", color: "#ff9500", bg: "#fff9f2" };
|
||||
return { text: "USER", color: "#8e8e93", bg: "#f2f2f7" };
|
||||
};
|
||||
|
||||
const handleRemoveSelected = () => {
|
||||
selectedUsers.forEach(userId => onRemoveUser(userId));
|
||||
setSelectedUsers([]);
|
||||
};
|
||||
// 本地筛选(如果没有提供onSearch回调)
|
||||
const filteredUsers = onSearch
|
||||
? users
|
||||
: users.filter(user => {
|
||||
if (!searchKeyword) return true;
|
||||
const keyword = searchKeyword.toLowerCase();
|
||||
return (
|
||||
user.name?.toLowerCase().includes(keyword) ||
|
||||
user.tags?.some(tag => tag.toLowerCase().includes(keyword))
|
||||
);
|
||||
});
|
||||
|
||||
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>
|
||||
);
|
||||
}
|
||||
const displayTotal = total || filteredUsers.length;
|
||||
|
||||
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>
|
||||
<div className={styles.header}>
|
||||
<div className={styles.title}>符合条件的用户</div>
|
||||
<div className={styles.userCount}>{displayTotal}人</div>
|
||||
</div>
|
||||
|
||||
{users.length > 0 && (
|
||||
<div className={styles.batchActions}>
|
||||
{/* 搜索框 */}
|
||||
<div className={styles.searchWrapper}>
|
||||
<SearchBar
|
||||
placeholder="搜索昵称、微信号、标签..."
|
||||
value={searchKeyword}
|
||||
onChange={handleSearchChange}
|
||||
onClear={() => {
|
||||
setSearchKeyword("");
|
||||
onSearch?.("");
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{loading && users.length === 0 ? (
|
||||
<div className={styles.loadingContainer}>
|
||||
<SpinLoading style={{ "--size": "32px" }} />
|
||||
<span>加载中...</span>
|
||||
</div>
|
||||
) : filteredUsers.length === 0 ? (
|
||||
<div style={{ paddingTop: 40 }}>
|
||||
<Empty description={searchKeyword ? "未找到匹配用户" : "暂无用户数据"} />
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
<div className={styles.batchBar}>
|
||||
<Checkbox
|
||||
checked={
|
||||
selectedUsers.length === users.length && users.length > 0
|
||||
}
|
||||
onChange={handleSelectAll}
|
||||
className={styles.selectAllCheckbox}
|
||||
checked={selectedUsers.length === filteredUsers.length && filteredUsers.length > 0}
|
||||
onChange={checked => setSelectedUsers(checked ? filteredUsers.map(u => u.id) : [])}
|
||||
style={{ '--font-size': '14px' }}
|
||||
>
|
||||
全选
|
||||
</Checkbox>
|
||||
{selectedUsers.length > 0 && (
|
||||
<Button
|
||||
size="small"
|
||||
color="danger"
|
||||
fill="outline"
|
||||
onClick={handleRemoveSelected}
|
||||
className={styles.removeSelectedBtn}
|
||||
>
|
||||
移除选中 ({selectedUsers.length})
|
||||
<Button size="mini" color="danger" fill="none" onClick={() => {
|
||||
selectedUsers.forEach(onRemoveUser);
|
||||
setSelectedUsers([]);
|
||||
}}>
|
||||
批量删除 ({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}
|
||||
<div className={styles.userList}>
|
||||
{filteredUsers.map(user => {
|
||||
const badge = getRfmBadge(user.rfmScore);
|
||||
return (
|
||||
<div key={user.id} className={styles.userItem}>
|
||||
<Checkbox
|
||||
checked={selectedUsers.includes(user.id)}
|
||||
onChange={checked => setSelectedUsers(prev => checked ? [...prev, user.id] : prev.filter(id => id !== user.id))}
|
||||
/>
|
||||
<Avatar src={user.avatar} className={styles.avatar} />
|
||||
<div className={styles.userInfo}>
|
||||
<div className={styles.nameRow}>
|
||||
<span className={styles.userName}>{user.name}</span>
|
||||
<span className={styles.badge} style={{ color: badge.color, background: badge.bg }}>
|
||||
{badge.text}
|
||||
</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 className={styles.metaRow}>
|
||||
消费 ¥{user.consumption} · 活跃 {user.lastActive}
|
||||
</div>
|
||||
{user.tags && user.tags.length > 0 && (
|
||||
<div className={styles.tagsRow}>
|
||||
{user.tags.slice(0, 3).map((tag, idx) => (
|
||||
<span key={idx} className={styles.tagItem}>{tag}</span>
|
||||
))}
|
||||
{user.tags.length > 3 && (
|
||||
<span className={styles.tagMore}>+{user.tags.length - 3}</span>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<Button
|
||||
fill="none"
|
||||
className={styles.removeBtn}
|
||||
onClick={() => onRemoveUser(user.id)}
|
||||
>
|
||||
<CloseCircleOutline />
|
||||
</Button>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
|
||||
<Button
|
||||
size="small"
|
||||
fill="none"
|
||||
onClick={() => onRemoveUser(user.id)}
|
||||
className={styles.removeBtn}
|
||||
>
|
||||
<DeleteOutline />
|
||||
</Button>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</Card>
|
||||
{/* 无限滚动加载更多 */}
|
||||
{onLoadMore && (
|
||||
<InfiniteScroll loadMore={onLoadMore} hasMore={hasMore}>
|
||||
{hasMore ? (
|
||||
<div className={styles.loadingMore}>
|
||||
<SpinLoading style={{ "--size": "24px" }} />
|
||||
<span>加载中...</span>
|
||||
</div>
|
||||
) : (
|
||||
<span>没有更多了</span>
|
||||
)}
|
||||
</InfiniteScroll>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -1,49 +1,55 @@
|
||||
.tabsContainer {
|
||||
background: #fff;
|
||||
border-bottom: 1px solid #f0f0f0;
|
||||
.container {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
height: 100vh;
|
||||
background: #f8fafc;
|
||||
}
|
||||
|
||||
.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;
|
||||
}
|
||||
.mainScroll {
|
||||
flex: 1;
|
||||
overflow-y: auto;
|
||||
-webkit-overflow-scrolling: touch;
|
||||
}
|
||||
|
||||
.content {
|
||||
padding: 16px;
|
||||
min-height: calc(100vh - 200px);
|
||||
padding: 8px 0 24px;
|
||||
}
|
||||
|
||||
.footer {
|
||||
padding: 16px;
|
||||
background: #fff;
|
||||
border-top: 1px solid #f0f0f0;
|
||||
padding: 16px 20px;
|
||||
padding-bottom: calc(16px + env(safe-area-inset-bottom));
|
||||
background: rgba(255, 255, 255, 0.95);
|
||||
backdrop-filter: blur(10px);
|
||||
border-top: 1px solid rgba(0, 0, 0, 0.05);
|
||||
box-shadow: 0 -4px 15px rgba(0, 0, 0, 0.03);
|
||||
}
|
||||
|
||||
.buttonGroup {
|
||||
display: flex;
|
||||
gap: 12px;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.prevButton {
|
||||
flex: 1;
|
||||
height: 48px;
|
||||
border-radius: 12px;
|
||||
font-weight: 600;
|
||||
border: 1px solid #e5e7eb;
|
||||
color: #4b5563;
|
||||
background: #fff;
|
||||
}
|
||||
|
||||
.nextButton {
|
||||
flex: 1;
|
||||
.nextButton, .submitButton {
|
||||
flex: 2;
|
||||
height: 48px;
|
||||
border-radius: 12px;
|
||||
font-weight: 600;
|
||||
background: #007aff;
|
||||
box-shadow: 0 4px 12px rgba(0, 122, 255, 0.2);
|
||||
}
|
||||
|
||||
.submitButton {
|
||||
flex: 1;
|
||||
.stepHeader {
|
||||
background: #fff;
|
||||
border-bottom: 1px solid #f3f4f6;
|
||||
padding-bottom: 4px;
|
||||
}
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import React, { useState } from "react";
|
||||
import { Button } from "antd-mobile";
|
||||
import { Button, Toast } from "antd-mobile";
|
||||
import { useNavigate } from "react-router-dom";
|
||||
import Layout from "@/components/Layout/Layout";
|
||||
import NavCommon from "@/components/NavCommon";
|
||||
import BasicInfo from "./components/BasicInfo";
|
||||
@@ -7,10 +8,16 @@ import AudienceFilter from "./components/AudienceFilter";
|
||||
import UserListPreview from "./components/UserListPreview";
|
||||
import styles from "./index.module.scss";
|
||||
import StepIndicator from "@/components/StepIndicator";
|
||||
import { createTrafficPackage, getUsersByFilter } from "./api";
|
||||
|
||||
const CreateTrafficPackage: React.FC = () => {
|
||||
const navigate = useNavigate();
|
||||
const [currentStep, setCurrentStep] = useState(1); // 1 基础信息 2 人群筛选 3 用户列表
|
||||
const [submitting, setSubmitting] = useState(false); // 添加提交状态
|
||||
const [loading, setLoading] = useState(false); // 加载用户列表状态
|
||||
const [searchKeyword, setSearchKeyword] = useState(""); // 搜索关键词
|
||||
const [currentPage, setCurrentPage] = useState(1); // 当前页
|
||||
const [hasMore, setHasMore] = useState(false); // 是否还有更多数据
|
||||
const [formData, setFormData] = useState({
|
||||
// 基本信息
|
||||
name: "",
|
||||
@@ -20,6 +27,7 @@ const CreateTrafficPackage: React.FC = () => {
|
||||
filterConditions: [],
|
||||
// 用户列表
|
||||
filteredUsers: [],
|
||||
filteredUsersTotal: 0,
|
||||
});
|
||||
|
||||
const steps = [
|
||||
@@ -40,59 +48,6 @@ const CreateTrafficPackage: React.FC = () => {
|
||||
setFormData(prev => ({ ...prev, filteredUsers: users }));
|
||||
};
|
||||
|
||||
// 初始化模拟数据
|
||||
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 = async () => {
|
||||
// 防止重复提交
|
||||
@@ -100,20 +55,41 @@ const CreateTrafficPackage: React.FC = () => {
|
||||
return;
|
||||
}
|
||||
|
||||
if (!formData.name) {
|
||||
Toast.show({ content: "请填写流量包名称", icon: "fail" });
|
||||
return;
|
||||
}
|
||||
|
||||
if (!formData.filterConditions || formData.filterConditions.length === 0) {
|
||||
Toast.show({ content: "请设置筛选条件", icon: "fail" });
|
||||
return;
|
||||
}
|
||||
|
||||
setSubmitting(true);
|
||||
try {
|
||||
// 提交逻辑
|
||||
console.log("提交数据:", formData);
|
||||
// 这里可以调用实际的 API
|
||||
// await createTrafficPackage(formData);
|
||||
// 构造ruleConfig
|
||||
const ruleConfig = {
|
||||
logic: "AND",
|
||||
conditions: formData.filterConditions,
|
||||
};
|
||||
|
||||
// 模拟 API 调用
|
||||
await new Promise(resolve => setTimeout(resolve, 1000));
|
||||
// 调用创建接口
|
||||
const result = await createTrafficPackage({
|
||||
groupName: formData.name,
|
||||
description: formData.description || undefined,
|
||||
ruleType: 1, // 动态规则
|
||||
ruleConfig: ruleConfig,
|
||||
});
|
||||
|
||||
// 提交成功后可以跳转或显示成功消息
|
||||
console.log("流量包创建成功");
|
||||
} catch (error) {
|
||||
Toast.show({ content: "创建成功", icon: "success" });
|
||||
|
||||
// 跳转回列表页
|
||||
setTimeout(() => {
|
||||
navigate("/mine/traffic-pool");
|
||||
}, 1000);
|
||||
} catch (error: any) {
|
||||
console.error("创建流量包失败:", error);
|
||||
Toast.show({ content: error?.message || "创建失败", icon: "fail" });
|
||||
} finally {
|
||||
setSubmitting(false);
|
||||
}
|
||||
@@ -121,24 +97,87 @@ const CreateTrafficPackage: React.FC = () => {
|
||||
|
||||
const canSubmit = formData.name && formData.filterConditions.length > 0;
|
||||
|
||||
// 模拟生成用户数据
|
||||
const generateMockUsers = (conditions: any[]) => {
|
||||
const mockUsers = [];
|
||||
const userCount = Math.floor(Math.random() * 1000) + 100; // 100-1100个用户
|
||||
// 转换用户数据格式
|
||||
const formatUserData = (user: any) => ({
|
||||
id: String(user.id),
|
||||
name: user.nickname || user.wechatId || user.identifier,
|
||||
avatar: user.avatar || `https://api.dicebear.com/7.x/avataaars/svg?seed=${user.id}`,
|
||||
tags: Array.isArray(user.tags) ? user.tags.map((tag: any) => typeof tag === 'string' ? tag : tag.tagName || '') : [],
|
||||
rfmScore: user.rfmScore?.total || 0,
|
||||
lastActive: user.lastInteractTime ? `${Math.floor((Date.now() / 1000 - user.lastInteractTime) / 86400)}天前` : "从未",
|
||||
consumption: parseFloat(user.totalOrderAmount || "0"),
|
||||
});
|
||||
|
||||
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,
|
||||
});
|
||||
// 根据筛选条件获取真实用户数据
|
||||
const loadUsersByFilter = async (conditions: any[], keyword: string = "", page: number = 1, append: boolean = false) => {
|
||||
if (!conditions || conditions.length === 0) {
|
||||
return [];
|
||||
}
|
||||
|
||||
return mockUsers;
|
||||
setLoading(true);
|
||||
try {
|
||||
// 构造ruleConfig,如果有关键词搜索,添加额外条件
|
||||
let ruleConfig: any = {
|
||||
logic: "AND",
|
||||
conditions: [...conditions],
|
||||
};
|
||||
|
||||
// 如果有搜索关键词,添加搜索条件
|
||||
if (keyword) {
|
||||
ruleConfig.keyword = keyword;
|
||||
}
|
||||
|
||||
const result = await getUsersByFilter({
|
||||
ruleConfig,
|
||||
page,
|
||||
pageSize: 50,
|
||||
});
|
||||
|
||||
// 转换为组件需要的格式
|
||||
const users = result.list.map(formatUserData);
|
||||
|
||||
// 判断是否还有更多数据
|
||||
const totalLoaded = append ? formData.filteredUsers.length + users.length : users.length;
|
||||
setHasMore(totalLoaded < result.total);
|
||||
setCurrentPage(page);
|
||||
|
||||
if (append) {
|
||||
// 追加模式
|
||||
setFormData(prev => ({
|
||||
...prev,
|
||||
filteredUsers: [...prev.filteredUsers, ...users],
|
||||
filteredUsersTotal: result.total,
|
||||
}));
|
||||
} else {
|
||||
// 替换模式
|
||||
setFormData(prev => ({
|
||||
...prev,
|
||||
filteredUsers: users,
|
||||
filteredUsersTotal: result.total,
|
||||
}));
|
||||
}
|
||||
|
||||
return users;
|
||||
} catch (error: any) {
|
||||
console.error("获取用户列表失败:", error);
|
||||
Toast.show({ content: error?.message || "获取用户列表失败", icon: "fail" });
|
||||
return [];
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
// 搜索用户
|
||||
const handleSearchUsers = async (keyword: string) => {
|
||||
setSearchKeyword(keyword);
|
||||
setCurrentPage(1);
|
||||
await loadUsersByFilter(formData.filterConditions, keyword, 1, false);
|
||||
};
|
||||
|
||||
// 加载更多用户
|
||||
const handleLoadMoreUsers = async () => {
|
||||
if (loading || !hasMore) return;
|
||||
await loadUsersByFilter(formData.filterConditions, searchKeyword, currentPage + 1, true);
|
||||
};
|
||||
|
||||
const renderFooter = () => {
|
||||
@@ -158,19 +197,20 @@ const CreateTrafficPackage: React.FC = () => {
|
||||
<Button
|
||||
color="primary"
|
||||
className={styles.nextButton}
|
||||
onClick={() => {
|
||||
onClick={async () => {
|
||||
if (currentStep === 2) {
|
||||
// 在第二步时生成用户列表
|
||||
const mockUsers = generateMockUsers(
|
||||
// 在第二步时加载真实用户列表
|
||||
const users = await loadUsersByFilter(
|
||||
formData.filterConditions,
|
||||
);
|
||||
handleGenerateUsers(mockUsers);
|
||||
handleGenerateUsers(users);
|
||||
}
|
||||
setCurrentStep(s => Math.min(3, s + 1));
|
||||
}}
|
||||
disabled={submitting}
|
||||
disabled={submitting || loading}
|
||||
loading={loading}
|
||||
>
|
||||
下一步
|
||||
{currentStep === 2 && loading ? "加载中..." : "下一步"}
|
||||
</Button>
|
||||
) : (
|
||||
<Button
|
||||
@@ -191,10 +231,10 @@ const CreateTrafficPackage: React.FC = () => {
|
||||
return (
|
||||
<Layout
|
||||
header={
|
||||
<>
|
||||
<NavCommon title="新建流量包" />
|
||||
<div className={styles.stepHeader}>
|
||||
<NavCommon title="新建流量池" />
|
||||
<StepIndicator currentStep={currentStep} steps={steps} />
|
||||
</>
|
||||
</div>
|
||||
}
|
||||
footer={renderFooter()}
|
||||
>
|
||||
@@ -213,14 +253,20 @@ const CreateTrafficPackage: React.FC = () => {
|
||||
{currentStep === 3 && (
|
||||
<UserListPreview
|
||||
users={formData.filteredUsers}
|
||||
total={formData.filteredUsersTotal}
|
||||
loading={loading}
|
||||
hasMore={hasMore}
|
||||
onRemoveUser={userId => {
|
||||
setFormData(prev => ({
|
||||
...prev,
|
||||
filteredUsers: prev.filteredUsers.filter(
|
||||
(user: any) => user.id !== userId,
|
||||
),
|
||||
filteredUsersTotal: prev.filteredUsersTotal - 1,
|
||||
}));
|
||||
}}
|
||||
onSearch={handleSearchUsers}
|
||||
onLoadMore={handleLoadMoreUsers}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
|
||||
@@ -5,13 +5,6 @@ import {
|
||||
type TrafficPoolGroup,
|
||||
} from "../api";
|
||||
|
||||
/**
|
||||
* 是否启用 V2 API(需要先完成数据迁移才能启用)
|
||||
* 设置为 true 使用新版 V2 接口
|
||||
* 设置为 false 使用旧版 V1 接口
|
||||
*/
|
||||
const USE_V2_API = true;
|
||||
|
||||
// 兼容旧版 Package 接口
|
||||
export interface Package {
|
||||
id: number;
|
||||
@@ -33,39 +26,45 @@ export interface PackageList {
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取流量池分组列表
|
||||
* 自动转换为旧版 Package 格式以保持兼容
|
||||
* 获取流量池分组列表 (V2)
|
||||
* 转换为旧版 Package 格式以保持兼容
|
||||
*/
|
||||
export async function getPackage(params: {
|
||||
page: number;
|
||||
pageSize: number;
|
||||
keyword: string;
|
||||
}): Promise<PackageList> {
|
||||
// 如果未启用 V2 API,直接使用 V1 接口
|
||||
if (!USE_V2_API) {
|
||||
return request("/v1/traffic/pool/getPackage", params, "GET");
|
||||
}
|
||||
|
||||
// V2 API 逻辑
|
||||
try {
|
||||
const groups = await getGroupsV2();
|
||||
|
||||
// 转换为旧版格式
|
||||
let list: Package[] = groups.map((group: TrafficPoolGroup) => ({
|
||||
id: group.id,
|
||||
name: group.groupName,
|
||||
description: group.description || "",
|
||||
pic: group.groupIcon || "",
|
||||
type: group.isSystem, // 1=系统,0=自定义
|
||||
createTime: group.createTime
|
||||
? new Date(group.createTime * 1000).toLocaleDateString()
|
||||
: "",
|
||||
num: group.memberCount || 0,
|
||||
R: Math.round(group.avgRfmR || 0),
|
||||
F: Math.round(group.avgRfmF || 0),
|
||||
M: Math.round(group.avgRfmM || 0),
|
||||
RFM: group.avgRfmScore || 0,
|
||||
}));
|
||||
let list: Package[] = groups.map((group: TrafficPoolGroup) => {
|
||||
// 处理 createTime:可能是时间戳(数字)或日期字符串
|
||||
let formattedTime = "";
|
||||
if (group.createTime) {
|
||||
if (typeof group.createTime === "number") {
|
||||
// Unix 时间戳(秒)
|
||||
formattedTime = new Date(group.createTime * 1000).toLocaleDateString();
|
||||
} else if (typeof group.createTime === "string") {
|
||||
// 已经是日期字符串,直接取日期部分
|
||||
formattedTime = group.createTime.split(" ")[0] || group.createTime;
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
id: group.id,
|
||||
name: group.groupName,
|
||||
description: group.description || "",
|
||||
pic: group.groupIcon || "",
|
||||
type: group.isSystem, // 1=系统,0=自定义
|
||||
createTime: formattedTime,
|
||||
num: group.memberCount || 0,
|
||||
R: Math.round(group.avgRfmR || 0),
|
||||
F: Math.round(group.avgRfmF || 0),
|
||||
M: Math.round(group.avgRfmM || 0),
|
||||
RFM: group.avgRfmScore || 0,
|
||||
};
|
||||
});
|
||||
|
||||
// 关键字过滤
|
||||
if (params.keyword) {
|
||||
@@ -88,28 +87,20 @@ export async function getPackage(params: {
|
||||
total,
|
||||
};
|
||||
} catch (error) {
|
||||
console.error("V2 API 调用失败,降级到 V1:", error);
|
||||
// 降级到旧版API
|
||||
return request("/v1/traffic/pool/getPackage", params, "GET");
|
||||
console.error("获取流量池分组列表失败:", error);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 删除分组
|
||||
* 删除分组 (V2)
|
||||
*/
|
||||
export async function deletePackage(id: number): Promise<{ success: boolean }> {
|
||||
// 如果未启用 V2 API,直接使用 V1 接口
|
||||
if (!USE_V2_API) {
|
||||
return request("/v1/traffic/pool/deletePackage", { packageId: id }, "DELETE");
|
||||
}
|
||||
|
||||
// V2 API 逻辑
|
||||
try {
|
||||
await deleteGroupV2(id);
|
||||
return { success: true };
|
||||
} catch (error) {
|
||||
console.error("V2 API 调用失败,降级到 V1:", error);
|
||||
// 降级到旧版API
|
||||
return request("/v1/traffic/pool/deletePackage", { packageId: id }, "DELETE");
|
||||
console.error("删除分组失败:", error);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -80,7 +80,7 @@ const TrafficPoolList: React.FC = () => {
|
||||
// 设置新的定时器,500ms 后执行搜索
|
||||
debounceTimer.current = setTimeout(() => {
|
||||
setKeyword(value);
|
||||
setPage(1);
|
||||
setPage(1);
|
||||
}, 500);
|
||||
};
|
||||
|
||||
@@ -99,22 +99,22 @@ const TrafficPoolList: React.FC = () => {
|
||||
};
|
||||
|
||||
const fetchData = useCallback(async (customPage?: number) => {
|
||||
setLoading(true);
|
||||
try {
|
||||
const params = {
|
||||
setLoading(true);
|
||||
try {
|
||||
const params = {
|
||||
page: customPage || page,
|
||||
pageSize,
|
||||
pageSize,
|
||||
keyword,
|
||||
};
|
||||
};
|
||||
|
||||
const res: PackageList = await getPackage(params);
|
||||
setList(res?.list || []);
|
||||
setTotal(res?.total || 0);
|
||||
} catch (error) {
|
||||
console.error("获取列表失败:", error);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
const res: PackageList = await getPackage(params);
|
||||
setList(res?.list || []);
|
||||
setTotal(res?.total || 0);
|
||||
} catch (error) {
|
||||
console.error("获取列表失败:", error);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}, [page, pageSize, keyword]);
|
||||
|
||||
const handleDelete = async (id: number, name: string) => {
|
||||
@@ -212,12 +212,13 @@ const TrafficPoolList: React.FC = () => {
|
||||
`/mine/traffic-pool/userList/${item.id}`,
|
||||
),
|
||||
},
|
||||
{
|
||||
// 只有非系统分组才显示删除选项
|
||||
...(item.type !== 1 ? [{
|
||||
key: "delete",
|
||||
danger: true,
|
||||
label: "删除数据包",
|
||||
onClick: () => handleDelete(item.id, item.name),
|
||||
},
|
||||
}] : []),
|
||||
],
|
||||
}}
|
||||
trigger={["click"]}
|
||||
|
||||
@@ -9,13 +9,6 @@ import {
|
||||
type PageResult,
|
||||
} from "../api";
|
||||
|
||||
/**
|
||||
* 是否启用 V2 API(需要先完成数据迁移才能启用)
|
||||
* 设置为 true 使用新版 V2 接口
|
||||
* 设置为 false 使用旧版 V1 接口
|
||||
*/
|
||||
const USE_V2_API = true;
|
||||
|
||||
// 兼容旧版返回格式
|
||||
export interface PoolListItem {
|
||||
id: number;
|
||||
@@ -40,19 +33,13 @@ export interface PoolListResult {
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取流量池列表
|
||||
* 获取流量池列表 (V2)
|
||||
*/
|
||||
export async function fetchTrafficPoolList(params: {
|
||||
page?: number;
|
||||
pageSize?: number;
|
||||
keyword?: string;
|
||||
}): Promise<PoolListResult> {
|
||||
// 如果未启用 V2 API,直接使用 V1 接口
|
||||
if (!USE_V2_API) {
|
||||
return request("/v1/traffic/pool", params, "GET");
|
||||
}
|
||||
|
||||
// V2 API 逻辑
|
||||
try {
|
||||
const result = await getPoolList({
|
||||
page: params.page || 1,
|
||||
@@ -82,8 +69,8 @@ export async function fetchTrafficPoolList(params: {
|
||||
total: result.total,
|
||||
};
|
||||
} catch (error) {
|
||||
console.error("V2 API 调用失败,降级到 V1:", error);
|
||||
return request("/v1/traffic/pool", params, "GET");
|
||||
console.error("获取流量池列表失败:", error);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -95,18 +82,12 @@ export async function fetchScenarioOptions() {
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取流量池分组选项
|
||||
* 获取流量池分组选项 (V2)
|
||||
*/
|
||||
export async function fetchPackageOptions(): Promise<{
|
||||
list: Array<{ id: number; name: string; type: number; num: number }>;
|
||||
total: number;
|
||||
}> {
|
||||
// 如果未启用 V2 API,直接使用 V1 接口
|
||||
if (!USE_V2_API) {
|
||||
return request("/v1/traffic/pool/getPackage", {}, "GET");
|
||||
}
|
||||
|
||||
// V2 API 逻辑
|
||||
try {
|
||||
const groups = await getGroups();
|
||||
const list = groups.map((g: TrafficPoolGroup) => ({
|
||||
@@ -117,13 +98,13 @@ export async function fetchPackageOptions(): Promise<{
|
||||
}));
|
||||
return { list, total: list.length };
|
||||
} catch (error) {
|
||||
console.error("V2 API 调用失败,降级到 V1:", error);
|
||||
return request("/v1/traffic/pool/getPackage", {}, "GET");
|
||||
console.error("获取流量池分组选项失败:", error);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 创建分组或添加成员到分组
|
||||
* 创建分组或添加成员到分组 (V2)
|
||||
*/
|
||||
export async function addPackage(params: {
|
||||
type: string; // 类型 1搜索 2选择用户 3文件上传
|
||||
@@ -138,12 +119,6 @@ export async function addPackage(params: {
|
||||
userIds?: number[]; // 要添加的用户ID列表
|
||||
userValue?: number;
|
||||
}) {
|
||||
// 如果未启用 V2 API,直接使用 V1 接口
|
||||
if (!USE_V2_API) {
|
||||
return request("/v1/traffic/pool/addPackage", params, "POST");
|
||||
}
|
||||
|
||||
// V2 API 逻辑
|
||||
try {
|
||||
// 如果是添加成员到现有分组
|
||||
if (params.addPackageId && params.userIds && params.userIds.length > 0) {
|
||||
@@ -166,15 +141,14 @@ export async function addPackage(params: {
|
||||
return { success: true, id: result.id };
|
||||
}
|
||||
|
||||
// 降级到旧版API
|
||||
return request("/v1/traffic/pool/addPackage", params, "POST");
|
||||
throw new Error("缺少必要参数");
|
||||
} catch (error) {
|
||||
console.error("V2 API 调用失败,降级到 V1:", error);
|
||||
return request("/v1/traffic/pool/addPackage", params, "POST");
|
||||
console.error("创建分组或添加成员失败:", error);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
// 导出V2 API供直接使用(当 USE_V2_API 设置为 true 后可用)
|
||||
// 导出V2 API供直接使用
|
||||
export {
|
||||
getGroups,
|
||||
createGroup,
|
||||
|
||||
@@ -22,6 +22,9 @@ export interface UserListItem {
|
||||
// 其他字段
|
||||
alias: string | null;
|
||||
tags: string[];
|
||||
region: string;
|
||||
lifecycle: number;
|
||||
intentionLevel: number;
|
||||
R: number;
|
||||
F: number;
|
||||
M: number;
|
||||
@@ -90,6 +93,9 @@ export async function fetchTrafficPoolList(params: {
|
||||
// 其他字段
|
||||
alias: item.wechatAlias || null,
|
||||
tags: item.tags?.map((t: any) => t.tagName) || [],
|
||||
region: item.region || "",
|
||||
lifecycle: item.lifecycle || 1,
|
||||
intentionLevel: item.intentionLevel || 0,
|
||||
R: item.rfmScore?.R || 0,
|
||||
F: item.rfmScore?.F || 0,
|
||||
M: item.rfmScore?.M || 0,
|
||||
|
||||
@@ -14,6 +14,9 @@ export interface TrafficPoolUser {
|
||||
// 其他字段
|
||||
alias: string | null;
|
||||
tags: string[];
|
||||
region: string; // 地区
|
||||
lifecycle: number; // 生命周期
|
||||
intentionLevel: number; // 意向等级
|
||||
R: number;
|
||||
F: number;
|
||||
M: number;
|
||||
|
||||
@@ -1,40 +1,183 @@
|
||||
.listWrap {
|
||||
padding: 16px;
|
||||
padding: 12px;
|
||||
background-color: #f6f7f9;
|
||||
min-height: calc(100vh - 120px);
|
||||
}
|
||||
|
||||
.cardWrap {
|
||||
margin-bottom: 12px;
|
||||
}
|
||||
|
||||
.card {
|
||||
background: #fff;
|
||||
border-radius: 8px;
|
||||
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.04);
|
||||
border-radius: 12px;
|
||||
padding: 16px;
|
||||
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.03);
|
||||
position: relative;
|
||||
overflow: hidden;
|
||||
transition: all 0.2s;
|
||||
transition: transform 0.1s;
|
||||
|
||||
&:hover {
|
||||
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.08);
|
||||
&:active {
|
||||
transform: scale(0.98);
|
||||
background-color: #fafafa;
|
||||
}
|
||||
}
|
||||
|
||||
.cardContent {
|
||||
.cardHeader {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
padding: 16px;
|
||||
gap: 12px;
|
||||
margin-bottom: 12px;
|
||||
|
||||
.avatar {
|
||||
border-radius: 8px;
|
||||
border: 1px solid #f0f0f0;
|
||||
}
|
||||
|
||||
.mainInfo {
|
||||
flex: 1;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
justify-content: center;
|
||||
|
||||
.nicknameRow {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
margin-bottom: 4px;
|
||||
|
||||
.nickname {
|
||||
font-size: 16px;
|
||||
font-weight: 600;
|
||||
color: #1a1a1a;
|
||||
max-width: 160px;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
}
|
||||
|
||||
.wechatId {
|
||||
font-size: 13px;
|
||||
color: #8c8c8c;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 4px;
|
||||
margin-top: 2px;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.title {
|
||||
font-size: 16px;
|
||||
font-weight: 600;
|
||||
color: #333;
|
||||
margin-bottom: 8px;
|
||||
.statsRow {
|
||||
display: flex;
|
||||
gap: 16px;
|
||||
margin-bottom: 12px;
|
||||
padding: 8px 12px;
|
||||
background: #f8fbff;
|
||||
border-radius: 8px;
|
||||
|
||||
.statItem {
|
||||
flex: 1;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
|
||||
.statLabel {
|
||||
font-size: 11px;
|
||||
color: #8c8c8c;
|
||||
margin-bottom: 2px;
|
||||
}
|
||||
.statValue {
|
||||
font-size: 14px;
|
||||
font-weight: 600;
|
||||
color: #1677ff;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.desc {
|
||||
font-size: 14px;
|
||||
color: #666;
|
||||
margin-bottom: 4px;
|
||||
line-height: 1.4;
|
||||
.tagsRow {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 6px;
|
||||
margin-top: 10px;
|
||||
min-height: 20px;
|
||||
align-items: center;
|
||||
|
||||
.tagItem {
|
||||
padding: 2px 8px;
|
||||
background: #f0f5ff;
|
||||
color: #1677ff;
|
||||
border-radius: 4px;
|
||||
font-size: 11px;
|
||||
border: 1px solid #d6e4ff;
|
||||
}
|
||||
|
||||
.moreTags {
|
||||
font-size: 11px;
|
||||
color: #bfbfbf;
|
||||
line-height: 20px;
|
||||
}
|
||||
|
||||
.noTags {
|
||||
font-size: 11px;
|
||||
color: #d9d9d9;
|
||||
font-style: italic;
|
||||
}
|
||||
}
|
||||
|
||||
.footerRow {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
margin-top: 12px;
|
||||
padding-top: 10px;
|
||||
border-top: 1px solid #f0f0f0;
|
||||
font-size: 12px;
|
||||
color: #bfbfbf;
|
||||
|
||||
.source {
|
||||
color: #52c41a;
|
||||
background: #f6ffed;
|
||||
padding: 1px 6px;
|
||||
border-radius: 4px;
|
||||
}
|
||||
}
|
||||
|
||||
// 搜索栏样式优化
|
||||
:global {
|
||||
.search-bar {
|
||||
display: flex;
|
||||
padding: 12px 16px;
|
||||
gap: 10px;
|
||||
background: #fff;
|
||||
border-bottom: 1px solid #f0f0f0;
|
||||
|
||||
.search-input-wrapper {
|
||||
flex: 1;
|
||||
.ant-input-affix-wrapper {
|
||||
border-radius: 20px;
|
||||
background: #f5f5f5;
|
||||
border: none;
|
||||
padding: 6px 16px;
|
||||
}
|
||||
}
|
||||
|
||||
.ant-btn {
|
||||
border-radius: 50%;
|
||||
width: 40px;
|
||||
height: 40px;
|
||||
padding: 0;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
border: none;
|
||||
background: #f0f5ff;
|
||||
color: #1677ff;
|
||||
}
|
||||
}
|
||||
|
||||
.pagination-container {
|
||||
padding: 16px;
|
||||
background: #fff;
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
border-top: 1px solid #f0f0f0;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,22 +1,31 @@
|
||||
import React, { useCallback, useEffect, useState, useRef } 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 {
|
||||
SearchOutlined,
|
||||
ReloadOutlined,
|
||||
MessageOutlined,
|
||||
DollarOutlined,
|
||||
AreaChartOutlined,
|
||||
UserOutlined,
|
||||
EnvironmentOutlined,
|
||||
WomanOutlined,
|
||||
ManOutlined,
|
||||
} from "@ant-design/icons";
|
||||
import { Input, Button, Pagination, Tag } from "antd";
|
||||
import styles from "./index.module.scss";
|
||||
import { Empty, Avatar } from "antd-mobile";
|
||||
import NavCommon from "@/components/NavCommon";
|
||||
import { fetchTrafficPoolList } from "./api";
|
||||
import { LIFECYCLE_TEXT } from "../api";
|
||||
import type { TrafficPoolUser } from "./data";
|
||||
|
||||
const defaultAvatar =
|
||||
"https://cdn.jsdelivr.net/gh/maokaka/static/avatar-default.png";
|
||||
const defaultAvatar = "https://cdn.jsdelivr.net/gh/maokaka/static/avatar-default.png";
|
||||
|
||||
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);
|
||||
@@ -25,15 +34,14 @@ const TrafficPoolUserList: React.FC = () => {
|
||||
const [keyword, setKeyword] = useState("");
|
||||
const [searchInput, setSearchInput] = useState("");
|
||||
|
||||
// 防抖定时器
|
||||
const debounceTimer = useRef<NodeJS.Timeout | null>(null);
|
||||
|
||||
// 获取列表
|
||||
const getList = useCallback(async (customParams?: any) => {
|
||||
setLoading(true);
|
||||
try {
|
||||
const currentPage = customParams?.page || page;
|
||||
const params: any = {
|
||||
page,
|
||||
page: currentPage,
|
||||
pageSize,
|
||||
keyword,
|
||||
packageId: id,
|
||||
@@ -44,43 +52,24 @@ const TrafficPoolUserList: React.FC = () => {
|
||||
setList(res.list || []);
|
||||
setTotal(res.total || 0);
|
||||
} catch (error) {
|
||||
if (error !== "请求过于频繁,请稍后再试") {
|
||||
console.error("获取列表失败:", error);
|
||||
}
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}, [page, pageSize, keyword, id]);
|
||||
|
||||
// 搜索输入变化时的防抖处理
|
||||
const handleSearchChange = (value: string) => {
|
||||
setSearchInput(value);
|
||||
|
||||
// 清除之前的定时器
|
||||
if (debounceTimer.current) {
|
||||
clearTimeout(debounceTimer.current);
|
||||
}
|
||||
|
||||
// 设置新的定时器,500ms 后执行搜索
|
||||
if (debounceTimer.current) clearTimeout(debounceTimer.current);
|
||||
debounceTimer.current = setTimeout(() => {
|
||||
setKeyword(value);
|
||||
setPage(1);
|
||||
}, 500);
|
||||
};
|
||||
|
||||
// 清理定时器
|
||||
useEffect(() => {
|
||||
return () => {
|
||||
if (debounceTimer.current) {
|
||||
clearTimeout(debounceTimer.current);
|
||||
}
|
||||
};
|
||||
}, []);
|
||||
|
||||
// 初始加载和参数变化时重新获取数据
|
||||
useEffect(() => {
|
||||
getList();
|
||||
}, [getList]);
|
||||
getList({ page: 1 });
|
||||
}, [keyword, id]);
|
||||
|
||||
return (
|
||||
<Layout
|
||||
@@ -88,14 +77,13 @@ const TrafficPoolUserList: React.FC = () => {
|
||||
header={
|
||||
<>
|
||||
<NavCommon title="用户列表" />
|
||||
{/* 搜索栏 */}
|
||||
<div className="search-bar">
|
||||
<div className="search-input-wrapper">
|
||||
<Input
|
||||
placeholder="搜索用户"
|
||||
placeholder="搜索用户姓名/微信号"
|
||||
value={searchInput}
|
||||
onChange={e => handleSearchChange(e.target.value)}
|
||||
prefix={<SearchOutlined />}
|
||||
prefix={<SearchOutlined style={{ color: '#bfbfbf' }} />}
|
||||
allowClear
|
||||
size="large"
|
||||
/>
|
||||
@@ -103,7 +91,6 @@ const TrafficPoolUserList: React.FC = () => {
|
||||
<Button
|
||||
onClick={() => getList()}
|
||||
loading={loading}
|
||||
size="large"
|
||||
icon={<ReloadOutlined />}
|
||||
/>
|
||||
</div>
|
||||
@@ -112,10 +99,10 @@ const TrafficPoolUserList: React.FC = () => {
|
||||
footer={
|
||||
<div className="pagination-container">
|
||||
<Pagination
|
||||
simple
|
||||
current={page}
|
||||
pageSize={pageSize}
|
||||
total={total}
|
||||
showSizeChanger={false}
|
||||
onChange={newPage => {
|
||||
setPage(newPage);
|
||||
getList({ page: newPage });
|
||||
@@ -128,45 +115,89 @@ const TrafficPoolUserList: React.FC = () => {
|
||||
{list.length === 0 && !loading ? (
|
||||
<Empty description="暂无用户数据" />
|
||||
) : (
|
||||
<div>
|
||||
<div className={styles.listContainer}>
|
||||
{list.map(item => (
|
||||
<div key={item.id} className={styles.cardWrap}>
|
||||
<div
|
||||
className={styles.card}
|
||||
style={{ cursor: "pointer" }}
|
||||
onClick={() =>
|
||||
navigate(
|
||||
`/mine/traffic-pool/detail/${item.wechatId}/${item.id}`,
|
||||
)
|
||||
}
|
||||
<div
|
||||
key={item.id}
|
||||
className={styles.cardWrap}
|
||||
onClick={() => navigate(`/mine/traffic-pool/detail/${item.wechatId}/${item.id}`)}
|
||||
>
|
||||
<div className={styles.cardContent}>
|
||||
{/* 顶部:头像与基本信息 */}
|
||||
<div className={styles.cardHeader}>
|
||||
<Avatar
|
||||
src={item.avatar || defaultAvatar}
|
||||
style={{ "--size": "60px" }}
|
||||
className={styles.avatar}
|
||||
style={{ "--size": "52px" }}
|
||||
/>
|
||||
<div style={{ flex: 1 }}>
|
||||
<div className={styles.title}>
|
||||
<div className={styles.mainInfo}>
|
||||
<div className={styles.nicknameRow}>
|
||||
<span className={styles.nickname}>
|
||||
{item.nickname || item.identifier}
|
||||
{item.gender === 1 ? (
|
||||
<ManOutlined style={{ color: "#1677ff", fontSize: "12px", marginLeft: "4px" }} />
|
||||
) : item.gender === 2 ? (
|
||||
<WomanOutlined style={{ color: "#ff4d4f", fontSize: "12px", marginLeft: "4px" }} />
|
||||
) : null}
|
||||
</span>
|
||||
<div style={{ display: 'flex', gap: '4px', alignItems: 'center' }}>
|
||||
<Tag color="blue" style={{ borderRadius: '4px', fontSize: '10px', border: 'none', margin: 0 }}>
|
||||
{LIFECYCLE_TEXT[item.lifecycle] || "新流量"}
|
||||
</Tag>
|
||||
<Tag color="orange" style={{ borderRadius: '4px', fontSize: '10px', border: 'none', margin: 0 }}>
|
||||
RFM: {item.RFM}
|
||||
</Tag>
|
||||
</div>
|
||||
<div className={styles.desc}>
|
||||
微信号:{item.wechatId || "-"}
|
||||
</div>
|
||||
<div className={styles.desc}>
|
||||
来源:{item.fromd || "-"}
|
||||
<div className={styles.wechatId}>
|
||||
<div style={{ flex: 1, display: 'flex', alignItems: 'center', gap: '4px' }}>
|
||||
<UserOutlined style={{ fontSize: '12px' }} />
|
||||
{item.wechatId || "未设置微信号"}
|
||||
</div>
|
||||
<div className={styles.desc}>
|
||||
分组:
|
||||
{item.packages && item.packages.length
|
||||
? item.packages.join(",")
|
||||
: "-"}
|
||||
</div>
|
||||
<div className={styles.desc}>
|
||||
创建时间:{item.createTime}
|
||||
{item.region && (
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: '2px', color: '#bfbfbf', fontSize: '11px' }}>
|
||||
<EnvironmentOutlined />
|
||||
{item.region}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 中间:互动统计小色块 */}
|
||||
<div className={styles.statsRow}>
|
||||
<div className={styles.statItem}>
|
||||
<span className={styles.statLabel}><MessageOutlined /> 消息</span>
|
||||
<span className={styles.statValue}>{item.msgCount || 0}</span>
|
||||
</div>
|
||||
<div className={styles.statItem}>
|
||||
<span className={styles.statLabel}><DollarOutlined /> 成交</span>
|
||||
<span className={styles.statValue}>¥{item.money || 0}</span>
|
||||
</div>
|
||||
<div className={styles.statItem}>
|
||||
<span className={styles.statLabel}><AreaChartOutlined /> 活跃度</span>
|
||||
<span className={styles.statValue}>{item.F || 0}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 底部:用户标签 */}
|
||||
<div className={styles.tagsRow}>
|
||||
{item.tags && item.tags.length > 0 ? (
|
||||
<>
|
||||
{item.tags.slice(0, 3).map((tag, idx) => (
|
||||
<span key={idx} className={styles.tagItem}>{tag}</span>
|
||||
))}
|
||||
{item.tags.length > 3 && <span className={styles.moreTags}>+{item.tags.length - 3}</span>}
|
||||
</>
|
||||
) : (
|
||||
<span className={styles.noTags}>暂无标签</span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* 脚注:来源与时间 */}
|
||||
<div className={styles.footerRow}>
|
||||
<span className={styles.source}>{item.fromd}</span>
|
||||
<span>{item.createTime?.split(' ')[0]}</span>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
@@ -160,31 +160,31 @@ export function deleteContent(contentId: string) {
|
||||
return request(`/api/contents/${contentId}`, undefined, "DELETE");
|
||||
}
|
||||
|
||||
// ==================== 流量池相关接口 ====================
|
||||
// ==================== 流量池相关接口 (V2) ====================
|
||||
|
||||
// 获取流量池列表
|
||||
// 获取流量池分组列表
|
||||
export function getTrafficPools() {
|
||||
return request("/api/traffic-pools", undefined, "GET");
|
||||
return request("/v1/traffic/pool/v2/groups", {}, "GET");
|
||||
}
|
||||
|
||||
// 获取流量池详情
|
||||
// 获取流量池用户详情
|
||||
export function getTrafficPoolDetail(poolId: string) {
|
||||
return request(`/api/traffic-pools/${poolId}`, undefined, "GET");
|
||||
return request("/v1/traffic/pool/v2/detail", { id: poolId }, "GET");
|
||||
}
|
||||
|
||||
// 创建流量池
|
||||
export function createTrafficPool(data: any) {
|
||||
return request("/api/traffic-pools", data, "POST");
|
||||
// 创建流量池分组
|
||||
export function createTrafficPool(data: { groupName: string; description?: string; ruleType?: number; ruleConfig?: any }) {
|
||||
return request("/v1/traffic/pool/v2/group/create", data, "POST");
|
||||
}
|
||||
|
||||
// 更新流量池
|
||||
export function updateTrafficPool(poolId: string, data: any) {
|
||||
return request(`/api/traffic-pools/${poolId}`, data, "PUT");
|
||||
// 更新流量池分组
|
||||
export function updateTrafficPool(groupId: string, data: any) {
|
||||
return request("/v1/traffic/pool/v2/group/update", { groupId, ...data }, "PUT");
|
||||
}
|
||||
|
||||
// 删除流量池
|
||||
export function deleteTrafficPool(poolId: string) {
|
||||
return request(`/api/traffic-pools/${poolId}`, undefined, "DELETE");
|
||||
// 删除流量池分组
|
||||
export function deleteTrafficPool(groupId: string) {
|
||||
return request("/v1/traffic/pool/v2/group/delete", { groupId }, "DELETE");
|
||||
}
|
||||
|
||||
// ==================== 工作台相关接口 ====================
|
||||
|
||||
Reference in New Issue
Block a user