Browse Source

feat: 日志

jihuaqiang 1 week ago
parent
commit
2da5a3968f

+ 188 - 0
src/hooks/useLogger.ts

@@ -0,0 +1,188 @@
+import http from '@src/http';
+import { uploadLogApi } from '@src/http/api';
+import sso from '@src/http/sso';
+import { VideoItem } from '@src/views/publishContent/types';
+import { RecentNotUsedType, SortTypeEnum, TagType, VideoLibraryType } from '@src/views/publishContent/weCom/components/videoSelectModal';
+import { VideoSearchPlanType } from '@src/views/publishContent/weCom/type';
+
+type NormalLogParams = {
+	traceId: number;
+	requestId: number;
+	planType?: VideoSearchPlanType;
+	gzhAccountId?: number;
+	subChannel?: string;
+}
+
+type VideoListQueryLogParams = {
+	category?: string;
+	title?: string;
+	recentNotUsed?: RecentNotUsedType;
+	sortType?: SortTypeEnum;
+	tags?: TagType[];
+	videoLibraryType?: VideoLibraryType;
+} & NormalLogParams;
+
+type VideoLogParams = {
+	videoId: number;
+	playTime?: number;
+	idx?: number;
+	collect?: number;
+} & NormalLogParams;
+
+interface PublishPlanLogParams extends NormalLogParams {
+	videoList: VideoItem[];
+}
+
+const useLogger = () => {
+	const token = sso.getUserInfo()?.token;
+	const channel = sso.getUserInfo()?.channel;
+	const timestamp = Date.now();
+	const userAgent = navigator.userAgent;
+	const page = window.location.pathname;
+	const uploadLog = async ({ businessType, objectType, extParams }: { businessType: string, objectType?: string, extParams?: Record<string, any> }) => {
+		const log = {
+			token,
+			channel,
+			page,
+			userAgent,
+			timestamp,
+			businessType,
+			objectType,
+			extParams,
+		};
+		const response = await http.post(uploadLogApi, {
+			key: 'content-platform-user-behavior',
+			data: log,
+		});
+		return response;
+	}
+
+	const uploadLogVideoListQuery = ({
+		traceId,
+		requestId,
+		planType,
+		gzhAccountId,
+		subChannel,
+		category,
+		title,
+		recentNotUsed,
+		sortType,
+		tags,
+		videoLibraryType,
+	}:VideoListQueryLogParams) => { 
+		return uploadLog({
+			businessType: 'video_list_query',
+			objectType: 'video_list',
+			extParams: {
+				traceId,
+				requestId,
+				planType,
+				gzhAccountId,
+				subChannel,
+				category,
+				title,
+				recentNotUsed,
+				sortType,
+				tags,
+				videoLibraryType,
+			},
+		});
+	}
+
+	const uploadLogVideoPlay = ({videoId, traceId, requestId, planType, gzhAccountId, subChannel}: VideoLogParams) => { 
+		return uploadLog({
+			businessType: 'video_play',
+			objectType: 'video',
+			extParams: {
+				videoId,
+				traceId,
+				requestId,
+				planType,
+				gzhAccountId,
+				subChannel,
+			},
+		});
+	}
+
+	const uploadLogVideoPlayEnd = ({videoId, playTime, traceId, requestId, planType, gzhAccountId, subChannel}: VideoLogParams) => { 
+		return uploadLog({
+			businessType: 'video_play_end',
+			objectType: 'video',
+			extParams: {
+				videoId,
+				playTime,
+				traceId,
+				requestId,
+				planType,
+				gzhAccountId,
+				subChannel,
+			},
+		});
+	}
+
+	const uploadLogVideoView = ({videoId, idx, traceId, requestId, planType, gzhAccountId, subChannel}: VideoLogParams) => { 
+		return uploadLog({
+			businessType: 'video_view',
+			objectType: 'video',
+			extParams: {
+				videoId,
+				idx,
+				traceId,
+				requestId,
+				planType,
+				gzhAccountId,
+				subChannel,
+			},
+		});
+	}
+
+	const uploadLogVideoCollect = ({videoId, traceId, requestId, collect, planType, gzhAccountId, subChannel}: VideoLogParams) => { 
+		return uploadLog({
+			businessType: 'video_collect',
+			objectType: 'video',
+			extParams: {
+				videoId,
+				traceId,
+				requestId,
+				collect,
+				planType,
+				gzhAccountId,
+				subChannel,
+			},
+		});
+	}
+
+	const uploadLogVideoCreatePublish = ({
+		videoList,
+		traceId,
+		requestId,
+		planType,
+		gzhAccountId,
+		subChannel
+	}: PublishPlanLogParams) => { 
+		return uploadLog({
+			businessType: 'publish_plan_create',
+			objectType: 'publish_plan',
+			extParams: {
+				videoList,
+				traceId,
+				requestId,
+				planType,
+				gzhAccountId,
+				subChannel,
+			},
+		});
+	}
+
+	return {
+		uploadLog,
+		uploadLogVideoPlay,
+		uploadLogVideoView,
+		uploadLogVideoPlayEnd,
+		uploadLogVideoCollect,
+		uploadLogVideoListQuery,
+		uploadLogVideoCreatePublish,
+	};
+};
+
+export default useLogger;

