代码优化
This commit is contained in:
@@ -313,7 +313,9 @@ export async function getPoolDetail(id: number): Promise<
|
||||
allotRecords: AllotRecord[];
|
||||
}
|
||||
> {
|
||||
return request("/v1/traffic/pool/v2/detail", { id }, "GET");
|
||||
return request("/v1/traffic/pool/v2/detail", { id }, "GET", {
|
||||
timeout: 0, // 去除超时限制
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -403,6 +405,59 @@ export async function removeTag(
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* 从标签引擎同步用户标签
|
||||
*/
|
||||
export async function syncTagsFromEngine(
|
||||
poolCompanyId: number,
|
||||
): Promise<{ syncedCount: number; skippedCount: number; total: number }> {
|
||||
return request(
|
||||
"/v1/traffic/pool/v2/tag/sync-from-engine",
|
||||
{ poolCompanyId },
|
||||
"POST",
|
||||
{
|
||||
timeout: 0, // 去除超时限制
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
// ==================== 来源和行为相关 API ====================
|
||||
|
||||
/**
|
||||
* 分页获取流量来源
|
||||
*/
|
||||
export async function getPoolSources(params: {
|
||||
poolCompanyId: number;
|
||||
page?: number;
|
||||
pageSize?: number;
|
||||
keyword?: string;
|
||||
}): Promise<{
|
||||
list: TrafficSource[];
|
||||
total: number;
|
||||
page: number;
|
||||
pageSize: number;
|
||||
}> {
|
||||
return request("/v1/traffic/pool/v2/sources", params, "GET");
|
||||
}
|
||||
|
||||
/**
|
||||
* 分页获取流量行为轨迹
|
||||
*/
|
||||
export async function getPoolBehaviors(params: {
|
||||
poolCompanyId: number;
|
||||
page?: number;
|
||||
pageSize?: number;
|
||||
keyword?: string;
|
||||
behaviorType?: number;
|
||||
}): Promise<{
|
||||
list: TrafficBehavior[];
|
||||
total: number;
|
||||
page: number;
|
||||
pageSize: number;
|
||||
}> {
|
||||
return request("/v1/traffic/pool/v2/behaviors", params, "GET");
|
||||
}
|
||||
|
||||
// ==================== 分配相关 API ====================
|
||||
|
||||
/**
|
||||
@@ -533,4 +588,3 @@ export const TAG_TYPE_TEXT: Record<number, string> = {
|
||||
[TAG_TYPE.SITE]: "站内标签",
|
||||
[TAG_TYPE.AI]: "AI标签",
|
||||
};
|
||||
|
||||
|
||||
@@ -24,6 +24,13 @@
|
||||
.icon {
|
||||
font-size: 16px;
|
||||
}
|
||||
|
||||
.sourceCount {
|
||||
font-size: 12px;
|
||||
font-weight: normal;
|
||||
color: #999;
|
||||
margin-left: 4px;
|
||||
}
|
||||
}
|
||||
|
||||
.sectionContent {
|
||||
@@ -184,7 +191,35 @@
|
||||
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;
|
||||
@@ -349,6 +384,34 @@
|
||||
|
||||
// 来源信息
|
||||
.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;
|
||||
@@ -429,3 +492,31 @@
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 搜索框包装器
|
||||
.searchBarWrapper {
|
||||
margin-bottom: 12px;
|
||||
padding: 0 4px;
|
||||
}
|
||||
|
||||
// 显示更多按钮
|
||||
.showMoreBtn {
|
||||
text-align: center;
|
||||
padding: 12px 16px;
|
||||
color: #1890ff;
|
||||
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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import React, { useEffect, useState } from "react";
|
||||
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 } from "antd-mobile";
|
||||
import { Avatar, Empty, Popup, CheckList, Button as MobileButton, SearchBar } from "antd-mobile";
|
||||
import { Spin, message } from "antd";
|
||||
import {
|
||||
UserOutlined,
|
||||
@@ -13,6 +13,7 @@ import {
|
||||
TrophyOutlined,
|
||||
EditOutlined,
|
||||
CloseCircleOutlined,
|
||||
SyncOutlined,
|
||||
} from "@ant-design/icons";
|
||||
import NavCommon from "@/components/NavCommon";
|
||||
import { fetchUserDetail, addTag, removeTag } from "./api";
|
||||
@@ -23,6 +24,9 @@ import {
|
||||
INTENTION_LEVEL_TEXT,
|
||||
FRIEND_STATUS_TEXT,
|
||||
getTagDefines,
|
||||
syncTagsFromEngine,
|
||||
getPoolSources,
|
||||
getPoolBehaviors,
|
||||
type TagDefine,
|
||||
} from "../api";
|
||||
import styles from "./index.module.scss";
|
||||
@@ -66,6 +70,26 @@ const TrafficPoolDetail: React.FC = () => {
|
||||
const [tagDefines, setTagDefines] = useState<TagDefine[]>([]);
|
||||
const [selectedTagIds, setSelectedTagIds] = useState<number[]>([]);
|
||||
const [tagLoading, setTagLoading] = useState(false);
|
||||
const [syncLoading, setSyncLoading] = useState(false);
|
||||
const [showAllSources, setShowAllSources] = useState(false); // 是否显示全部来源
|
||||
|
||||
// 来源分页相关状态
|
||||
const [sourcesPage, setSourcesPage] = useState(1);
|
||||
const [sourcesLoading, setSourcesLoading] = useState(false);
|
||||
const [sourcesTotal, setSourcesTotal] = useState(0);
|
||||
const [allSources, setAllSources] = useState<any[]>([]);
|
||||
const [sourcesKeyword, setSourcesKeyword] = useState(""); // 来源搜索关键词
|
||||
const [sourcesSearchInput, setSourcesSearchInput] = useState(""); // 来源搜索输入框实时值
|
||||
const sourcesSearchTimer = useRef<NodeJS.Timeout | null>(null); // 来源搜索防抖定时器
|
||||
|
||||
// 行为分页相关状态
|
||||
const [behaviorsPage, setBehaviorsPage] = useState(1);
|
||||
const [behaviorsLoading, setBehaviorsLoading] = useState(false);
|
||||
const [behaviorsTotal, setBehaviorsTotal] = useState(0);
|
||||
const [allBehaviors, setAllBehaviors] = useState<any[]>([]);
|
||||
const [behaviorsKeyword, setBehaviorsKeyword] = useState(""); // 行为搜索关键词
|
||||
const [behaviorsSearchInput, setBehaviorsSearchInput] = useState(""); // 行为搜索输入框实时值
|
||||
const behaviorsSearchTimer = useRef<NodeJS.Timeout | null>(null); // 行为搜索防抖定时器
|
||||
|
||||
useEffect(() => {
|
||||
if (id) {
|
||||
@@ -75,10 +99,63 @@ const TrafficPoolDetail: React.FC = () => {
|
||||
|
||||
const loadDetail = async () => {
|
||||
if (!id) return;
|
||||
// 如果有搜索关键词,不重置搜索状态
|
||||
if (sourcesKeyword || behaviorsKeyword) {
|
||||
return;
|
||||
}
|
||||
|
||||
setLoading(true);
|
||||
try {
|
||||
const res = await fetchUserDetail(parseInt(id));
|
||||
setDetail(res);
|
||||
// 初始化来源和行为数据(详情接口返回的前50条)
|
||||
const initialSources = res.sources || [];
|
||||
const initialBehaviors = res.behaviors || [];
|
||||
setAllSources(initialSources);
|
||||
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, // 只需要获取总数
|
||||
});
|
||||
setSourcesTotal(sourcesResult.total);
|
||||
// 详情接口返回了50条,相当于分页接口的前3页(每页20条),所以从第4页开始
|
||||
setSourcesPage(3); // 已加载3页(50条)
|
||||
} catch (e) {
|
||||
// 如果获取失败,使用已加载的数量
|
||||
setSourcesTotal(initialSources.length);
|
||||
setSourcesPage(1);
|
||||
}
|
||||
} else {
|
||||
setSourcesTotal(initialSources.length);
|
||||
setSourcesPage(1);
|
||||
}
|
||||
|
||||
if (initialBehaviors.length >= 50) {
|
||||
try {
|
||||
const behaviorsResult = await getPoolBehaviors({
|
||||
poolCompanyId: parseInt(id),
|
||||
page: 1,
|
||||
pageSize: 1, // 只需要获取总数
|
||||
});
|
||||
setBehaviorsTotal(behaviorsResult.total);
|
||||
// 详情接口返回了50条,相当于分页接口的前3页(每页20条),所以从第4页开始
|
||||
setBehaviorsPage(3); // 已加载3页(50条)
|
||||
} catch (e) {
|
||||
// 如果获取失败,使用已加载的数量
|
||||
setBehaviorsTotal(initialBehaviors.length);
|
||||
setBehaviorsPage(1);
|
||||
}
|
||||
} else {
|
||||
setBehaviorsTotal(initialBehaviors.length);
|
||||
setBehaviorsPage(1);
|
||||
}
|
||||
} catch (error: any) {
|
||||
message.error(error?.message || "获取详情失败");
|
||||
} finally {
|
||||
@@ -86,6 +163,137 @@ 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); // 搜索时自动展开
|
||||
}, 500);
|
||||
};
|
||||
|
||||
// 搜索来源数据
|
||||
const searchSources = async (keyword: string, page: number = 1) => {
|
||||
if (!id) return;
|
||||
setSourcesLoading(true);
|
||||
try {
|
||||
const result = await getPoolSources({
|
||||
poolCompanyId: parseInt(id),
|
||||
page,
|
||||
pageSize: keyword ? 100 : 50, // 搜索时每次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]);
|
||||
}
|
||||
|
||||
setSourcesTotal(result.total);
|
||||
setSourcesPage(page);
|
||||
} catch (error: any) {
|
||||
message.error(error?.message || "搜索失败");
|
||||
} finally {
|
||||
setSourcesLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
// 加载更多来源
|
||||
const loadMoreSources = async () => {
|
||||
if (!id || sourcesLoading) return;
|
||||
const nextPage = sourcesPage + 1;
|
||||
await searchSources(sourcesKeyword, nextPage);
|
||||
};
|
||||
|
||||
// 监听搜索关键词变化
|
||||
useEffect(() => {
|
||||
if (id && sourcesKeyword !== undefined && 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); // 搜索时重置到第一页
|
||||
}, 500);
|
||||
};
|
||||
|
||||
// 搜索行为数据
|
||||
const searchBehaviors = async (keyword: string, page: number = 1) => {
|
||||
if (!id) return;
|
||||
setBehaviorsLoading(true);
|
||||
try {
|
||||
const result = await getPoolBehaviors({
|
||||
poolCompanyId: parseInt(id),
|
||||
page,
|
||||
pageSize: keyword ? 100 : 50, // 搜索时每次100条,非搜索时50条
|
||||
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]);
|
||||
}
|
||||
|
||||
setBehaviorsTotal(result.total);
|
||||
setBehaviorsPage(page);
|
||||
} catch (error: any) {
|
||||
message.error(error?.message || "搜索失败");
|
||||
} finally {
|
||||
setBehaviorsLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
// 加载更多行为
|
||||
const loadMoreBehaviors = async () => {
|
||||
if (!id || behaviorsLoading) return;
|
||||
const nextPage = behaviorsPage + 1;
|
||||
await searchBehaviors(behaviorsKeyword, nextPage);
|
||||
};
|
||||
|
||||
// 监听搜索关键词变化
|
||||
useEffect(() => {
|
||||
if (id && behaviorsKeyword !== undefined && behaviorsKeyword !== "") {
|
||||
searchBehaviors(behaviorsKeyword, 1);
|
||||
} else if (id && behaviorsKeyword === "") {
|
||||
// 清空搜索时,重新加载详情
|
||||
loadDetail();
|
||||
}
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [behaviorsKeyword]);
|
||||
|
||||
// 打开标签编辑弹窗
|
||||
const openTagModal = async () => {
|
||||
setTagModalVisible(true);
|
||||
@@ -155,6 +363,21 @@ const TrafficPoolDetail: React.FC = () => {
|
||||
}
|
||||
};
|
||||
|
||||
// 从标签引擎同步标签
|
||||
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 "-";
|
||||
@@ -339,9 +562,17 @@ const TrafficPoolDetail: React.FC = () => {
|
||||
<div className={styles.sectionTitle}>
|
||||
<TagOutlined className={styles.icon} />
|
||||
标签
|
||||
<span className={styles.editBtn} onClick={openTagModal}>
|
||||
<EditOutlined /> 编辑
|
||||
</span>
|
||||
<div className={styles.titleActions}>
|
||||
<span
|
||||
className={`${styles.actionBtn} ${syncLoading ? styles.loading : ''}`}
|
||||
onClick={syncLoading ? undefined : handleSyncTags}
|
||||
>
|
||||
<SyncOutlined spin={syncLoading} /> 同步
|
||||
</span>
|
||||
<span className={styles.actionBtn} onClick={openTagModal}>
|
||||
<EditOutlined /> 编辑
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
{detail.tags && detail.tags.length > 0 ? (
|
||||
<div className={styles.tagGroups}>
|
||||
@@ -421,60 +652,124 @@ const TrafficPoolDetail: React.FC = () => {
|
||||
<div className={styles.sectionTitle}>
|
||||
<ShareAltOutlined className={styles.icon} />
|
||||
来源追溯
|
||||
{sourcesTotal > 0 && (
|
||||
<span className={styles.sourceCount}>({sourcesTotal})</span>
|
||||
)}
|
||||
</div>
|
||||
<div className={styles.sectionContent}>
|
||||
{detail.sources && detail.sources.length > 0 ? (
|
||||
<div className={styles.sourceList}>
|
||||
{detail.sources.map((source: any, index: number) => (
|
||||
<div key={index} className={styles.sourceItem}>
|
||||
<div className={styles.sourceIcon}>
|
||||
{sourceIcons[source.sourceType] || "📋"}
|
||||
</div>
|
||||
<div className={styles.sourceInfo}>
|
||||
<div className={styles.sourceName}>
|
||||
{source.sourceTypeName || SOURCE_TYPE_TEXT[source.sourceType] || "未知来源"}
|
||||
{/* 显示来源名称:优先用 sourceName,群成员来源时用 chatroomInfo,避免重复 */}
|
||||
{source.sourceType === 2 ? (
|
||||
// 群成员来源:优先显示 chatroomInfo 的群名称
|
||||
source.chatroomInfo?.chatroomName && (
|
||||
<span className={styles.chatroomName}>
|
||||
{` - ${source.chatroomInfo.chatroomName}`}
|
||||
</span>
|
||||
)
|
||||
) : (
|
||||
// 其他来源:显示 sourceName
|
||||
source.sourceName && ` - ${source.sourceName}`
|
||||
{/* 搜索框 */}
|
||||
<div className={styles.searchBarWrapper}>
|
||||
<SearchBar
|
||||
placeholder="搜索来源名称"
|
||||
value={sourcesSearchInput}
|
||||
onChange={handleSourcesSearchChange}
|
||||
onClear={() => {
|
||||
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();
|
||||
}
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
{allSources && allSources.length > 0 ? (
|
||||
<>
|
||||
<div className={`${styles.sourceList} ${showAllSources ? styles.sourceListExpanded : ''}`}>
|
||||
{(showAllSources ? allSources : allSources.slice(0, 5)).map((source: any, index: number) => (
|
||||
<div key={index} className={styles.sourceItem}>
|
||||
<div className={styles.sourceIcon}>
|
||||
{sourceIcons[source.sourceType] || "📋"}
|
||||
</div>
|
||||
<div className={styles.sourceInfo}>
|
||||
<div className={styles.sourceName}>
|
||||
{source.sourceTypeName || SOURCE_TYPE_TEXT[source.sourceType] || "未知来源"}
|
||||
{/* 显示来源名称:优先用 sourceName,群成员来源时用 chatroomInfo,避免重复 */}
|
||||
{source.sourceType === 2 ? (
|
||||
// 群成员来源:优先显示 chatroomInfo 的群名称
|
||||
source.chatroomInfo?.chatroomName && (
|
||||
<span className={styles.chatroomName}>
|
||||
{` - ${source.chatroomInfo.chatroomName}`}
|
||||
</span>
|
||||
)
|
||||
) : (
|
||||
// 其他来源:显示 sourceName
|
||||
source.sourceName && ` - ${source.sourceName}`
|
||||
)}
|
||||
</div>
|
||||
{/* 显示ID:群ID或好友ID */}
|
||||
{source.displayId && (
|
||||
<div className={styles.sourceId}>
|
||||
{source.sourceType === 2 ? '群ID:' : source.sourceType === 1 ? '好友ID:' : 'ID:'}
|
||||
<span className={styles.idValue}>{source.displayId}</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
{/* 显示ID:群ID或好友ID */}
|
||||
{source.displayId && (
|
||||
<div className={styles.sourceId}>
|
||||
{source.sourceType === 2 ? '群ID:' : source.sourceType === 1 ? '好友ID:' : 'ID:'}
|
||||
<span className={styles.idValue}>{source.displayId}</span>
|
||||
{/* 群归属客服信息 */}
|
||||
{source.sourceType === 2 && source.chatroomOwners && source.chatroomOwners.length > 0 && (
|
||||
<div className={styles.chatroomOwners}>
|
||||
归属客服:
|
||||
{source.chatroomOwners.map((owner: any, ownerIndex: number) => (
|
||||
<span key={ownerIndex} className={styles.ownerTag}>
|
||||
{owner.ownerNickname || owner.ownerAlias || owner.ownerWechatId}
|
||||
{ownerIndex < source.chatroomOwners.length - 1 && '、'}
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
<div className={styles.sourceTime}>
|
||||
{source.createTimeFormatted || formatTime(source.createTime)}
|
||||
</div>
|
||||
)}
|
||||
{/* 群归属客服信息 */}
|
||||
{source.sourceType === 2 && source.chatroomOwners && source.chatroomOwners.length > 0 && (
|
||||
<div className={styles.chatroomOwners}>
|
||||
归属客服:
|
||||
{source.chatroomOwners.map((owner: any, ownerIndex: number) => (
|
||||
<span key={ownerIndex} className={styles.ownerTag}>
|
||||
{owner.ownerNickname || owner.ownerAlias || owner.ownerWechatId}
|
||||
{ownerIndex < source.chatroomOwners.length - 1 && '、'}
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
<div className={styles.sourceTime}>
|
||||
{source.createTimeFormatted || formatTime(source.createTime)}
|
||||
</div>
|
||||
{source.isFirstSource === 1 && (
|
||||
<span className={styles.firstBadge}>首次</span>
|
||||
)}
|
||||
</div>
|
||||
{source.isFirstSource === 1 && (
|
||||
<span className={styles.firstBadge}>首次</span>
|
||||
)}
|
||||
))}
|
||||
</div>
|
||||
{allSources.length > 5 && (
|
||||
<div className={styles.showMoreBtn} onClick={() => setShowAllSources(!showAllSources)}>
|
||||
{showAllSources ? '收起' : `显示更多 (${allSources.length - 5}条)`}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
{showAllSources && allSources.length < sourcesTotal && (
|
||||
<div
|
||||
className={`${styles.showMoreBtn} ${sourcesLoading ? styles.loading : ''}`}
|
||||
onClick={() => {
|
||||
if (!sourcesLoading) {
|
||||
loadMoreSources();
|
||||
}
|
||||
}}
|
||||
>
|
||||
{sourcesLoading ? '加载中...' : `加载更多 (${sourcesTotal - allSources.length}条)`}
|
||||
</div>
|
||||
)}
|
||||
{/* 如果已加载50条但总数未知,也显示加载更多 */}
|
||||
{showAllSources && allSources.length >= 50 && sourcesTotal === allSources.length && (
|
||||
<div
|
||||
className={`${styles.showMoreBtn} ${sourcesLoading ? styles.loading : ''}`}
|
||||
onClick={() => {
|
||||
if (!sourcesLoading) {
|
||||
loadMoreSources();
|
||||
}
|
||||
}}
|
||||
>
|
||||
{sourcesLoading ? '加载中...' : '加载更多'}
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
) : (
|
||||
<div className={styles.emptyBehavior}>暂无来源记录</div>
|
||||
)}
|
||||
@@ -486,11 +781,43 @@ const TrafficPoolDetail: React.FC = () => {
|
||||
<div className={styles.sectionTitle}>
|
||||
<HistoryOutlined className={styles.icon} />
|
||||
行为轨迹
|
||||
{behaviorsTotal > 0 && (
|
||||
<span className={styles.sourceCount}>({behaviorsTotal})</span>
|
||||
)}
|
||||
</div>
|
||||
<div className={styles.sectionContent}>
|
||||
{detail.behaviors && detail.behaviors.length > 0 ? (
|
||||
<div className={styles.behaviorList}>
|
||||
{detail.behaviors.slice(0, 20).map((behavior: any, index: number) => (
|
||||
{/* 搜索框 */}
|
||||
<div className={styles.searchBarWrapper}>
|
||||
<SearchBar
|
||||
placeholder="搜索行为名称"
|
||||
value={behaviorsSearchInput}
|
||||
onChange={handleBehaviorsSearchChange}
|
||||
onClear={() => {
|
||||
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();
|
||||
}
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
{allBehaviors && allBehaviors.length > 0 ? (
|
||||
<>
|
||||
<div className={styles.behaviorList}>
|
||||
{allBehaviors.map((behavior: any, index: number) => (
|
||||
<div key={index} className={styles.behaviorItem}>
|
||||
<div className={styles.behaviorIcon}>
|
||||
{behaviorIcons[behavior.behaviorType] || "📋"}
|
||||
@@ -506,7 +833,33 @@ const TrafficPoolDetail: React.FC = () => {
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
{allBehaviors.length < behaviorsTotal && (
|
||||
<div
|
||||
className={`${styles.showMoreBtn} ${behaviorsLoading ? styles.loading : ''}`}
|
||||
onClick={() => {
|
||||
if (!behaviorsLoading) {
|
||||
loadMoreBehaviors();
|
||||
}
|
||||
}}
|
||||
>
|
||||
{behaviorsLoading ? '加载中...' : `加载更多 (${behaviorsTotal - allBehaviors.length}条)`}
|
||||
</div>
|
||||
)}
|
||||
{/* 如果已加载50条但总数未知,也显示加载更多 */}
|
||||
{allBehaviors.length >= 50 && behaviorsTotal === allBehaviors.length && (
|
||||
<div
|
||||
className={`${styles.showMoreBtn} ${behaviorsLoading ? styles.loading : ''}`}
|
||||
onClick={() => {
|
||||
if (!behaviorsLoading) {
|
||||
loadMoreBehaviors();
|
||||
}
|
||||
}}
|
||||
>
|
||||
{behaviorsLoading ? '加载中...' : '加载更多'}
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
) : (
|
||||
<div className={styles.emptyBehavior}>暂无行为记录</div>
|
||||
)}
|
||||
|
||||
@@ -195,6 +195,46 @@ class TrafficPoolBehavior extends Model
|
||||
->limit($limit)
|
||||
->select();
|
||||
}
|
||||
|
||||
/**
|
||||
* 分页获取用户行为轨迹
|
||||
* @param int $poolCompanyId
|
||||
* @param int $page 页码
|
||||
* @param int $pageSize 每页数量
|
||||
* @param string $keyword 搜索关键词(搜索行为名称)
|
||||
* @param int $behaviorType 行为类型筛选
|
||||
* @return array ['list' => [], 'total' => 0, 'page' => 1, 'pageSize' => 10]
|
||||
*/
|
||||
public static function getUserJourneyPaginated(int $poolCompanyId, int $page = 1, int $pageSize = 20, string $keyword = '', int $behaviorType = 0): array
|
||||
{
|
||||
$query = self::where('poolCompanyId', $poolCompanyId);
|
||||
|
||||
// 关键词搜索
|
||||
if (!empty($keyword)) {
|
||||
$query->where('behaviorName', 'like', '%' . $keyword . '%');
|
||||
}
|
||||
|
||||
// 行为类型筛选
|
||||
if ($behaviorType > 0) {
|
||||
$query->where('behaviorType', $behaviorType);
|
||||
}
|
||||
|
||||
// 统计总数
|
||||
$total = $query->count();
|
||||
|
||||
// 分页查询
|
||||
$behaviors = $query->order('behaviorTime DESC')
|
||||
->page($page, $pageSize)
|
||||
->select()
|
||||
->toArray();
|
||||
|
||||
return [
|
||||
'list' => $behaviors,
|
||||
'total' => $total,
|
||||
'page' => $page,
|
||||
'pageSize' => $pageSize
|
||||
];
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
|
||||
@@ -135,14 +135,19 @@ class TrafficPoolSource extends Model
|
||||
/**
|
||||
* 获取流量的所有来源(带群归属信息)
|
||||
* @param int $poolCompanyId
|
||||
* @param int $limit 限制数量,0表示不限制
|
||||
* @return array
|
||||
*/
|
||||
public static function getSourcesWithOwners(int $poolCompanyId): array
|
||||
public static function getSourcesWithOwners(int $poolCompanyId, int $limit = 0): array
|
||||
{
|
||||
$sources = self::where('poolCompanyId', $poolCompanyId)
|
||||
->order('createTime DESC')
|
||||
->select()
|
||||
->toArray();
|
||||
$query = self::where('poolCompanyId', $poolCompanyId)
|
||||
->order('createTime DESC');
|
||||
|
||||
if ($limit > 0) {
|
||||
$query->limit($limit);
|
||||
}
|
||||
|
||||
$sources = $query->select()->toArray();
|
||||
|
||||
if (empty($sources)) {
|
||||
return [];
|
||||
@@ -325,6 +330,125 @@ class TrafficPoolSource extends Model
|
||||
|
||||
return $chatroom;
|
||||
}
|
||||
|
||||
/**
|
||||
* 分页获取流量的来源(带群归属信息)
|
||||
* @param int $poolCompanyId
|
||||
* @param int $page 页码
|
||||
* @param int $pageSize 每页数量
|
||||
* @param string $keyword 搜索关键词(搜索来源名称)
|
||||
* @return array ['list' => [], 'total' => 0, 'page' => 1, 'pageSize' => 10]
|
||||
*/
|
||||
public static function getSourcesWithOwnersPaginated(int $poolCompanyId, int $page = 1, int $pageSize = 20, string $keyword = ''): array
|
||||
{
|
||||
$query = self::where('poolCompanyId', $poolCompanyId);
|
||||
|
||||
// 关键词搜索
|
||||
if (!empty($keyword)) {
|
||||
$query->where('sourceName', 'like', '%' . $keyword . '%');
|
||||
}
|
||||
|
||||
// 统计总数
|
||||
$total = $query->count();
|
||||
|
||||
// 分页查询
|
||||
$sources = $query->order('createTime DESC')
|
||||
->page($page, $pageSize)
|
||||
->select()
|
||||
->toArray();
|
||||
|
||||
if (empty($sources)) {
|
||||
return [
|
||||
'list' => [],
|
||||
'total' => 0,
|
||||
'page' => $page,
|
||||
'pageSize' => $pageSize
|
||||
];
|
||||
}
|
||||
|
||||
// 收集所有群ID
|
||||
$chatroomIds = [];
|
||||
foreach ($sources as $source) {
|
||||
if (!empty($source['sourceChatroomId'])) {
|
||||
$chatroomIds[] = $source['sourceChatroomId'];
|
||||
}
|
||||
}
|
||||
|
||||
// 查询群信息和归属客服
|
||||
$chatroomOwners = [];
|
||||
if (!empty($chatroomIds)) {
|
||||
$chatroomOwners = self::getChatroomOwners($chatroomIds);
|
||||
}
|
||||
|
||||
// 组装数据(按来源类型和关键标识去重)
|
||||
$result = [];
|
||||
$seenChatroomIds = []; // 用于群成员来源去重
|
||||
$seenFriendIds = []; // 用于好友添加来源去重
|
||||
|
||||
foreach ($sources as $source) {
|
||||
// 群成员来源去重:同一个群只保留一条记录
|
||||
if ($source['sourceType'] == self::SOURCE_TYPE_GROUP_MEMBER && !empty($source['sourceChatroomId'])) {
|
||||
$chatroomId = $source['sourceChatroomId'];
|
||||
if (isset($seenChatroomIds[$chatroomId])) {
|
||||
continue; // 跳过重复的群
|
||||
}
|
||||
$seenChatroomIds[$chatroomId] = true;
|
||||
}
|
||||
|
||||
// 好友添加来源去重:按来源微信ID或来源名称去重
|
||||
if ($source['sourceType'] == self::SOURCE_TYPE_FRIEND_ADD) {
|
||||
$friendAddKey = $source['sourceWechatId'] ?? ($source['sourceName'] ?? '');
|
||||
if (!empty($friendAddKey) && isset($seenFriendIds[$friendAddKey])) {
|
||||
continue; // 跳过重复的好友添加
|
||||
}
|
||||
$seenFriendIds[$friendAddKey] = true;
|
||||
}
|
||||
|
||||
$sourceData = $source;
|
||||
|
||||
// 设置显示ID
|
||||
if ($source['sourceType'] == self::SOURCE_TYPE_GROUP_MEMBER && !empty($source['sourceChatroomId'])) {
|
||||
$sourceData['displayId'] = "群ID:" . $source['sourceChatroomId'];
|
||||
} elseif ($source['sourceType'] == self::SOURCE_TYPE_FRIEND_ADD) {
|
||||
$sourceData['displayId'] = "好友ID:" . ($source['sourceWechatId'] ?? $source['sourceName'] ?? '-');
|
||||
} else {
|
||||
$sourceData['displayId'] = null;
|
||||
}
|
||||
|
||||
// 格式化时间(兼容时间戳和日期字符串)
|
||||
if (!empty($source['createTime'])) {
|
||||
if (is_numeric($source['createTime'])) {
|
||||
$sourceData['createTimeFormatted'] = date('Y-m-d H:i:s', (int)$source['createTime']);
|
||||
} else {
|
||||
$sourceData['createTimeFormatted'] = $source['createTime'];
|
||||
}
|
||||
} else {
|
||||
$sourceData['createTimeFormatted'] = null;
|
||||
}
|
||||
|
||||
// 添加来源类型名称
|
||||
$sourceData['sourceTypeName'] = self::SOURCE_TYPE_NAMES[$source['sourceType']] ?? '未知来源';
|
||||
|
||||
// 如果是群成员来源,添加群归属信息
|
||||
if ($source['sourceType'] == self::SOURCE_TYPE_GROUP_MEMBER && !empty($source['sourceChatroomId'])) {
|
||||
$chatroomId = $source['sourceChatroomId'];
|
||||
$sourceData['chatroomOwners'] = $chatroomOwners[$chatroomId] ?? [];
|
||||
$sourceData['chatroomInfo'] = self::getChatroomInfo($chatroomId);
|
||||
} else {
|
||||
$sourceData['chatroomOwners'] = [];
|
||||
$sourceData['chatroomInfo'] = null;
|
||||
}
|
||||
|
||||
$result[] = $sourceData;
|
||||
}
|
||||
|
||||
return [
|
||||
'list' => $result,
|
||||
'total' => $total,
|
||||
'page' => $page,
|
||||
'pageSize' => $pageSize
|
||||
];
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
|
||||
@@ -14,7 +14,7 @@ class TagEngineService
|
||||
* API基础URL
|
||||
* @var string
|
||||
*/
|
||||
private $baseUrl = 'http://192.168.1.134:3000';
|
||||
private $baseUrl = 'http://192.168.1.40:8080';
|
||||
|
||||
/**
|
||||
* API Key
|
||||
|
||||
@@ -104,6 +104,7 @@ Route::group('v1/', function () {
|
||||
Route::get('tag/pool-tags', 'app\cunkebao\controller\TrafficPoolV2Controller@getPoolTags'); // 获取流量的标签
|
||||
Route::post('tag/add', 'app\cunkebao\controller\TrafficPoolV2Controller@addTag'); // 添加标签
|
||||
Route::delete('tag/remove', 'app\cunkebao\controller\TrafficPoolV2Controller@removeTag'); // 移除标签
|
||||
Route::post('tag/sync-from-engine', 'app\cunkebao\controller\TrafficPoolV2Controller@syncTagsFromEngine'); // 从标签引擎同步标签
|
||||
|
||||
// 分配相关
|
||||
Route::post('allocate', 'app\cunkebao\controller\TrafficPoolV2Controller@allocatePool'); // 分配流量
|
||||
@@ -111,6 +112,10 @@ Route::group('v1/', function () {
|
||||
|
||||
// 统计相关
|
||||
Route::get('statistics', 'app\cunkebao\controller\TrafficPoolV2Controller@getStatistics'); // 获取统计数据
|
||||
|
||||
// 来源和行为相关
|
||||
Route::get('sources', 'app\cunkebao\controller\TrafficPoolV2Controller@getPoolSources'); // 分页获取来源
|
||||
Route::get('behaviors', 'app\cunkebao\controller\TrafficPoolV2Controller@getPoolBehaviors'); // 分页获取行为轨迹
|
||||
});
|
||||
|
||||
// 工作台相关
|
||||
|
||||
@@ -10,6 +10,8 @@ use app\common\model\TrafficPoolTag;
|
||||
use app\common\model\TrafficPoolTagCategory;
|
||||
use app\common\model\TrafficPoolTagDefine;
|
||||
use app\common\model\TrafficPoolAllotRecord;
|
||||
use app\common\model\TrafficPoolSource;
|
||||
use app\common\model\TrafficPoolBehavior;
|
||||
use app\common\service\ClassTableService;
|
||||
use library\ResponseHelper;
|
||||
|
||||
@@ -461,6 +463,28 @@ class TrafficPoolV2Controller extends BaseController
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 从标签引擎同步用户标签
|
||||
* @return \think\response\Json
|
||||
*/
|
||||
public function syncTagsFromEngine()
|
||||
{
|
||||
$poolCompanyId = $this->request->param('poolCompanyId', 0, 'intval');
|
||||
$companyId = $this->getUserInfo('companyId');
|
||||
$operatorId = $this->getUserInfo('id');
|
||||
|
||||
if (empty($poolCompanyId)) {
|
||||
return ResponseHelper::error('流量ID不能为空');
|
||||
}
|
||||
|
||||
try {
|
||||
$result = $this->poolService->syncTagsFromEngine($poolCompanyId, $companyId, $operatorId);
|
||||
return ResponseHelper::success($result, "同步成功:已同步 {$result['syncedCount']} 个标签");
|
||||
} catch (\Exception $e) {
|
||||
return ResponseHelper::error('同步标签失败:' . $e->getMessage());
|
||||
}
|
||||
}
|
||||
|
||||
// ==================== 分配相关接口 ====================
|
||||
|
||||
/**
|
||||
@@ -565,5 +589,74 @@ class TrafficPoolV2Controller extends BaseController
|
||||
return ResponseHelper::error('获取统计数据失败:' . $e->getMessage());
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 分页获取流量来源
|
||||
* @return \think\response\Json
|
||||
*/
|
||||
public function getPoolSources()
|
||||
{
|
||||
$poolCompanyId = $this->request->param('poolCompanyId', 0, 'intval');
|
||||
$companyId = $this->getUserInfo('companyId');
|
||||
$page = $this->request->param('page', 1, 'intval');
|
||||
$pageSize = $this->request->param('pageSize', 20, 'intval');
|
||||
$keyword = $this->request->param('keyword', '');
|
||||
|
||||
if (empty($poolCompanyId)) {
|
||||
return ResponseHelper::error('流量ID不能为空');
|
||||
}
|
||||
|
||||
// 验证流量归属
|
||||
$poolCompany = TrafficPoolCompany::where('id', $poolCompanyId)
|
||||
->where('companyId', $companyId)
|
||||
->where('isDel', 0)
|
||||
->find();
|
||||
|
||||
if (!$poolCompany) {
|
||||
return ResponseHelper::error('流量不存在');
|
||||
}
|
||||
|
||||
try {
|
||||
$result = TrafficPoolSource::getSourcesWithOwnersPaginated($poolCompanyId, $page, $pageSize, $keyword);
|
||||
return ResponseHelper::success($result);
|
||||
} catch (\Exception $e) {
|
||||
return ResponseHelper::error('获取来源列表失败:' . $e->getMessage());
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 分页获取流量行为轨迹
|
||||
* @return \think\response\Json
|
||||
*/
|
||||
public function getPoolBehaviors()
|
||||
{
|
||||
$poolCompanyId = $this->request->param('poolCompanyId', 0, 'intval');
|
||||
$companyId = $this->getUserInfo('companyId');
|
||||
$page = $this->request->param('page', 1, 'intval');
|
||||
$pageSize = $this->request->param('pageSize', 20, 'intval');
|
||||
$keyword = $this->request->param('keyword', '');
|
||||
$behaviorType = $this->request->param('behaviorType', 0, 'intval');
|
||||
|
||||
if (empty($poolCompanyId)) {
|
||||
return ResponseHelper::error('流量ID不能为空');
|
||||
}
|
||||
|
||||
// 验证流量归属
|
||||
$poolCompany = TrafficPoolCompany::where('id', $poolCompanyId)
|
||||
->where('companyId', $companyId)
|
||||
->where('isDel', 0)
|
||||
->find();
|
||||
|
||||
if (!$poolCompany) {
|
||||
return ResponseHelper::error('流量不存在');
|
||||
}
|
||||
|
||||
try {
|
||||
$result = TrafficPoolBehavior::getUserJourneyPaginated($poolCompanyId, $page, $pageSize, $keyword, $behaviorType);
|
||||
return ResponseHelper::success($result);
|
||||
} catch (\Exception $e) {
|
||||
return ResponseHelper::error('获取行为轨迹失败:' . $e->getMessage());
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -287,8 +287,8 @@ class TrafficPoolService
|
||||
// 获取标签
|
||||
$data['tags'] = TrafficPoolTag::getTagsByPoolCompany($poolCompanyId)->toArray();
|
||||
|
||||
// 获取来源历史(带群归属信息)
|
||||
$data['sources'] = TrafficPoolSource::getSourcesWithOwners($poolCompanyId);
|
||||
// 获取来源历史(带群归属信息,限制50条)
|
||||
$data['sources'] = TrafficPoolSource::getSourcesWithOwners($poolCompanyId, 50);
|
||||
|
||||
// 获取行为轨迹(最近50条)
|
||||
$data['behaviors'] = TrafficPoolBehavior::getUserJourney($poolCompanyId, 50)->toArray();
|
||||
@@ -296,6 +296,58 @@ class TrafficPoolService
|
||||
// 获取分配历史
|
||||
$data['allotRecords'] = TrafficPoolAllotRecord::getAllotHistory($poolCompanyId)->toArray();
|
||||
|
||||
// 如果消息数为0,从微信消息表中统计实际消息数
|
||||
if (empty($data['totalMsgCount']) || $data['totalMsgCount'] == 0) {
|
||||
$msgCount = 0;
|
||||
|
||||
// 优先通过wechatFriendId统计(最准确)
|
||||
if (!empty($poolCompany->wechatFriendId)) {
|
||||
$msgCount = Db::table('s2_wechat_message')
|
||||
->where('wechatFriendId', $poolCompany->wechatFriendId)
|
||||
->where('type', 1) // 好友消息(type=1)
|
||||
->where('isDeleted', 0)
|
||||
->count();
|
||||
}
|
||||
|
||||
// 如果wechatFriendId没有统计到,尝试通过identifier(微信ID)统计
|
||||
if ($msgCount == 0 && !empty($poolCompany->identifier)) {
|
||||
// 统计发送者或接收者是该微信ID的消息
|
||||
// 需要关联s2_wechat_friend表,通过wechatId匹配
|
||||
$msgCount = Db::table('s2_wechat_message')
|
||||
->alias('wm')
|
||||
->join(['s2_wechat_friend' => 'wf'], 'wm.wechatFriendId = wf.id', 'LEFT')
|
||||
->where(function($query) use ($poolCompany) {
|
||||
$query->where('wm.senderWechatId', $poolCompany->identifier)
|
||||
->whereOr('wf.wechatId', $poolCompany->identifier);
|
||||
})
|
||||
->where('wm.type', 1) // 好友消息
|
||||
->where('wm.isDeleted', 0)
|
||||
->where('wf.isDeleted', 0)
|
||||
->count();
|
||||
}
|
||||
|
||||
// 如果从行为表也有记录,取较大值(兼容旧数据)
|
||||
$behaviorMsgCount = TrafficPoolBehavior::where('poolCompanyId', $poolCompanyId)
|
||||
->whereIn('behaviorType', [
|
||||
TrafficPoolBehavior::BEHAVIOR_TYPE_SEND_MSG,
|
||||
TrafficPoolBehavior::BEHAVIOR_TYPE_RECEIVE_MSG
|
||||
])
|
||||
->count();
|
||||
|
||||
if ($behaviorMsgCount > $msgCount) {
|
||||
$msgCount = $behaviorMsgCount;
|
||||
}
|
||||
|
||||
if ($msgCount > 0) {
|
||||
// 更新数据库中的消息数
|
||||
$poolCompany->save([
|
||||
'totalMsgCount' => $msgCount,
|
||||
'updateTime' => time()
|
||||
]);
|
||||
$data['totalMsgCount'] = $msgCount;
|
||||
}
|
||||
}
|
||||
|
||||
// 计算 RFM
|
||||
$data['rfmR'] = $poolCompany->lastInteractTime ? (int)floor((time() - $poolCompany->lastInteractTime) / 86400) : 9999;
|
||||
$data['rfmScore'] = $this->calculateRfmScore($data['rfmR'], $data['rfmF'], $data['rfmM']);
|
||||
@@ -502,6 +554,209 @@ class TrafficPoolService
|
||||
'sourceDistribution' => $sourceDistribution
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* 从标签引擎同步用户标签
|
||||
*
|
||||
* @param int $poolCompanyId 流量池公司ID
|
||||
* @param int $companyId 公司ID
|
||||
* @param int $operatorId 操作人ID
|
||||
* @return array 同步结果
|
||||
*/
|
||||
public function syncTagsFromEngine(int $poolCompanyId, int $companyId, int $operatorId = null)
|
||||
{
|
||||
// 获取流量池记录
|
||||
$poolCompany = TrafficPoolCompany::where('id', $poolCompanyId)
|
||||
->where('companyId', $companyId)
|
||||
->where('isDel', 0)
|
||||
->find();
|
||||
|
||||
if (!$poolCompany) {
|
||||
throw new \Exception('流量不存在');
|
||||
}
|
||||
|
||||
// 获取标识信息用于查询标签引擎
|
||||
$identifiers = [];
|
||||
|
||||
// 微信ID
|
||||
if (!empty($poolCompany->identifier)) {
|
||||
$identifiers[] = [
|
||||
'type' => 'wechat',
|
||||
'value' => $poolCompany->identifier
|
||||
];
|
||||
}
|
||||
|
||||
// 手机号
|
||||
if (!empty($poolCompany->phone)) {
|
||||
$identifiers[] = [
|
||||
'type' => 'phone',
|
||||
'value' => $poolCompany->phone
|
||||
];
|
||||
}
|
||||
|
||||
if (empty($identifiers)) {
|
||||
throw new \Exception('无有效标识可用于查询标签');
|
||||
}
|
||||
|
||||
|
||||
// 调用标签引擎服务
|
||||
$tagEngineService = new \app\common\service\TagEngineService();
|
||||
$result = $tagEngineService->queryByIdentifiers($identifiers, [
|
||||
'mask_identifier' => false
|
||||
]);
|
||||
//exit_data($result);
|
||||
if ($result === false) {
|
||||
throw new \Exception('标签引擎查询失败');
|
||||
}
|
||||
|
||||
// 检查返回结果
|
||||
if (isset($result['code']) && $result['code'] !== 0) {
|
||||
throw new \Exception($result['message'] ?? '标签引擎返回错误');
|
||||
}
|
||||
|
||||
$data = $result['data'] ?? $result;
|
||||
if (!is_array($data)) {
|
||||
$data = [];
|
||||
}
|
||||
|
||||
$syncedCount = 0;
|
||||
$skippedCount = 0;
|
||||
|
||||
// 处理返回的标签数据
|
||||
foreach ($data as $item) {
|
||||
if (empty($item['found']) || empty($item['tags'])) {
|
||||
continue;
|
||||
}
|
||||
|
||||
foreach ($item['tags'] as $tagData) {
|
||||
try {
|
||||
// 查找或创建标签定义
|
||||
$tagDefine = $this->findOrCreateTagDefine(
|
||||
$companyId,
|
||||
$tagData['tag_code'] ?? '',
|
||||
$tagData['tag_name'] ?? '',
|
||||
$tagData['category'] ?? '标签引擎',
|
||||
$tagData['tag_type'] ?? 'string'
|
||||
);
|
||||
|
||||
if (!$tagDefine) {
|
||||
$skippedCount++;
|
||||
continue;
|
||||
}
|
||||
|
||||
// 添加标签到流量池
|
||||
$tag = TrafficPoolTag::addTag(
|
||||
$poolCompanyId,
|
||||
$poolCompany->identifier,
|
||||
$companyId,
|
||||
$tagDefine->id,
|
||||
TrafficPoolTag::SOURCE_AI, // 来源为AI/外部同步
|
||||
$operatorId,
|
||||
$tagData['tag_value'] ?? null,
|
||||
null // score
|
||||
);
|
||||
|
||||
if ($tag) {
|
||||
$syncedCount++;
|
||||
} else {
|
||||
$skippedCount++;
|
||||
}
|
||||
} catch (\Exception $e) {
|
||||
$skippedCount++;
|
||||
continue;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return [
|
||||
'syncedCount' => $syncedCount,
|
||||
'skippedCount' => $skippedCount,
|
||||
'total' => $syncedCount + $skippedCount
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* 查找或创建标签定义
|
||||
*
|
||||
* @param int $companyId 公司ID
|
||||
* @param string $tagCode 标签代码
|
||||
* @param string $tagName 标签名称
|
||||
* @param string $categoryName 分类名称
|
||||
* @param string $valueType 值类型
|
||||
* @return \app\common\model\TrafficPoolTagDefine|null
|
||||
*/
|
||||
protected function findOrCreateTagDefine(
|
||||
int $companyId,
|
||||
string $tagCode,
|
||||
string $tagName,
|
||||
string $categoryName,
|
||||
string $valueType
|
||||
) {
|
||||
if (empty($tagName)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
// 标签类型映射
|
||||
$typeMap = [
|
||||
'numeric' => 'number',
|
||||
'enum' => 'enum',
|
||||
'string' => 'string',
|
||||
'boolean' => 'boolean',
|
||||
'datetime' => 'datetime',
|
||||
'json' => 'json',
|
||||
];
|
||||
$mappedType = $typeMap[$valueType] ?? 'string';
|
||||
|
||||
// 先查找是否已存在该标签定义
|
||||
$tagDefine = \app\common\model\TrafficPoolTagDefine::where('companyId', $companyId)
|
||||
->where('tagName', $tagName)
|
||||
->where('tagType', TrafficPoolTag::TAG_TYPE_AI) // AI标签类型
|
||||
->where('isDel', 0)
|
||||
->find();
|
||||
|
||||
if ($tagDefine) {
|
||||
return $tagDefine;
|
||||
}
|
||||
|
||||
// 查找或创建分类
|
||||
$category = \app\common\model\TrafficPoolTagCategory::where('companyId', $companyId)
|
||||
->where('categoryName', $categoryName)
|
||||
->where('tagType', TrafficPoolTag::TAG_TYPE_AI)
|
||||
->where('isDel', 0)
|
||||
->find();
|
||||
|
||||
if (!$category) {
|
||||
$category = new \app\common\model\TrafficPoolTagCategory();
|
||||
$category->save([
|
||||
'companyId' => $companyId,
|
||||
'tagType' => TrafficPoolTag::TAG_TYPE_AI,
|
||||
'categoryName' => $categoryName,
|
||||
'description' => '从标签引擎同步的标签分类',
|
||||
'sortOrder' => 0,
|
||||
'isDel' => 0,
|
||||
'createTime' => time(),
|
||||
'updateTime' => time()
|
||||
]);
|
||||
}
|
||||
|
||||
// 创建标签定义
|
||||
$tagDefine = new \app\common\model\TrafficPoolTagDefine();
|
||||
$tagDefine->save([
|
||||
'companyId' => $companyId,
|
||||
'categoryId' => $category->id,
|
||||
'tagType' => TrafficPoolTag::TAG_TYPE_AI,
|
||||
'tagCode' => $tagCode ?: 'engine_' . md5($tagName),
|
||||
'tagName' => $tagName,
|
||||
'valueType' => $mappedType,
|
||||
'description' => '从标签引擎同步',
|
||||
'isSystem' => 0,
|
||||
'isDel' => 0,
|
||||
'createTime' => time(),
|
||||
'updateTime' => time()
|
||||
]);
|
||||
|
||||
return $tagDefine;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
|
||||
@@ -1821,8 +1821,8 @@ class Adapter implements WeChatServiceInterface
|
||||
COALESCE(f.M, 0) AS rfmM,
|
||||
0 AS rfmScore,
|
||||
NULL AS rfmType,
|
||||
0 AS totalMsgCount,
|
||||
NULL AS lastMsgTime,
|
||||
COALESCE(msg_stats.msgCount, 0) AS totalMsgCount,
|
||||
msg_stats.lastMsgTime AS lastMsgTime,
|
||||
1 AS firstSourceType,
|
||||
f.createTime AS firstSourceTime,
|
||||
1 AS lifecycle,
|
||||
@@ -1834,6 +1834,15 @@ class Adapter implements WeChatServiceInterface
|
||||
JOIN ck_traffic_pool tp ON tp.identifier = f.wechatId
|
||||
LEFT JOIN s2_wechat_account a ON f.wechatAccountId = a.id
|
||||
LEFT JOIN s2_company_account c ON c.id = a.deviceAccountId
|
||||
LEFT JOIN (
|
||||
SELECT
|
||||
wechatFriendId,
|
||||
COUNT(*) AS msgCount,
|
||||
MAX(createTime) AS lastMsgTime
|
||||
FROM s2_wechat_message
|
||||
WHERE type = 1 AND isDeleted = 0
|
||||
GROUP BY wechatFriendId
|
||||
) msg_stats ON msg_stats.wechatFriendId = f.id
|
||||
WHERE c.departmentId IS NOT NULL
|
||||
ORDER BY f.id DESC
|
||||
LIMIT ?, ?
|
||||
@@ -1850,6 +1859,8 @@ class Adapter implements WeChatServiceInterface
|
||||
lastInteractTime = GREATEST(COALESCE(ck_traffic_pool_company.lastInteractTime, 0), COALESCE(VALUES(lastInteractTime), 0)),
|
||||
rfmF = COALESCE(VALUES(rfmF), ck_traffic_pool_company.rfmF),
|
||||
rfmM = COALESCE(VALUES(rfmM), ck_traffic_pool_company.rfmM),
|
||||
totalMsgCount = GREATEST(COALESCE(ck_traffic_pool_company.totalMsgCount, 0), COALESCE(VALUES(totalMsgCount), 0)),
|
||||
lastMsgTime = GREATEST(COALESCE(ck_traffic_pool_company.lastMsgTime, 0), COALESCE(VALUES(lastMsgTime), 0)),
|
||||
status = VALUES(status),
|
||||
updateTime = UNIX_TIMESTAMP()";
|
||||
|
||||
|
||||
Reference in New Issue
Block a user