From f956fc949b0da4db8dcc6a56d8113171b4e6ff4a Mon Sep 17 00:00:00 2001 From: wong <106998207@qq.com> Date: Wed, 4 Feb 2026 10:58:02 +0800 Subject: [PATCH] =?UTF-8?q?=E5=AD=98=E5=AE=A2=E5=AE=9D=E6=96=B0=E7=89=88?= =?UTF-8?q?=E6=B5=81=E9=87=8F=E6=B1=A0=E6=8F=90=E4=BA=A4?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- Cunkebao/src/components/PoolSelection/api.ts | 65 +- .../PoolSelection/selectionPopup.tsx | 43 +- .../StepIndicator/index.module.scss | 38 + .../src/components/StepIndicator/index.tsx | 24 +- .../mobile/mine/traffic-pool/detail/api.ts | 16 +- .../traffic-pool/detail/index.module.scss | 697 +++++------- .../mobile/mine/traffic-pool/detail/index.tsx | 993 ++++++------------ .../mobile/mine/traffic-pool/form/api.ts | 165 +-- .../components/AudienceFilter.module.scss | 224 ++-- .../form/components/AudienceFilter.tsx | 246 ++--- .../form/components/BasicInfo.module.scss | 91 +- .../form/components/BasicInfo.tsx | 35 +- .../form/components/ConditionList.module.scss | 63 +- .../form/components/ConditionList.tsx | 49 +- .../CustomConditionModal.module.scss | 254 ++++- .../form/components/CustomConditionModal.tsx | 488 ++++++--- .../components/FriendSearchModal.module.scss | 153 +++ .../form/components/FriendSearchModal.tsx | 248 +++++ .../components/UserListPreview.module.scss | 177 ++-- .../form/components/UserListPreview.tsx | 245 +++-- .../mine/traffic-pool/form/index.module.scss | 60 +- .../mobile/mine/traffic-pool/form/index.tsx | 220 ++-- .../mobile/mine/traffic-pool/list/api.ts | 77 +- .../mobile/mine/traffic-pool/list/index.tsx | 33 +- .../mobile/mine/traffic-pool/poolList1/api.ts | 48 +- .../mobile/mine/traffic-pool/userList/api.ts | 6 + .../mobile/mine/traffic-pool/userList/data.ts | 3 + .../traffic-pool/userList/index.module.scss | 187 +++- .../mine/traffic-pool/userList/index.tsx | 153 +-- .../scenarios/plan/new/steps/step.api.ts | 28 +- 30 files changed, 2875 insertions(+), 2254 deletions(-) create mode 100644 Cunkebao/src/components/StepIndicator/index.module.scss create mode 100644 Cunkebao/src/pages/mobile/mine/traffic-pool/form/components/FriendSearchModal.module.scss create mode 100644 Cunkebao/src/pages/mobile/mine/traffic-pool/form/components/FriendSearchModal.tsx diff --git a/Cunkebao/src/components/PoolSelection/api.ts b/Cunkebao/src/components/PoolSelection/api.ts index 552be7e6e..e505ff419 100644 --- a/Cunkebao/src/components/PoolSelection/api.ts +++ b/Cunkebao/src/components/PoolSelection/api.ts @@ -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"); } diff --git a/Cunkebao/src/components/PoolSelection/selectionPopup.tsx b/Cunkebao/src/components/PoolSelection/selectionPopup.tsx index 20d08bec0..f70bd87aa 100644 --- a/Cunkebao/src/components/PoolSelection/selectionPopup.tsx +++ b/Cunkebao/src/components/PoolSelection/selectionPopup.tsx @@ -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); } diff --git a/Cunkebao/src/components/StepIndicator/index.module.scss b/Cunkebao/src/components/StepIndicator/index.module.scss new file mode 100644 index 000000000..e1cb9751b --- /dev/null +++ b/Cunkebao/src/components/StepIndicator/index.module.scss @@ -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; +} diff --git a/Cunkebao/src/components/StepIndicator/index.tsx b/Cunkebao/src/components/StepIndicator/index.tsx index 655bbafd8..134ba85fc 100644 --- a/Cunkebao/src/components/StepIndicator/index.tsx +++ b/Cunkebao/src/components/StepIndicator/index.tsx @@ -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 = ({ steps, }) => { return ( -
- - {steps.map((step, idx) => ( +
+ + {steps.map((step) => ( - {step.id} -
- } + className={styles.step} /> ))}
diff --git a/Cunkebao/src/pages/mobile/mine/traffic-pool/detail/api.ts b/Cunkebao/src/pages/mobile/mine/traffic-pool/detail/api.ts index acaeb3ae2..60efdc3f8 100644 --- a/Cunkebao/src/pages/mobile/mine/traffic-pool/detail/api.ts +++ b/Cunkebao/src/pages/mobile/mine/traffic-pool/detail/api.ts @@ -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, // 去除超时限制 + }); +} diff --git a/Cunkebao/src/pages/mobile/mine/traffic-pool/detail/index.module.scss b/Cunkebao/src/pages/mobile/mine/traffic-pool/detail/index.module.scss index b15f7316b..c8ff8caa1 100644 --- a/Cunkebao/src/pages/mobile/mine/traffic-pool/detail/index.module.scss +++ b/Cunkebao/src/pages/mobile/mine/traffic-pool/detail/index.module.scss @@ -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; } } diff --git a/Cunkebao/src/pages/mobile/mine/traffic-pool/detail/index.tsx b/Cunkebao/src/pages/mobile/mine/traffic-pool/detail/index.tsx index ef515d3d8..08d396070 100644 --- a/Cunkebao/src/pages/mobile/mine/traffic-pool/detail/index.tsx +++ b/Cunkebao/src/pages/mobile/mine/traffic-pool/detail/index.tsx @@ -1,7 +1,7 @@ import React, { useEffect, useState, useRef } from "react"; import { useParams } from "react-router-dom"; import Layout from "@/components/Layout/Layout"; -import { Avatar, Empty, Popup, CheckList, Button as MobileButton, SearchBar } from "antd-mobile"; +import { Avatar, Popup, CheckList, Button as MobileButton, SearchBar, Tag, Empty } from "antd-mobile"; import { Spin, message } from "antd"; import { UserOutlined, @@ -14,9 +14,12 @@ import { EditOutlined, CloseCircleOutlined, SyncOutlined, + CommentOutlined, + NodeIndexOutlined, + ClockCircleOutlined, } from "@ant-design/icons"; import NavCommon from "@/components/NavCommon"; -import { fetchUserDetail, addTag, removeTag } from "./api"; +import { fetchUserDetail, addTag, removeTag, updateRfm } from "./api"; import { SOURCE_TYPE_TEXT, LEVEL_TEXT, @@ -31,70 +34,41 @@ import { } from "../api"; import styles from "./index.module.scss"; -const defaultAvatar = - "https://cdn.jsdelivr.net/gh/maokaka/static/avatar-default.png"; - -// 行为类型图标 -const behaviorIcons: Record = { - 1: "📤", // 发送消息 - 2: "📥", // 接收消息 - 3: "👀", // 浏览 - 4: "👆", // 点击 - 5: "💬", // 咨询 - 6: "🛒", // 下单 - 7: "💰", // 支付 - 8: "↩️", // 退款 - 9: "👍", // 点赞朋友圈 - 10: "💭", // 评论朋友圈 -}; - -// 来源类型图标 -const sourceIcons: Record = { - 1: "👥", // 好友添加 - 2: "👪", // 群成员 - 3: "🖼️", // 海报获客 - 4: "📞", // 电话获客 - 5: "📦", // 订单获客 - 6: "🔗", // API导入 - 7: "✏️", // 手动导入 - 8: "🔄", // 裂变活动 -}; +const defaultAvatar = "https://cdn.jsdelivr.net/gh/maokaka/static/avatar-default.png"; const TrafficPoolDetail: React.FC = () => { const { id } = useParams<{ wechatId: string; id: string }>(); const [loading, setLoading] = useState(false); const [detail, setDetail] = useState(null); - // 标签编辑相关状态 + // 状态管理保持原有逻辑 const [tagModalVisible, setTagModalVisible] = useState(false); const [tagDefines, setTagDefines] = useState([]); const [selectedTagIds, setSelectedTagIds] = useState([]); const [tagLoading, setTagLoading] = useState(false); const [syncLoading, setSyncLoading] = useState(false); - const [showAllSources, setShowAllSources] = useState(false); // 是否显示全部来源 + const [rfmLoading, setRfmLoading] = useState(false); - // 来源分页相关状态 const [sourcesPage, setSourcesPage] = useState(1); const [sourcesLoading, setSourcesLoading] = useState(false); const [sourcesTotal, setSourcesTotal] = useState(0); const [allSources, setAllSources] = useState([]); - const [sourcesKeyword, setSourcesKeyword] = useState(""); // 来源搜索关键词 - const [sourcesSearchInput, setSourcesSearchInput] = useState(""); // 来源搜索输入框实时值 - const sourcesSearchTimer = useRef(null); // 来源搜索防抖定时器 + const [sourcesKeyword, setSourcesKeyword] = useState(""); + const [sourcesSearchInput, setSourcesSearchInput] = useState(""); + const sourcesSearchTimer = useRef(null); + const sourcesKeywordInitialized = useRef(false); - // 行为分页相关状态 const [behaviorsPage, setBehaviorsPage] = useState(1); const [behaviorsLoading, setBehaviorsLoading] = useState(false); const [behaviorsTotal, setBehaviorsTotal] = useState(0); const [allBehaviors, setAllBehaviors] = useState([]); - const [behaviorsKeyword, setBehaviorsKeyword] = useState(""); // 行为搜索关键词 - const [behaviorsSearchInput, setBehaviorsSearchInput] = useState(""); // 行为搜索输入框实时值 - const behaviorsSearchTimer = useRef(null); // 行为搜索防抖定时器 + const [behaviorsKeyword, setBehaviorsKeyword] = useState(""); + const [behaviorsSearchInput, setBehaviorsSearchInput] = useState(""); + const behaviorsSearchTimer = useRef(null); + const behaviorsKeywordInitialized = useRef(false); useEffect(() => { - if (id) { - loadDetail(); - } + if (id) loadDetail(); }, [id]); const loadDetail = async () => { @@ -115,20 +89,16 @@ const TrafficPoolDetail: React.FC = () => { setAllBehaviors(initialBehaviors); // 如果返回了50条,说明可能还有更多,需要获取总数 - // 详情接口返回了50条,相当于分页接口的page=1-3(每页20条) - // 所以分页应该从page=4开始(如果总数>50) if (initialSources.length >= 50) { try { const sourcesResult = await getPoolSources({ poolCompanyId: parseInt(id), page: 1, - pageSize: 1, // 只需要获取总数 + pageSize: 1, }); setSourcesTotal(sourcesResult.total); - // 详情接口返回了50条,相当于分页接口的前3页(每页20条),所以从第4页开始 - setSourcesPage(3); // 已加载3页(50条) + setSourcesPage(3); } catch (e) { - // 如果获取失败,使用已加载的数量 setSourcesTotal(initialSources.length); setSourcesPage(1); } @@ -142,13 +112,11 @@ const TrafficPoolDetail: React.FC = () => { const behaviorsResult = await getPoolBehaviors({ poolCompanyId: parseInt(id), page: 1, - pageSize: 1, // 只需要获取总数 + pageSize: 1, }); setBehaviorsTotal(behaviorsResult.total); - // 详情接口返回了50条,相当于分页接口的前3页(每页20条),所以从第4页开始 - setBehaviorsPage(3); // 已加载3页(50条) + setBehaviorsPage(3); } catch (e) { - // 如果获取失败,使用已加载的数量 setBehaviorsTotal(initialBehaviors.length); setBehaviorsPage(1); } @@ -166,17 +134,12 @@ const TrafficPoolDetail: React.FC = () => { // 搜索来源(带防抖) const handleSourcesSearchChange = (value: string) => { setSourcesSearchInput(value); - - // 清除之前的定时器 if (sourcesSearchTimer.current) { clearTimeout(sourcesSearchTimer.current); } - - // 设置新的定时器,500ms 后执行搜索 sourcesSearchTimer.current = setTimeout(() => { setSourcesKeyword(value); - setSourcesPage(1); // 搜索时重置到第一页 - setShowAllSources(true); // 搜索时自动展开 + setSourcesPage(1); }, 500); }; @@ -188,20 +151,17 @@ const TrafficPoolDetail: React.FC = () => { const result = await getPoolSources({ poolCompanyId: parseInt(id), page, - pageSize: keyword ? 100 : 50, // 搜索时每次100条,非搜索时50条 + pageSize: keyword ? 100 : 50, keyword, }); - if (page === 1) { - // 第一页,替换数据 setAllSources(result.list); } else { - // 后续页,合并数据(去重) - const existingIds = new Set(allSources.map((s: any) => s.id)); - const newSources = result.list.filter((s: any) => !existingIds.has(s.id)); - setAllSources([...allSources, ...newSources]); + // 去重合并 + const existingIds = new Set(allSources.map(s => s.id)); + const newList = result.list.filter(s => !existingIds.has(s.id)); + setAllSources([...allSources, ...newList]); } - setSourcesTotal(result.total); setSourcesPage(page); } catch (error: any) { @@ -220,28 +180,26 @@ const TrafficPoolDetail: React.FC = () => { // 监听搜索关键词变化 useEffect(() => { - if (id && sourcesKeyword !== undefined && sourcesKeyword !== "") { + if (!sourcesKeywordInitialized.current) { + sourcesKeywordInitialized.current = true; + return; + } + if (id && sourcesKeyword !== "") { searchSources(sourcesKeyword, 1); } else if (id && sourcesKeyword === "") { - // 清空搜索时,重新加载详情 loadDetail(); } - // eslint-disable-next-line react-hooks/exhaustive-deps }, [sourcesKeyword]); // 搜索行为(带防抖) const handleBehaviorsSearchChange = (value: string) => { setBehaviorsSearchInput(value); - - // 清除之前的定时器 if (behaviorsSearchTimer.current) { clearTimeout(behaviorsSearchTimer.current); } - - // 设置新的定时器,500ms 后执行搜索 behaviorsSearchTimer.current = setTimeout(() => { setBehaviorsKeyword(value); - setBehaviorsPage(1); // 搜索时重置到第一页 + setBehaviorsPage(1); }, 500); }; @@ -253,20 +211,16 @@ const TrafficPoolDetail: React.FC = () => { const result = await getPoolBehaviors({ poolCompanyId: parseInt(id), page, - pageSize: keyword ? 100 : 50, // 搜索时每次100条,非搜索时50条 + pageSize: 100, keyword, }); - if (page === 1) { - // 第一页,替换数据 setAllBehaviors(result.list); } else { - // 后续页,合并数据(去重) - const existingIds = new Set(allBehaviors.map((b: any) => b.id)); - const newBehaviors = result.list.filter((b: any) => !existingIds.has(b.id)); - setAllBehaviors([...allBehaviors, ...newBehaviors]); + const existingIds = new Set(allBehaviors.map(b => b.id)); + const newList = result.list.filter(b => !existingIds.has(b.id)); + setAllBehaviors([...allBehaviors, ...newList]); } - setBehaviorsTotal(result.total); setBehaviorsPage(page); } catch (error: any) { @@ -285,27 +239,25 @@ const TrafficPoolDetail: React.FC = () => { // 监听搜索关键词变化 useEffect(() => { - if (id && behaviorsKeyword !== undefined && behaviorsKeyword !== "") { + if (!behaviorsKeywordInitialized.current) { + behaviorsKeywordInitialized.current = true; + return; + } + if (id && behaviorsKeyword !== "") { searchBehaviors(behaviorsKeyword, 1); } else if (id && behaviorsKeyword === "") { - // 清空搜索时,重新加载详情 loadDetail(); } - // eslint-disable-next-line react-hooks/exhaustive-deps }, [behaviorsKeyword]); - // 打开标签编辑弹窗 + // 标签处理逻辑 const openTagModal = async () => { setTagModalVisible(true); setTagLoading(true); try { - // 获取所有标签定义(站内标签,tagType=2) const defines = await getTagDefines({ tagType: 2 }); - setTagDefines(defines || []); - // 设置已选中的标签 - const existingTagIds = detail?.tags - ?.filter((t: any) => t.tagType === 2) - ?.map((t: any) => t.tagDefineId) || []; + setTagDefines(defines); + const existingTagIds = detail?.tags?.filter((t: any) => t.tagType === 2).map((t: any) => t.tagDefineId) || []; setSelectedTagIds(existingTagIds); } catch (error) { message.error("获取标签列表失败"); @@ -314,35 +266,55 @@ const TrafficPoolDetail: React.FC = () => { } }; - // 保存标签变更 - const saveTagChanges = async () => { - if (!id) return; - const poolCompanyId = parseInt(id); + const handleSyncTags = async () => { + if (!id || syncLoading) return; + setSyncLoading(true); + try { + const result = await syncTagsFromEngine(parseInt(id)); + message.success(`同步成功:已同步 ${result.syncedCount} 个标签`); + loadDetail(); + } catch (error: any) { + message.error(error?.message || "同步失败"); + } finally { + setSyncLoading(false); + } + }; - // 获取当前已有的站内标签ID - const existingTagIds = detail?.tags + const handleUpdateRfm = async () => { + if (!detail?.identifier || rfmLoading) return; + setRfmLoading(true); + try { + const result = await updateRfm(detail.identifier); + message.success("RFM评分更新成功"); + // 重新加载详情以获取最新的RFM数据 + loadDetail(); + } catch (error: any) { + message.error(error?.message || "RFM更新失败"); + } finally { + setRfmLoading(false); + } + }; + + const saveTagChanges = async () => { + if (!id || !detail) return; + const poolCompanyId = parseInt(id); + const existingTagIds = detail.tags ?.filter((t: any) => t.tagType === 2) ?.map((t: any) => t.tagDefineId) || []; - // 需要添加的标签 - const toAdd = selectedTagIds.filter(id => !existingTagIds.includes(id)); - // 需要删除的标签 - const toRemove = existingTagIds.filter((id: number) => !selectedTagIds.includes(id)); + const toAdd = selectedTagIds.filter(tagId => !existingTagIds.includes(tagId)); + const toRemove = existingTagIds.filter((tagId: number) => !selectedTagIds.includes(tagId)); setTagLoading(true); try { - // 添加新标签 for (const tagDefineId of toAdd) { await addTag(poolCompanyId, tagDefineId); } - // 删除移除的标签 for (const tagDefineId of toRemove) { await removeTag(poolCompanyId, tagDefineId); } - message.success("标签更新成功"); setTagModalVisible(false); - // 重新加载详情 loadDetail(); } catch (error) { message.error("标签更新失败"); @@ -351,577 +323,294 @@ const TrafficPoolDetail: React.FC = () => { } }; - // 快速删除标签 - const handleRemoveTag = async (tagDefineId: number) => { - if (!id) return; - try { - await removeTag(parseInt(id), tagDefineId); - message.success("标签已移除"); - loadDetail(); - } catch (error) { - message.error("移除失败"); - } - }; - - // 从标签引擎同步标签 - const handleSyncTags = async () => { - if (!id) return; - setSyncLoading(true); - try { - const result = await syncTagsFromEngine(parseInt(id)); - message.success(`同步完成:已同步 ${result.syncedCount} 个标签`); - loadDetail(); // 重新加载详情 - } catch (error: any) { - message.error(error?.message || "同步失败"); - } finally { - setSyncLoading(false); - } - }; - - // 格式化时间戳 - const formatTime = (timestamp: number | null) => { - if (!timestamp) return "-"; - const date = new Date(timestamp * 1000); - return date.toLocaleString("zh-CN"); - }; - - if (loading) { - return ( - }> -
- -
-
- ); - } - - if (!detail) { - return ( - }> - - - ); + if (loading && !detail) { + return
; } return ( - }> -
- {/* 用户基本信息 */} -
-
- -
-
- {detail.nickname || detail.identifier} -
-
- 微信号:{detail.wechatAlias || detail.wechatId || "-"} -
-
- {detail.gender === 1 ? "男" : detail.gender === 2 ? "女" : "未知"}{" "} - · {detail.region || detail.city || "-"} -
+ +
+ + + {/* 顶部核心卡片 */} +
+ +
+
+ {detail?.nickname || '未知用户'} + + {LEVEL_TEXT[detail?.level] || '普通'} + +
+
+ {detail?.wechatAlias || detail?.wechatId || '无微信号'} + {detail?.region || '未知地区'}
- {/* RFM 评分 */} -
+ {/* 统计面板卡片 */} +
- - RFM 评分 -
-
-
-
{detail.rfmR ?? "-"}
-
R (最近)
-
-
-
{detail.rfmF ?? 0}
-
F (频次)
-
-
-
{detail.rfmM ?? 0}
-
M (金额)
-
-
-
- {(detail.rfmScore?.total) ?? "-"} -
-
总分
-
-
-
- - {/* 客户信息 */} -
-
- - 客户信息 -
-
-
-
- 真实姓名 - {detail.realName || "-"} -
-
- 手机号 - - {detail.phone || detail.mobile || "-"} - -
-
- 客户等级 - - {LEVEL_TEXT[detail.level] || "普通"} - -
-
- 意向度 - - {INTENTION_LEVEL_TEXT[detail.intentionLevel] || "未知"} - -
-
- 生命周期 - - {LIFECYCLE_TEXT[detail.lifecycle] || "新流量"} - -
-
- 好友状态 - - {FRIEND_STATUS_TEXT[detail.friendStatus] || "-"} - -
-
- 备注 - {detail.remark || "-"} -
-
-
-
- - {/* 统计数据 */} -
-
- - 互动统计 -
-
-
-
- 消息数 - {detail.totalMsgCount || 0} -
-
- 最后消息时间 - - {formatTime(detail.lastMsgTime)} - -
-
- 订单数 - - {detail.totalOrderCount || 0} - -
-
- 订单金额 - - ¥{detail.totalOrderAmount || "0.00"} - -
-
- 最后互动时间 - - {formatTime(detail.lastInteractTime)} - -
-
- 创建时间 - {detail.createTime || "-"} -
-
-
-
- - {/* 标签 - 按类型分组展示 */} -
-
- - 标签 + 互动统计
- - 同步 - - - 编辑 + + 更新RFM
- {detail.tags && detail.tags.length > 0 ? ( -
- {/* 微信标签 */} - {detail.tags.filter((t: any) => t.tagType === 1).length > 0 && ( -
-
- 💬 - 微信标签 - (来自微信同步) -
-
- {detail.tags - .filter((t: any) => t.tagType === 1) - .map((tag: any, index: number) => ( - - {tag.tagName} - - ))} -
-
- )} - {/* 站内标签 */} - {detail.tags.filter((t: any) => t.tagType === 2).length > 0 && ( -
-
- 🏷️ - 站内标签 - (可编辑) -
-
- {detail.tags - .filter((t: any) => t.tagType === 2) - .map((tag: any, index: number) => ( - - {tag.tagName} - { - e.stopPropagation(); - handleRemoveTag(tag.tagDefineId); - }} - /> - - ))} -
-
- )} - {/* AI标签 */} - {detail.tags.filter((t: any) => t.tagType === 3).length > 0 && ( -
-
- 🤖 - AI标签 - (AI自动生成) -
-
- {detail.tags - .filter((t: any) => t.tagType === 3) - .map((tag: any, index: number) => ( - - {tag.tagName} - {tag.score && {Math.round(tag.score * 100)}%} - - ))} -
-
- )} +
+
+ {detail?.totalMsgCount || 0} + 消息数
- ) : ( -
暂无标签
+
+ {detail?.totalOrderCount || 0} + 订单数 +
+
+ {detail?.rfmScore?.total || 0} + RFM得分 +
+
+ + {/* RFM详细得分 */} +
+
+
R-最近购买
+
{detail?.rfmScore?.R || 0}分
+
+
+
F-购买频次
+
{detail?.rfmScore?.F || 0}分
+
+
+
M-购买金额
+
{detail?.rfmScore?.M || 0}分
+
+
+
+ + {/* 基础信息卡片 */} +
+
+ 基础资料 +
+
+
+
加粉状态
+
{FRIEND_STATUS_TEXT[detail?.friendStatus] || '未知'}
+
+
+
客户周期
+
{LIFECYCLE_TEXT[detail?.lifecycle] || '新流量'}
+
+
+
意向等级
+
{INTENTION_LEVEL_TEXT[detail?.intentionLevel] || '未知'}
+
+
+
最后互动
+
{detail?.lastInteractTime ? new Date(detail.lastInteractTime * 1000).toLocaleDateString() : '从未'}
+
+
+
+ + {/* 标签管理卡片 */} +
+
+ 标签画像 +
+ 同步 + 编辑 +
+
+ +
+ {/* 微信标签 */} + {detail?.tags?.some((t: any) => t.tagType === 1) && ( +
+
微信标签
+
+ {detail?.tags?.filter((t: any) => t.tagType === 1).map((tag: any) => ( + {tag.tagName} + ))} +
+
+ )} + {/* 站内标签 */} +
+
站内标签
+
+ {detail?.tags?.filter((t: any) => t.tagType === 2).length > 0 ? ( + detail?.tags?.filter((t: any) => t.tagType === 2).map((tag: any) => ( + {tag.tagName} + )) + ) : 暂无标签} +
+
+ {/* AI标签 */} + {detail?.tags?.some((t: any) => t.tagType === 3) && ( +
+
AI标签
+
+ {detail?.tags?.filter((t: any) => t.tagType === 3).map((tag: any) => ( + {tag.tagName} + ))} +
+
+ )} +
+
+ + {/* 行为轨迹卡片 */} +
+
+ 行为轨迹 +
+
+ { + setBehaviorsSearchInput(""); + setBehaviorsKeyword(""); + setBehaviorsPage(1); + }} + /> +
+
+ {allBehaviors.length > 0 ? ( + allBehaviors.map((b, index) => ( +
+
+ {b.behaviorName} + {b.behaviorTimeFormatted || '刚刚'} +
+
{b.targetName || '执行了相关操作'}
+
+ )) + ) : } +
+ {behaviorsTotal > allBehaviors.length && ( + + 加载更多 + )}
- {/* 来源信息 */} -
+ {/* 来源追溯卡片 */} +
- - 来源追溯 - {sourcesTotal > 0 && ( - ({sourcesTotal}) - )} + 来源追溯
-
- {/* 搜索框 */} -
- { - setSourcesSearchInput(""); - setSourcesKeyword(""); - setSourcesPage(1); - setShowAllSources(false); - // 重新加载详情数据 - if (id) { - const loadDetailData = async () => { - try { - const res = await fetchUserDetail(parseInt(id)); - const initialSources = res.sources || []; - setAllSources(initialSources); - setSourcesTotal(initialSources.length); - setSourcesPage(1); - } catch (e) { - // ignore - } - }; - loadDetailData(); - } - }} - /> -
- {allSources && allSources.length > 0 ? ( - <> -
- {(showAllSources ? allSources : allSources.slice(0, 5)).map((source: any, index: number) => ( -
-
- {sourceIcons[source.sourceType] || "📋"} -
-
-
- {source.sourceTypeName || SOURCE_TYPE_TEXT[source.sourceType] || "未知来源"} - {/* 显示来源名称:优先用 sourceName,群成员来源时用 chatroomInfo,避免重复 */} - {source.sourceType === 2 ? ( - // 群成员来源:优先显示 chatroomInfo 的群名称 - source.chatroomInfo?.chatroomName && ( - - {` - ${source.chatroomInfo.chatroomName}`} - - ) - ) : ( - // 其他来源:显示 sourceName - source.sourceName && ` - ${source.sourceName}` - )} -
- {/* 显示ID:群ID或好友ID */} - {source.displayId && ( -
- {source.sourceType === 2 ? '群ID:' : source.sourceType === 1 ? '好友ID:' : 'ID:'} - {source.displayId} -
- )} - {/* 群归属客服信息 */} - {source.sourceType === 2 && source.chatroomOwners && source.chatroomOwners.length > 0 && ( -
- 归属客服: - {source.chatroomOwners.map((owner: any, ownerIndex: number) => ( - - {owner.ownerNickname || owner.ownerAlias || owner.ownerWechatId} - {ownerIndex < source.chatroomOwners.length - 1 && '、'} - +
+ { + setSourcesSearchInput(""); + setSourcesKeyword(""); + setSourcesPage(1); + }} + /> +
+
+ {allSources.length > 0 ? ( + allSources.map((s, index) => ( +
+
+
+ {s.sourceType === 2 && s.chatroomInfo?.chatroomAvatar ? ( + + ) : (s.sourceType === 1 && s.sourceAvatar ? ( + + ) : ( +
{SOURCE_TYPE_TEXT[s.sourceType]?.charAt(0)}
+ ))} + {SOURCE_TYPE_TEXT[s.sourceType]} +
+ {s.createTimeFormatted} +
+
+ {s.sourceType === 2 ? ( +
+
群聊:{s.chatroomInfo?.chatroomName}
+ {s.chatroomOwners && s.chatroomOwners.length > 0 && ( +
+ 归属: + {s.chatroomOwners.map((owner: any, idx: number) => ( +
+ + {owner.ownerNickname} +
))}
)} -
- {source.createTimeFormatted || formatTime(source.createTime)} -
- {source.isFirstSource === 1 && ( - 首次 - )} -
- ))} + ) : ( + s.sourceName + )} + {s.displayId &&
ID: {s.displayId}
} +
- {allSources.length > 5 && ( -
setShowAllSources(!showAllSources)}> - {showAllSources ? '收起' : `显示更多 (${allSources.length - 5}条)`} -
- )} - {showAllSources && allSources.length < sourcesTotal && ( -
{ - if (!sourcesLoading) { - loadMoreSources(); - } - }} - > - {sourcesLoading ? '加载中...' : `加载更多 (${sourcesTotal - allSources.length}条)`} -
- )} - {/* 如果已加载50条但总数未知,也显示加载更多 */} - {showAllSources && allSources.length >= 50 && sourcesTotal === allSources.length && ( -
{ - if (!sourcesLoading) { - loadMoreSources(); - } - }} - > - {sourcesLoading ? '加载中...' : '加载更多'} -
- )} - - ) : ( -
暂无来源记录
- )} + )) + ) : }
-
- - {/* 行为轨迹 */} -
-
- - 行为轨迹 - {behaviorsTotal > 0 && ( - ({behaviorsTotal}) - )} -
-
- {/* 搜索框 */} -
- { - setBehaviorsSearchInput(""); - setBehaviorsKeyword(""); - setBehaviorsPage(1); - // 重新加载详情数据 - if (id) { - const loadDetailData = async () => { - try { - const res = await fetchUserDetail(parseInt(id)); - const initialBehaviors = res.behaviors || []; - setAllBehaviors(initialBehaviors); - setBehaviorsTotal(initialBehaviors.length); - setBehaviorsPage(1); - } catch (e) { - // ignore - } - }; - loadDetailData(); - } - }} - /> -
- {allBehaviors && allBehaviors.length > 0 ? ( - <> -
- {allBehaviors.map((behavior: any, index: number) => ( -
-
- {behaviorIcons[behavior.behaviorType] || "📋"} -
-
-
- {behavior.behaviorName || `行为${behavior.behaviorType}`} - {behavior.amount > 0 && ` ¥${behavior.amount}`} -
-
- {formatTime(behavior.behaviorTime)} -
-
-
- ))} -
- {allBehaviors.length < behaviorsTotal && ( -
{ - if (!behaviorsLoading) { - loadMoreBehaviors(); - } - }} - > - {behaviorsLoading ? '加载中...' : `加载更多 (${behaviorsTotal - allBehaviors.length}条)`} -
- )} - {/* 如果已加载50条但总数未知,也显示加载更多 */} - {allBehaviors.length >= 50 && behaviorsTotal === allBehaviors.length && ( -
{ - if (!behaviorsLoading) { - loadMoreBehaviors(); - } - }} - > - {behaviorsLoading ? '加载中...' : '加载更多'} -
- )} - - ) : ( -
暂无行为记录
- )} -
-
-
- - {/* 标签编辑弹窗 */} - setTagModalVisible(false)} - bodyStyle={{ height: '60vh', borderRadius: '16px 16px 0 0' }} - > -
-
- 编辑站内标签 - setTagModalVisible(false)}>× -
- - {tagLoading ? ( -
- -
- ) : ( - <> -
- {tagDefines.length > 0 ? ( - setSelectedTagIds(values.map(Number))} - > - {tagDefines.map((define) => ( - -
- {define.tagName} - {define.description && ( - {define.description} - )} -
-
- ))} -
- ) : ( - - )} -
- -
- - 保存 - -
- + {sourcesTotal > allSources.length && ( + + 加载更多 + )}
-
+ + {/* 标签编辑弹窗 */} + setTagModalVisible(false)} + bodyStyle={{ borderTopLeftRadius: '16px', borderTopRightRadius: '16px', minHeight: '40vh' }} + > +
+
编辑站内标签
+ {tagLoading ? : ( + setSelectedTagIds(val as number[])} + > + {tagDefines.map(item => ( + {item.tagName} + ))} + + )} + + 完成 + +
+
+
); }; diff --git a/Cunkebao/src/pages/mobile/mine/traffic-pool/form/api.ts b/Cunkebao/src/pages/mobile/mine/traffic-pool/form/api.ts index 781ebe281..276dbd1bf 100644 --- a/Cunkebao/src/pages/mobile/mine/traffic-pool/form/api.ts +++ b/Cunkebao/src/pages/mobile/mine/traffic-pool/form/api.ts @@ -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 { - 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 { - 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 { - // 模拟数据 - 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(); + + 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 { - return request("/v1/traffic/pool/industries", {}, "GET"); +export async function getFilterFields(): Promise { + return request("/v1/traffic/pool/v2/filter-fields", {}, "GET"); +} + +// 兼容旧接口名称 +export async function getIndustryOptions(): Promise { + return getFilterFields(); } diff --git a/Cunkebao/src/pages/mobile/mine/traffic-pool/form/components/AudienceFilter.module.scss b/Cunkebao/src/pages/mobile/mine/traffic-pool/form/components/AudienceFilter.module.scss index c1c7e292c..94a4594ef 100644 --- a/Cunkebao/src/pages/mobile/mine/traffic-pool/form/components/AudienceFilter.module.scss +++ b/Cunkebao/src/pages/mobile/mine/traffic-pool/form/components/AudienceFilter.module.scss @@ -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; + } } diff --git a/Cunkebao/src/pages/mobile/mine/traffic-pool/form/components/AudienceFilter.tsx b/Cunkebao/src/pages/mobile/mine/traffic-pool/form/components/AudienceFilter.tsx index c126041c5..dc57e5160 100644 --- a/Cunkebao/src/pages/mobile/mine/traffic-pool/form/components/AudienceFilter.tsx +++ b/Cunkebao/src/pages/mobile/mine/traffic-pool/form/components/AudienceFilter.tsx @@ -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 = ({ onChange, }) => { const [showCustomModal, setShowCustomModal] = useState(false); - const [industryOptions, setIndustryOptions] = useState([]); + const [showFriendModal, setShowFriendModal] = useState(false); + const [industryOptions, setIndustryOptions] = useState([]); const [presetSchemes, setPresetSchemes] = useState([]); - const [selectedIndustry, setSelectedIndustry] = useState< - string | number | undefined - >(undefined); - const [selectedScheme, setSelectedScheme] = useState( - undefined, - ); + const [selectedScheme, setSelectedScheme] = useState(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 (
- -
-
人群筛选
+
+
人群方案
+
+ ({ - label: `${scheme.name} (${scheme.userCount}人)`, - value: scheme.id, - }))} - allowClear - /> - -
+
+
自定义条件
+ { + const newConditions = conditions.filter(c => + c.field ? c.field !== condition.field : c.id !== condition.id + ); + onChange(newConditions); + }} + /> +
+ +
+
- {/* 条件筛选区域 - 当未选择方案时显示 */} - {!selectedScheme && ( - <> - {/* 行业筛选(固定项,接口获取选项) */} -
-
行业
- handleChange("name", value)} - className={styles.input} + clearable /> - 描述}> + 计划描述} + > handleChange("description", value)} - className={styles.input} + clearable /> - 备注}> - 详细备注} + > +