+ 3 - 0
src/http/api.ts

@@ -69,3 +69,6 @@ export const bindPQUser = `${import.meta.env.VITE_API_URL}/contentPlatform/setti
 export const saveCollectVideoContentApi = `${import.meta.env.VITE_API_URL}/contentPlatform/collectContent/save`
 export const getCollectContentListApi = `${import.meta.env.VITE_API_URL}/contentPlatform/collectContent/list`
 export const clearInvalidCollectContentApi = `${import.meta.env.VITE_API_URL}/contentPlatform/collectContent/clearInvalid`
+
+// log 
+export const uploadLogApi = `${import.meta.env.VITE_API_URL}/contentPlatform/logHub/upload`

+ 13 - 1
src/views/publishContent/weCom/components/addPlanModal/index.tsx

@@ -6,6 +6,7 @@ import { WeComPlanType, WeVideoItem, AddWeComPlanParam, VideoSearchPlanType } fr
 import { CloseOutlined, PlusOutlined, CaretRightFilled } from '@ant-design/icons';
 import VideoSelectModal from '../videoSelectModal';
 import VideoPlayModal from '../videoPlayModal';
+import useLogger from '@src/hooks/useLogger';
 
 const { Paragraph } = Typography;
 
@@ -22,13 +23,15 @@ const AddPlanModal: React.FC<{
 	const [selectedVideos, setSelectedVideos] = useState<WeVideoItem[]>([]);
 	const [isVideoSelectVisible, setIsVideoSelectVisible] = useState(false);
 	const [playingVideo, setPlayingVideo] = useState<WeVideoItem | null>(null);
-	
+	const [traceId, setTraceId] = useState<number>(0);
+	const { uploadLogVideoCreatePublish } = useLogger();
 	useEffect(() => {
 		form.setFieldsValue({
 			type: initType,
 			subChannel: '',
 		});
 		setSelectedVideos([]);
+		setTraceId(new Date().getTime());
 	}, [visible]);
 
 	useEffect(() => {
@@ -61,6 +64,13 @@ const AddPlanModal: React.FC<{
 				subChannel: values.subChannel,
 				videoList: selectedVideos,
 			});
+			uploadLogVideoCreatePublish({
+				traceId: traceId,
+				requestId: 0,
+				planType: defaultSearchVideoType,
+				subChannel: form.getFieldValue('subChannel') as string,
+				videoList: selectedVideos,
+			});
 
 		});
 	}
@@ -181,6 +191,8 @@ const AddPlanModal: React.FC<{
 		onClose={() => {
 			setIsVideoSelectVisible(false);
 		}}
+		traceId={traceId}
+		subChannel={form.getFieldValue('subChannel') as string}
 		onOk={handleSelectVideo}
 		initialSelectedIds={selectedVideos.map((video) => video.videoId)}
 		planType={defaultSearchVideoType}

+ 125 - 4
src/views/publishContent/weCom/components/videoSelectModal/index.tsx

@@ -26,6 +26,7 @@ import { VideoSearchPlanType } from '@src/views/publishContent/weCom/type'
 import { enumToOptions } from '@src/utils/helper';
 import { isNil } from 'lodash';
 import { CollectedStatusEnum, VideoStatusEnum } from '@src/views/publishContent/types';
+import useLogger from '@src/hooks/useLogger';
 
 export enum VideoLibraryType {
 	平台视频库 = 0,
@@ -37,6 +38,9 @@ const { Text, Paragraph } = Typography;
 
 interface VideoSelectContentProps {
 	planType: VideoSearchPlanType;
+	traceId?: number;
+	gzhAccountId?: number;
+	subChannel?: string;
 	initialSelectedIds?: number[];
 	selectedVideos?: VideoItem[];
 	defaultVideoLibraryType?: VideoLibraryType;
@@ -47,6 +51,9 @@ interface VideoSelectContentProps {
 
 interface VideoSelectModalProps {
 	planType: VideoSearchPlanType;
+	traceId?: number;
+	subChannel?: string;
+	gzhAccountId?: number;
 	visible: boolean;
 	onClose: () => void;
 	onOk: (selectedVideos: VideoItem[]) => void;
@@ -105,6 +112,9 @@ export const VideoSelectContent: React.FC<VideoSelectContentProps> = ({
 	onVideoListChange,
 	autoLoad = true,
 	isCollectedPage = false,
+	traceId = 0,
+	subChannel = '',
+	gzhAccountId = 0,
 }) => {
 	const { videoCategoryOptions } = useVideoCategoryOptions();
 	const [tagOptions, setTagOptions] = useState<TagTypeOptions[]>([]);
@@ -123,6 +133,13 @@ export const VideoSelectContent: React.FC<VideoSelectContentProps> = ({
 	const [playingVideo, setPlayingVideo] = useState<VideoItem | null>(null);
 	const [videoLibraryType, setVideoLibraryType] = useState<VideoLibraryType>(defaultVideoLibraryType || VideoLibraryType.平台视频库);
 	const MAX_SELECTION = 3;
+	const { uploadLogVideoPlay, uploadLogVideoPlayEnd, uploadLogVideoCollect, uploadLogVideoListQuery, uploadLogVideoView } = useLogger();
+	const [requestId, setRequestId] = useState<number>(0);
+	const [playTime, setPlayTime] = useState<number>(0);
+	const viewedVideoIdsRef = React.useRef<Set<number>>(new Set());
+	const imageRefs = React.useRef<Map<number, HTMLImageElement>>(new Map());
+	const observerRef = React.useRef<IntersectionObserver | null>(null);
+	const logParamsRef = React.useRef({ traceId, requestId, planType, gzhAccountId, subChannel, uploadLogVideoView });
 
 	useEffect(() => {
 		if (defaultVideoLibraryType) {
@@ -176,6 +193,21 @@ export const VideoSelectContent: React.FC<VideoSelectContentProps> = ({
 			message.error('获取视频列表失败');
 		}).finally(() => {
 			setLoading(false);
+			const _requestId = new Date().getTime();
+			setRequestId(_requestId);
+			uploadLogVideoListQuery({
+				traceId: traceId,
+				requestId: _requestId,
+				planType: planType,
+				gzhAccountId: gzhAccountId,
+				subChannel: subChannel,
+				category: isNil(_category) ? category : _category,
+				title: searchTerm,
+				recentNotUsed: isNil(_recentNotUsed) ? recentNotUsed : _recentNotUsed as RecentNotUsedType,
+				sortType: isNil(_sortType) ? sortType : _sortType,
+				tags: isNil(_tags) ? tags : _tags,
+				videoLibraryType: currentVideoLibraryType,
+			});
 		});
 		if (res && res.code === 0) {
 			setVideoList([...selectedVideos, ...res.data.objs.filter(v => !selectedVideos.find(ov => ov.videoId === v.videoId))]);
@@ -234,10 +266,12 @@ export const VideoSelectContent: React.FC<VideoSelectContentProps> = ({
 
 	const playVideo = (video: VideoItem) => {
 		setPlayingVideo(video);
+		uploadLogVideoPlay({videoId: video.videoId, traceId: traceId, requestId: requestId, planType: planType, gzhAccountId: gzhAccountId, subChannel: subChannel});
 	};
 
 	const closeVideoPlayer = () => {
 		setPlayingVideo(null);
+		uploadLogVideoPlayEnd({videoId: playingVideo?.videoId || 0, playTime: playTime, traceId: traceId, requestId: requestId, planType: planType, gzhAccountId: gzhAccountId, subChannel: subChannel});
 	};
 
 	const handleVideoLibraryTypeChange = (v: VideoLibraryType) => {
@@ -281,6 +315,15 @@ export const VideoSelectContent: React.FC<VideoSelectContentProps> = ({
 			if (res && res.code === 0) {
 				setVideoList(prev => prev.map(v => v.videoId === videoId ? {...v, collect: isCollected ? CollectedStatusEnum.未收藏 : CollectedStatusEnum.已收藏} : v));
 				message.success(isCollected ? '取消收藏' : '收藏成功');
+				uploadLogVideoCollect({
+					videoId: videoId,
+					traceId: traceId,
+					requestId: requestId,
+					collect: isCollected ? CollectedStatusEnum.未收藏 : CollectedStatusEnum.已收藏,
+					planType: planType,
+					gzhAccountId: gzhAccountId,
+					subChannel: subChannel,
+				});
 			} else {
 				message.error('收藏失败');
 			}
@@ -291,6 +334,58 @@ export const VideoSelectContent: React.FC<VideoSelectContentProps> = ({
 		window.location.href = `/publishContent/wegzh/${videoLibraryType}/${videoId}`;
 	}
 
+	const handleTimeUpdate = (e: React.ChangeEvent<HTMLVideoElement>) => {
+		setPlayTime(e.target.currentTime);
+	}
+
+	// 更新日志参数 ref
+	useEffect(() => {
+		logParamsRef.current = { traceId, requestId, planType, gzhAccountId, subChannel, uploadLogVideoView };
+	}, [traceId, requestId, planType, gzhAccountId, subChannel, uploadLogVideoView]);
+
+	// 初始化 Intersection Observer
+	useEffect(() => {
+		if (!observerRef.current) {
+			observerRef.current = new IntersectionObserver(
+				(entries) => {
+					entries.forEach((entry) => {
+						if (entry.isIntersecting && entry.intersectionRatio >= 1.0) {
+							// 图片完全进入视口
+							const videoId = parseInt(entry.target.getAttribute('data-video-id') || '0');
+							if (videoId && !viewedVideoIdsRef.current.has(videoId)) {
+								viewedVideoIdsRef.current.add(videoId);
+								// 使用 ref 中的最新值
+								const params = logParamsRef.current;
+								const idx = parseInt(entry.target.getAttribute('data-idx') || '0');
+								params.uploadLogVideoView({
+									videoId: videoId,
+									idx: idx,
+									traceId: params.traceId,
+									requestId: params.requestId,
+									planType: params.planType,
+									gzhAccountId: params.gzhAccountId,
+									subChannel: params.subChannel,
+								});
+							}
+						}
+					});
+				},
+				{
+					threshold: 1.0, // 完全可见时才触发
+					rootMargin: '0px',
+				}
+			);
+		}
+
+		return () => {
+			// 组件卸载时断开 observer
+			if (observerRef.current) {
+				observerRef.current.disconnect();
+				observerRef.current = null;
+			}
+		};
+	}, []);
+
 	return (
 		<>
 			<div className="flex flex-wrap gap-2 mb-2">
@@ -372,7 +467,7 @@ export const VideoSelectContent: React.FC<VideoSelectContentProps> = ({
 						</div>
 					)
 				}
-				{videoList.map((video) => {
+				{videoList.map((video, index) => {
 					const isSelected = selectedVideoIds.has(video.videoId);
 					const isDisabled = !isSelected && selectedVideoIds.size >= MAX_SELECTION;
 					const isInvalid = video.status === VideoStatusEnum.已下架;
@@ -398,7 +493,30 @@ export const VideoSelectContent: React.FC<VideoSelectContentProps> = ({
 								style={{ paddingBottom: '79.8%' }}
 								onClick={(e) => { e.stopPropagation(); playVideo(video); }}
 							>
-								<img src={video.customCover || video.cover} alt={video.customTitle || video.title} referrerPolicy="no-referrer" className="absolute inset-0 w-full h-full object-cover" />
+								<img 
+									ref={(el) => {
+										if (el) {
+											imageRefs.current.set(video.videoId, el);
+											// 当图片元素添加到 DOM 时,立即开始观察
+											if (observerRef.current) {
+												observerRef.current.observe(el);
+											}
+										} else {
+											// 当图片元素从 DOM 移除时,取消观察
+											const img = imageRefs.current.get(video.videoId);
+											if (img && observerRef.current) {
+												observerRef.current.unobserve(img);
+											}
+											imageRefs.current.delete(video.videoId);
+										}
+									}}
+									data-video-id={video.videoId}
+									data-idx={index + 1}
+									src={video.customCover || video.cover} 
+									alt={video.customTitle || video.title} 
+									referrerPolicy="no-referrer" 
+									className="absolute inset-0 w-full h-full object-cover" 
+								/>
 								<div className="absolute inset-0 flex justify-center items-center cursor-pointer">
 									<CaretRightFilled className="!text-white text-4xl bg-black/20 rounded-full p-1 pl-2" />
 								</div>
@@ -474,7 +592,7 @@ export const VideoSelectContent: React.FC<VideoSelectContentProps> = ({
 				styles={{ body: { padding: 0, background: '#000' } }}
 			>
 				{playingVideo && (
-					<video controls autoPlay className="w-full h-auto max-h-[80vh] block" src={playingVideo.video}>
+					<video controls autoPlay className="w-full h-auto max-h-[80vh] block" src={playingVideo.video} onTimeUpdate={handleTimeUpdate}>
 						Your browser does not support the video tag.
 					</video>
 				)}
@@ -483,7 +601,7 @@ export const VideoSelectContent: React.FC<VideoSelectContentProps> = ({
 	);
 };
 
-const VideoSelectModal: React.FC<VideoSelectModalProps> = ({ visible, onClose, onOk, planType, initialSelectedIds = [], selectedVideos = [], defaultVideoLibraryType }) => {
+const VideoSelectModal: React.FC<VideoSelectModalProps> = ({ visible, onClose, onOk, planType, initialSelectedIds = [], selectedVideos = [], defaultVideoLibraryType, traceId, subChannel, gzhAccountId }) => {
 	const [currentSelectedVideos, setCurrentSelectedVideos] = useState<VideoItem[]>([]);
 
 	const handleOk = () => {
@@ -514,6 +632,9 @@ const VideoSelectModal: React.FC<VideoSelectModalProps> = ({ visible, onClose, o
 			>
 				<VideoSelectContent
 					planType={planType}
+					traceId={traceId}
+					gzhAccountId={gzhAccountId}
+					subChannel={subChannel}
 					initialSelectedIds={initialSelectedIds}
 					selectedVideos={selectedVideos}
 					defaultVideoLibraryType={defaultVideoLibraryType}

+ 15 - 1
src/views/publishContent/weGZH/components/publishPlanModal/index.tsx

@@ -9,6 +9,8 @@ import { GzhPlanDataType, GzhPlanType } from '../../hooks/useGzhPlanList';
 import { useAccountOptions } from '../../hooks/useAccountOptions';
 import { VideoLibraryType } from '@src/views/publishContent/weCom/components/videoSelectModal';
 import { isNil } from 'lodash';
+import useLogger from '@src/hooks/useLogger';
+import { VideoSearchPlanType } from '@src/views/publishContent/weCom/type';
 
 const { Option } = Select;
 const { Paragraph } = Typography;
@@ -36,7 +38,8 @@ const AddPunlishPlanModal: React.FC<AddPunlishPlanModalProps> = ({ visible, isSu
 	const [initialSelectedVideoId, setInitialSelectedVideoId] = useState<string | null>(null); // State for initial video selection
 	const [videoLibraryType, setVideoLibraryType] = useState<VideoLibraryType>(defaultLibType || VideoLibraryType.平台视频库);
 	const { accountOptions, getAccountList } = useAccountOptions();
-
+	const [traceId, setTraceId] = useState<number>(0);
+	const { uploadLogVideoCreatePublish } = useLogger();
 	// 处理code参数
 	useEffect(() => {
 		if (!isNil(defaultLibType)) {
@@ -79,6 +82,7 @@ const AddPunlishPlanModal: React.FC<AddPunlishPlanModalProps> = ({ visible, isSu
 		if (!visible) {
 			setInitialSelectedVideoId(null);
 		}
+		setTraceId(new Date().getTime());
 	}, [visible]);
 
 	const onTypeChange = (value: string) => {
@@ -101,6 +105,14 @@ const AddPunlishPlanModal: React.FC<AddPunlishPlanModalProps> = ({ visible, isSu
 				}
 				formData.videoList = selectedVideos;
 				onOk(formData);
+				uploadLogVideoCreatePublish({
+					traceId: traceId,
+					requestId: 0,
+					planType: form.getFieldValue('type') as VideoSearchPlanType,
+					gzhAccountId: form.getFieldValue('accountId') as number,
+					subChannel: form.getFieldValue('subChannel') as string,
+					videoList: selectedVideos,
+				});
 			})
 			.catch((info) => {
 				console.log('Validate Failed:', info);
@@ -330,6 +342,8 @@ const AddPunlishPlanModal: React.FC<AddPunlishPlanModalProps> = ({ visible, isSu
 			{/* Video Selection Drawer */}
 			<VideoSelectModal
 				planType={type}
+				traceId={traceId}
+				gzhAccountId={form.getFieldValue('accountId') as number}
 				visible={isVideoSelectVisible}
 				onClose={handleVideoSelectionCancel}
 				onOk={handleVideoSelectionOk}