代码优化

This commit is contained in:
wong
2026-02-02 17:02:49 +08:00
parent 208c25b2f7
commit 442be3b02c
10 changed files with 1095 additions and 69 deletions

View File

@@ -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标签",
};

View File

@@ -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;
}
}

View File

@@ -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>
)}

View File

@@ -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
];
}
}

View File

@@ -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
];
}
}

View File

@@ -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

View File

@@ -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'); // 分页获取行为轨迹
});
// 工作台相关

View File

@@ -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());
}
}
}

View File

@@ -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;
}
}

View File

@@ -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()";