Просмотр исходного кода

Merge branch 'feature_260509_replace_upload_new_datasource' of Web/contentCooper into master

liulidong 1 день назад
Родитель
Сommit
4332ec7403

+ 1 - 0
src/http/api.ts

@@ -35,6 +35,7 @@ export const deleteQwPlanApi = `${import.meta.env.VITE_API_URL}/contentPlatform/
 export const getVideoContentCategoryListApi = `${import.meta.env.VITE_API_URL}/contentPlatform/plan/videoContentCategoryList`
 export const getVideoContentCoverFrameListApi = `${import.meta.env.VITE_API_URL}/contentPlatform/plan/videoContentCoverFrameList`
 export const getVideoContentListApi = `${import.meta.env.VITE_API_URL}/contentPlatform/plan/videoContentList`
+export const getDemandVideoContentListApi = `${import.meta.env.VITE_API_URL}/contentPlatform/plan/demand/videoContentList` // 群体特征召回(测试,仅企微)
 export const getShareQrPic = `${import.meta.env.VITE_API_URL}/contentPlatform/plan/qw/getSharePic`
 export const getShareQrLink = `${import.meta.env.VITE_API_URL}/contentPlatform/plan/getShareUrlLink`
 

+ 131 - 6
src/views/publishContent/weCom/components/videoSelectModal/index.tsx

@@ -11,8 +11,12 @@ import {
 	Pagination,
 	message,
 	Modal,
+	Tag,
+	Tabs,
+	Popover,
+	Descriptions,
 } from 'antd';
-import { CheckCircleFilled, CaretRightFilled } from '@ant-design/icons';
+import { CheckCircleFilled, CaretRightFilled, HeartOutlined, HistoryOutlined, FireOutlined } from '@ant-design/icons';
 import { VideoListResponse } from '@src/views/publishContent/weGZH/components/types';
 import http from '@src/http';
 import { getVideoContentListApi } from '@src/http/api';
@@ -22,9 +26,84 @@ import useLogger from '@src/hooks/useLogger';
 import { debounce } from '@src/utils/helper';
 
 const { Text, Paragraph } = Typography;
+const IS_NON_PROD = import.meta.env.MODE !== 'production';
+console.log('[VideoSelectModal] MODE=', import.meta.env.MODE, 'IS_NON_PROD=', IS_NON_PROD);
 
 type LoadMode = 'replace' | 'append' | 'jump';
 
+const SOURCE_TABS: { key: string; label: string; disabled?: boolean }[] = [
+	{ key: '', label: '全部' },
+	{ key: 'prior', label: '粉丝喜欢' },
+	{ key: 'posterior', label: '已发优质相似' },
+	{ key: 'hot', label: '票圈热门' },
+];
+const SOURCE_LABEL: Record<string, string> = { prior: '粉丝喜欢', posterior: '已发优质相似', hot: '票圈热门' };
+const SOURCE_COLOR: Record<string, string> = { prior: 'green', posterior: 'blue', hot: 'orange' };
+const SOURCE_ICON: Record<string, React.ReactNode> = {
+	prior: <HeartOutlined />,
+	posterior: <HistoryOutlined />,
+	hot: <FireOutlined />,
+};
+
+const DEMAND_LABEL_STYLE = { width: 120, padding: '2px 8px', whiteSpace: 'nowrap' as const, backgroundColor: '#e6f4ff' };
+const DEMAND_CONTENT_STYLE = { padding: '2px 8px' };
+const MATCH_LABEL_STYLE = { width: 120, padding: '2px 8px', whiteSpace: 'nowrap' as const, backgroundColor: '#fff7e6' };
+const MATCH_CONTENT_STYLE = { padding: '2px 8px' };
+
+const renderDemandDetail = (video: WeVideoItem) => (
+	<div style={{ maxWidth: 620, maxHeight: '70vh', overflowY: 'auto' }}>
+		<Descriptions
+			size="small"
+			column={1}
+			bordered
+			title={<span style={{ color: '#1677ff' }}>需求侧</span>}
+			labelStyle={DEMAND_LABEL_STYLE}
+			contentStyle={DEMAND_CONTENT_STYLE}
+		>
+			<Descriptions.Item label="渠道类名称">{video.channelName || '-'}</Descriptions.Item>
+			<Descriptions.Item label="三级渠道">{video.channelLevel3 || '-'}</Descriptions.Item>
+			<Descriptions.Item label="人群分组">{video.crowdSegment || '-'}</Descriptions.Item>
+			<Descriptions.Item label="需求策略">{video.demandStrategy || '-'}</Descriptions.Item>
+			<Descriptions.Item label="需求类型">{video.demandType || '-'}</Descriptions.Item>
+			<Descriptions.Item label="需求过滤排序">{video.demandFilterSortStrategy || '-'}</Descriptions.Item>
+			<Descriptions.Item label="驱动维度时间">{video.driveDimensionTime || '-'}</Descriptions.Item>
+			<Descriptions.Item label="点位类型">{video.pointType || '-'}</Descriptions.Item>
+			<Descriptions.Item label="标准元素">{video.standardElement || '-'}</Descriptions.Item>
+			<Descriptions.Item label="维度">{video.dimension || '-'}</Descriptions.Item>
+			<Descriptions.Item label="分类">{video.categoryName || '-'}</Descriptions.Item>
+			<Descriptions.Item label="需求id">{video.demandId || '-'}</Descriptions.Item>
+			<Descriptions.Item label="需求内容id">{video.demandContentId || '-'}</Descriptions.Item>
+			<Descriptions.Item label="需求内容标题">{video.demandContentTitle || '-'}</Descriptions.Item>
+			<Descriptions.Item label="需求内容主题">{video.demandContentTopic || '-'}</Descriptions.Item>
+			<Descriptions.Item label="人群包">{video.crowdPackage || '-'}</Descriptions.Item>
+			<Descriptions.Item label="转化目标">{video.conversionTarget || '-'}</Descriptions.Item>
+			<Descriptions.Item label="合作伙伴">{video.partner || '-'}</Descriptions.Item>
+			<Descriptions.Item label="账号">{video.account || '-'}</Descriptions.Item>
+			<Descriptions.Item label="场景值">{video.sceneValue || '-'}</Descriptions.Item>
+			<Descriptions.Item label="实验id">{video.experimentId || '-'}</Descriptions.Item>
+			<Descriptions.Item label="需求侧视频数">{video.videoCount != null ? video.videoCount : '-'}</Descriptions.Item>
+			<Descriptions.Item label="需求侧访问UV">{video.visitUv != null ? video.visitUv : '-'}</Descriptions.Item>
+			<Descriptions.Item label="需求侧UV占比">{video.uvRatio != null ? video.uvRatio.toFixed(4) : '-'}</Descriptions.Item>
+			<Descriptions.Item label="需求侧总ROV">{video.totalRov != null ? video.totalRov.toFixed(4) : '-'}</Descriptions.Item>
+		</Descriptions>
+		<div style={{ height: 8 }} />
+		<Descriptions
+			size="small"
+			column={1}
+			bordered
+			title={<span style={{ color: '#fa8c16' }}>匹配视频</span>}
+			labelStyle={MATCH_LABEL_STYLE}
+			contentStyle={MATCH_CONTENT_STYLE}
+		>
+			<Descriptions.Item label="来源">{video.source ? SOURCE_LABEL[video.source] || video.source : '-'}</Descriptions.Item>
+			<Descriptions.Item label="score">{video.score != null ? video.score.toFixed(4) : '-'}</Descriptions.Item>
+			<Descriptions.Item label="相似度">{video.sim != null ? video.sim.toFixed(4) : '-'}</Descriptions.Item>
+			<Descriptions.Item label="rov">{video.rov != null ? video.rov.toFixed(4) : '-'}</Descriptions.Item>
+			<Descriptions.Item label="视频id">{video.videoId != null ? video.videoId : '-'}</Descriptions.Item>
+		</Descriptions>
+	</div>
+);
+
 interface VideoSelectModalProps {
 	planType: WeComPlanType;
 	visible: boolean;
@@ -57,6 +136,7 @@ const VideoSelectModal: React.FC<VideoSelectModalProps> = ({ visible, onClose, o
 	const [playingVideo, setPlayingVideo] = useState<WeVideoItem | null>(null);
 	const [viewingPage, setViewingPage] = useState(1);
 	const [pageAnchors, setPageAnchors] = useState<Map<number, number>>(new Map()); // videoId -> page
+	const [source, setSource] = useState<string>(''); // 测试入口:数据来源筛选(prior/posterior/hot/空)
 	const observerRef = useRef<IntersectionObserver | null>(null);
 	const pageObserverRef = useRef<IntersectionObserver | null>(null);
 	const passedPagesRef = useRef<Set<number>>(new Set());
@@ -67,6 +147,7 @@ const VideoSelectModal: React.FC<VideoSelectModalProps> = ({ visible, onClose, o
 	const loadingRef = useRef(false);
 	const currentPageRef = useRef(1);
 	const getVideoListRef = useRef<(pageNum: number, mode: LoadMode) => Promise<void>>();
+	const reqIdRef = useRef(0);
 	const MAX_SELECTION = 3;
 	const { uploadLogVideoPlay, uploadLogVideoPlayEnd, uploadLogVideoCollect, uploadLogVideoListQuery } = useLogger();
 	const debouncedUploadLogVideoListQuery = useMemo(() => debounce(uploadLogVideoListQuery, 500), [uploadLogVideoListQuery]);
@@ -86,10 +167,12 @@ const VideoSelectModal: React.FC<VideoSelectModalProps> = ({ visible, onClose, o
 		}
 		setCurrentPage(pageNum);
 
+		const myReqId = reqIdRef.current;
+		const type = planType === WeComPlanType.社群 ? VideoSearchPlanType.企微社群 : VideoSearchPlanType.企微自动回复;
 		// 上报视频列表查询日志(使用防抖)
 		debouncedUploadLogVideoListQuery({
 			traceId: Date.now(),
-			planType: planType === WeComPlanType.社群 ? VideoSearchPlanType.企微社群 : VideoSearchPlanType.企微自动回复,
+			planType: type,
 			subChannel: 'weCom',
 			category: category ?? '',
 			title: searchTerm,
@@ -100,9 +183,10 @@ const VideoSelectModal: React.FC<VideoSelectModalProps> = ({ visible, onClose, o
 			category,
 			title: searchTerm,
 			sort,
-			type: planType === WeComPlanType.社群 ? VideoSearchPlanType.企微社群 : VideoSearchPlanType.企微自动回复,
+			type,
 			pageNum,
 			pageSize: PAGE_SIZE,
+			...(source ? { source } : {}),
 		};
 
 		const res = await http.post<VideoListResponse>(getVideoContentListApi, requestParams).catch(() => {
@@ -115,6 +199,7 @@ const VideoSelectModal: React.FC<VideoSelectModalProps> = ({ visible, onClose, o
 				setLoading(false);
 			}
 		});
+		if (myReqId !== reqIdRef.current) return;
 		if (res && res.code === 0) {
 			const mapped = (res.data.objs || []).map(video => ({ ...video, scene: 0 as 0 | 1 }));
 			if (mode === 'append') {
@@ -207,6 +292,7 @@ const VideoSelectModal: React.FC<VideoSelectModalProps> = ({ visible, onClose, o
 	}, []);
 
 	const jumpToPage = (page: number) => {
+		reqIdRef.current++;
 		setHasMore(true);
 		setVideoList([]);
 		setPageAnchors(new Map());
@@ -217,13 +303,28 @@ const VideoSelectModal: React.FC<VideoSelectModalProps> = ({ visible, onClose, o
 	};
 
 	const handleSearch = () => {
+		reqIdRef.current++;
 		setHasMore(true);
+		setVideoList([]);
+		setTotal(0);
 		setViewingPage(1);
 		setPageAnchors(new Map());
 		passedPagesRef.current.clear();
 		getVideoList(1, 'replace');
 	};
 
+	const handleChangeSource = (value: string) => {
+		reqIdRef.current++;
+		setSource(value);
+		setHasMore(true);
+		setVideoList([]);
+		setViewingPage(1);
+		setPageAnchors(new Map());
+		passedPagesRef.current.clear();
+		drawerBodyRef.current?.scrollTo({ top: 0 });
+		setTimeout(() => getVideoListRef.current?.(1, 'replace'), 0);
+	};
+
 	const handleSelectVideo = (videoId: number) => {
 		setSelectedVideoIds(prev => {
 			const newSet = new Set(prev);
@@ -314,7 +415,7 @@ const VideoSelectModal: React.FC<VideoSelectModalProps> = ({ visible, onClose, o
 	return (
 		<>
 			<Drawer
-				title="内容选取"
+				title="选择视频"
 				open={visible}
 				onClose={onClose}
 				width={900}
@@ -344,6 +445,12 @@ const VideoSelectModal: React.FC<VideoSelectModalProps> = ({ visible, onClose, o
 					</div>
 				}
 			>
+				<Tabs
+					activeKey={source}
+					onChange={handleChangeSource}
+					items={SOURCE_TABS.map(t => ({ key: t.key, label: t.label }))}
+					className="!mb-3"
+				/>
 				<div className="flex flex-wrap gap-2 mb-6">
 					<div className="flex items-center gap-2">
 						<span className="text-gray-600">品类:</span>
@@ -376,7 +483,7 @@ const VideoSelectModal: React.FC<VideoSelectModalProps> = ({ visible, onClose, o
 						const isSelected = selectedVideoIds.has(video.videoId);
 						const isDisabled = !isSelected && selectedVideoIds.size >= MAX_SELECTION;
 						const startsPage = pageAnchors.get(video.videoId);
-						return (
+						const cardNode = (
 							<Card
 								key={video.videoId}
 								{...(startsPage ? { 'data-page-anchor': startsPage } : {})}
@@ -399,7 +506,11 @@ const VideoSelectModal: React.FC<VideoSelectModalProps> = ({ visible, onClose, o
 									</div>
 								</div>
 								<div className="p-3 flex justify-between items-center">
-									<Text type="secondary" className="text-sm">推荐指数: <span className="text-gray-700 font-medium">{video.score?.toFixed(2) || '无'}</span></Text>
+									{video.source ? (
+										<Tag color={SOURCE_COLOR[video.source]} icon={SOURCE_ICON[video.source]} className="!mr-0">
+											{SOURCE_LABEL[video.source] || video.source}
+										</Tag>
+									) : <span />}
 									{isSelected ? (
 										<CheckCircleFilled className="text-green-500 text-xl" />
 									) : (
@@ -424,6 +535,20 @@ const VideoSelectModal: React.FC<VideoSelectModalProps> = ({ visible, onClose, o
 								)} */}
 							</Card>
 						);
+						if (IS_NON_PROD) {
+							return (
+								<Popover
+									key={video.videoId}
+									trigger="hover"
+									placement="right"
+									mouseEnterDelay={0.3}
+									content={renderDemandDetail(video)}
+								>
+									{cardNode}
+								</Popover>
+							);
+						}
+						return cardNode;
 					})}
 				</div>
 				<div ref={sentinelRef} className="text-center py-4 text-gray-400 text-xs">

+ 30 - 3
src/views/publishContent/weCom/type.ts

@@ -35,9 +35,36 @@ export interface WeVideoItem {
 	cover: string;
 	score: number,
 	scene?: 0 | 1;
-	industryFissionRate?: number;
-	channelFissionRate?: number;
-	recommendScore?: number;
+	sim?: number;
+	rov?: number;
+	channelName?: string;
+	channelLevel3?: string;
+	videoCount?: number;
+	visitUv?: number;
+	uvRatio?: number;
+	totalRov?: number;
+	// 数据来源标识(后端返回)
+	source?: 'prior' | 'posterior' | 'hot';
+	// 需求维度详情字段(仅 prior/posterior 来源会填充,hot 来源大多为空)
+	crowdSegment?: string;
+	demandId?: string;
+	crowdPackage?: string;
+	conversionTarget?: string;
+	partner?: string;
+	account?: string;
+	sceneValue?: string;
+	demandStrategy?: string;
+	driveDimensionTime?: string;
+	dimension?: string;
+	demandFilterSortStrategy?: string;
+	demandType?: string;
+	demandContentId?: string;
+	demandContentTitle?: string;
+	demandContentTopic?: string;
+	pointType?: string;
+	standardElement?: string;
+	categoryName?: string;
+	experimentId?: string;
 }
 
 export interface AddWeComPlanParam {

+ 13 - 3
src/views/publishContent/weGZH/components/publishPlanModal/index.tsx

@@ -1,4 +1,4 @@
-import React, { useEffect, useState } from 'react';
+import React, { useEffect, useMemo, useState } from 'react';
 import { Modal, Form, Select, Button, Card, Typography, message } from 'antd';
 import { CloseOutlined, PlusOutlined, EditOutlined, CaretRightFilled } from '@ant-design/icons';
 import VideoSelectModal from '../videoSelectModal';
@@ -27,12 +27,17 @@ const AddPunlishPlanModal: React.FC<AddPunlishPlanModalProps> = ({ visible, isSu
 	const [form] = Form.useForm();
 	const type = Form.useWatch('type', form);
 	const selectVideoType = Form.useWatch('selectVideoType', form);
+	const accountId = Form.useWatch('accountId', form);
 	const [selectedVideos, setSelectedVideos] = useState<VideoItem[]>([]);
 	const [isVideoSelectVisible, setIsVideoSelectVisible] = useState(false);
 	const [playingVideo, setPlayingVideo] = useState<VideoItem | null>(null); // State for video player modal
 	const [editingVideo, setEditingVideo] = useState<VideoItem | null>(null); // State for editing modal
 	const [initialSelectedVideoId, setInitialSelectedVideoId] = useState<string | null>(null); // State for initial video selection
 	const { accountOptions, getAccountList } = useAccountOptions();
+	const accountName = useMemo(
+		() => accountOptions.find(a => a.id === accountId)?.name,
+		[accountOptions, accountId]
+	);
 	const { uploadLogVideoCreatePublish } = useLogger();
 
 	const getVideoListType = (planType: GzhPlanType) => {
@@ -121,6 +126,10 @@ const AddPunlishPlanModal: React.FC<AddPunlishPlanModalProps> = ({ visible, isSu
 	};
 
 	const openVideoSelector = () => {
+		if (!accountId) {
+			message.warning('请先选择公众号,为您推荐适合该公众号的优质视频');
+			return;
+		}
 		setIsVideoSelectVisible(true);
 	};
 
@@ -319,11 +328,11 @@ const AddPunlishPlanModal: React.FC<AddPunlishPlanModalProps> = ({ visible, isSu
 									{/* Add Video Button - Conditionally Rendered */}
 									{selectedVideos.length < 3 && (
 										<div
-											className="w-[240px] h-[316px] flex flex-col justify-center items-center  border border-dashed border-gray-300 rounded cursor-pointer dark:border-gray-600  hover:border-blue-500 hover:text-blue-500"
+											className={`w-[240px] h-[316px] flex flex-col justify-center items-center border border-dashed rounded ${accountId ? 'border-gray-300 cursor-pointer dark:border-gray-600 hover:border-blue-500 hover:text-blue-500' : 'border-gray-200 text-gray-300 cursor-not-allowed bg-gray-50'}`}
 											onClick={openVideoSelector} // Open the drawer on click
 										>
 											<PlusOutlined className="text-2xl mb-2" />
-											<Typography.Text>添加视频</Typography.Text>
+											<Typography.Text type={accountId ? undefined : 'secondary'}>添加视频</Typography.Text>
 										</div>
 									)}
 								</div>)
@@ -344,6 +353,7 @@ const AddPunlishPlanModal: React.FC<AddPunlishPlanModalProps> = ({ visible, isSu
 				onOk={handleVideoSelectionOk}
 				selectedVideos={selectedVideos}
 				initialSelectedIds={initialSelectedVideoId ? [Number(initialSelectedVideoId)] : selectedVideos.map(v => v.videoId)}
+				ghName={accountName}
 			/>
 
 			{/* Video Player Modal */}

+ 31 - 4
src/views/publishContent/weGZH/components/types.ts

@@ -9,10 +9,37 @@ export interface VideoItem {
 	pageUrl: string,
 	video: string,
 	videoId: number,
-	industryFissionRate: number,
-	channelFissionRate: number,
-	recommendScore: number,
-} 
+	sim?: number,
+	rov?: number,
+	channelName?: string,
+	channelLevel3?: string,
+	videoCount?: number,
+	visitUv?: number,
+	uvRatio?: number,
+	totalRov?: number,
+	// 数据来源标识(后端返回)
+	source?: 'prior' | 'posterior' | 'hot';
+	// 需求维度详情字段(仅 prior/posterior 来源会填充,hot 来源大多为空)
+	crowdSegment?: string;
+	demandId?: string;
+	crowdPackage?: string;
+	conversionTarget?: string;
+	partner?: string;
+	account?: string;
+	sceneValue?: string;
+	demandStrategy?: string;
+	driveDimensionTime?: string;
+	dimension?: string;
+	demandFilterSortStrategy?: string;
+	demandType?: string;
+	demandContentId?: string;
+	demandContentTitle?: string;
+	demandContentTopic?: string;
+	pointType?: string;
+	standardElement?: string;
+	categoryName?: string;
+	experimentId?: string;
+}
 
 export interface VideoListResponse {
 	objs: VideoItem[],

+ 130 - 4
src/views/publishContent/weGZH/components/videoSelectModal/index.tsx

@@ -11,8 +11,12 @@ import {
 	Pagination,
 	message,
 	Modal,
+	Tag,
+	Tabs,
+	Popover,
+	Descriptions,
 } from 'antd';
-import { CheckCircleFilled, CaretRightFilled } from '@ant-design/icons';
+import { CheckCircleFilled, CaretRightFilled, HeartOutlined, HistoryOutlined, FireOutlined } from '@ant-design/icons';
 import { VideoItem, VideoListResponse } from '../types';
 import http from '@src/http';
 import { getVideoContentListApi } from '@src/http/api';
@@ -24,9 +28,83 @@ import useLogger from '@src/hooks/useLogger';
 import { debounce } from '@src/utils/helper';
 
 const { Paragraph, Text } = Typography;
+const IS_NON_PROD = import.meta.env.MODE !== 'production';
 
 type LoadMode = 'replace' | 'append' | 'jump';
 
+const SOURCE_TABS: { key: string; label: string; disabled?: boolean }[] = [
+	{ key: '', label: '全部' },
+	{ key: 'prior', label: '粉丝喜欢' },
+	{ key: 'posterior', label: '已发优质相似' },
+	{ key: 'hot', label: '票圈热门' },
+];
+const SOURCE_LABEL: Record<string, string> = { prior: '粉丝喜欢', posterior: '已发优质相似', hot: '票圈热门' };
+const SOURCE_COLOR: Record<string, string> = { prior: 'green', posterior: 'blue', hot: 'orange' };
+const SOURCE_ICON: Record<string, React.ReactNode> = {
+	prior: <HeartOutlined />,
+	posterior: <HistoryOutlined />,
+	hot: <FireOutlined />,
+};
+
+const DEMAND_LABEL_STYLE = { width: 120, padding: '2px 8px', whiteSpace: 'nowrap' as const, backgroundColor: '#e6f4ff' };
+const DEMAND_CONTENT_STYLE = { padding: '2px 8px' };
+const MATCH_LABEL_STYLE = { width: 120, padding: '2px 8px', whiteSpace: 'nowrap' as const, backgroundColor: '#fff7e6' };
+const MATCH_CONTENT_STYLE = { padding: '2px 8px' };
+
+const renderDemandDetail = (video: VideoItem) => (
+	<div style={{ maxWidth: 620, maxHeight: '70vh', overflowY: 'auto' }}>
+		<Descriptions
+			size="small"
+			column={1}
+			bordered
+			title={<span style={{ color: '#1677ff' }}>需求侧</span>}
+			labelStyle={DEMAND_LABEL_STYLE}
+			contentStyle={DEMAND_CONTENT_STYLE}
+		>
+			<Descriptions.Item label="渠道类名称">{video.channelName || '-'}</Descriptions.Item>
+			<Descriptions.Item label="三级渠道">{video.channelLevel3 || '-'}</Descriptions.Item>
+			<Descriptions.Item label="人群分组">{video.crowdSegment || '-'}</Descriptions.Item>
+			<Descriptions.Item label="需求策略">{video.demandStrategy || '-'}</Descriptions.Item>
+			<Descriptions.Item label="需求类型">{video.demandType || '-'}</Descriptions.Item>
+			<Descriptions.Item label="需求过滤排序">{video.demandFilterSortStrategy || '-'}</Descriptions.Item>
+			<Descriptions.Item label="驱动维度时间">{video.driveDimensionTime || '-'}</Descriptions.Item>
+			<Descriptions.Item label="点位类型">{video.pointType || '-'}</Descriptions.Item>
+			<Descriptions.Item label="标准元素">{video.standardElement || '-'}</Descriptions.Item>
+			<Descriptions.Item label="维度">{video.dimension || '-'}</Descriptions.Item>
+			<Descriptions.Item label="分类">{video.categoryName || '-'}</Descriptions.Item>
+			<Descriptions.Item label="需求id">{video.demandId || '-'}</Descriptions.Item>
+			<Descriptions.Item label="需求内容id">{video.demandContentId || '-'}</Descriptions.Item>
+			<Descriptions.Item label="需求内容标题">{video.demandContentTitle || '-'}</Descriptions.Item>
+			<Descriptions.Item label="需求内容主题">{video.demandContentTopic || '-'}</Descriptions.Item>
+			<Descriptions.Item label="人群包">{video.crowdPackage || '-'}</Descriptions.Item>
+			<Descriptions.Item label="转化目标">{video.conversionTarget || '-'}</Descriptions.Item>
+			<Descriptions.Item label="合作伙伴">{video.partner || '-'}</Descriptions.Item>
+			<Descriptions.Item label="账号">{video.account || '-'}</Descriptions.Item>
+			<Descriptions.Item label="场景值">{video.sceneValue || '-'}</Descriptions.Item>
+			<Descriptions.Item label="实验id">{video.experimentId || '-'}</Descriptions.Item>
+			<Descriptions.Item label="需求侧视频数">{video.videoCount != null ? video.videoCount : '-'}</Descriptions.Item>
+			<Descriptions.Item label="需求侧访问UV">{video.visitUv != null ? video.visitUv : '-'}</Descriptions.Item>
+			<Descriptions.Item label="需求侧UV占比">{video.uvRatio != null ? video.uvRatio.toFixed(4) : '-'}</Descriptions.Item>
+			<Descriptions.Item label="需求侧总ROV">{video.totalRov != null ? video.totalRov.toFixed(4) : '-'}</Descriptions.Item>
+		</Descriptions>
+		<div style={{ height: 8 }} />
+		<Descriptions
+			size="small"
+			column={1}
+			bordered
+			title={<span style={{ color: '#fa8c16' }}>匹配视频</span>}
+			labelStyle={MATCH_LABEL_STYLE}
+			contentStyle={MATCH_CONTENT_STYLE}
+		>
+			<Descriptions.Item label="来源">{video.source ? SOURCE_LABEL[video.source] || video.source : '-'}</Descriptions.Item>
+			<Descriptions.Item label="score">{video.score != null ? video.score.toFixed(4) : '-'}</Descriptions.Item>
+			<Descriptions.Item label="相似度">{video.sim != null ? video.sim.toFixed(4) : '-'}</Descriptions.Item>
+			<Descriptions.Item label="rov">{video.rov != null ? video.rov.toFixed(4) : '-'}</Descriptions.Item>
+			<Descriptions.Item label="视频id">{video.videoId != null ? video.videoId : '-'}</Descriptions.Item>
+		</Descriptions>
+	</div>
+);
+
 interface VideoSelectModalProps {
 	planType: GzhPlanType;
 	visible: boolean;
@@ -34,9 +112,10 @@ interface VideoSelectModalProps {
 	onOk: (selectedVideos: VideoItem[]) => void;
 	initialSelectedIds?: number[];
 	selectedVideos?: VideoItem[];
+	ghName?: string;
 }
 
-const VideoSelectModal: React.FC<VideoSelectModalProps> = ({ planType, visible, onClose, onOk, initialSelectedIds = [], selectedVideos = [] }) => {
+const VideoSelectModal: React.FC<VideoSelectModalProps> = ({ planType, visible, onClose, onOk, initialSelectedIds = [], selectedVideos = [], ghName }) => {
 	const { videoCategoryOptions } = useVideoCategoryOptions();
 	const { uploadLogVideoListQuery, uploadLogVideoPlay, uploadLogVideoPlayEnd, uploadLogVideoCollect } = useLogger();
 	const debouncedUploadLogVideoListQuery = useMemo(() => debounce(uploadLogVideoListQuery, 500), [uploadLogVideoListQuery]);
@@ -55,6 +134,7 @@ const VideoSelectModal: React.FC<VideoSelectModalProps> = ({ planType, visible,
 	const [playingVideo, setPlayingVideo] = useState<VideoItem | null>(null);
 	const [viewingPage, setViewingPage] = useState(1);
 	const [pageAnchors, setPageAnchors] = useState<Map<number, number>>(new Map()); // videoId -> page
+	const [source, setSource] = useState<string>(''); // 测试入口:数据来源筛选(prior/posterior/hot/空)
 	const observerRef = useRef<IntersectionObserver | null>(null);
 	const pageObserverRef = useRef<IntersectionObserver | null>(null);
 	const passedPagesRef = useRef<Set<number>>(new Set());
@@ -65,6 +145,7 @@ const VideoSelectModal: React.FC<VideoSelectModalProps> = ({ planType, visible,
 	const loadingRef = useRef(false);
 	const currentPageRef = useRef(1);
 	const getVideoListRef = useRef<(pageNum: number, mode: LoadMode) => Promise<void>>();
+	const reqIdRef = useRef(0);
 	const MAX_SELECTION = 3;
 
 	useEffect(() => { hasMoreRef.current = hasMore; }, [hasMore]);
@@ -92,6 +173,7 @@ const VideoSelectModal: React.FC<VideoSelectModalProps> = ({ planType, visible,
 		}
 		setCurrentPage(pageNum);
 
+		const myReqId = reqIdRef.current;
 		// 上报视频列表查询日志(使用防抖)
 		debouncedUploadLogVideoListQuery({
 			traceId: Date.now(),
@@ -109,6 +191,8 @@ const VideoSelectModal: React.FC<VideoSelectModalProps> = ({ planType, visible,
 			type: getVideoListType(planType),
 			pageNum,
 			pageSize: PAGE_SIZE,
+			...(source ? { source } : {}),
+			...(ghName ? { ghName } : {}),
 		};
 
 		const res = await http.post<VideoListResponse>(getVideoContentListApi, requestParams).catch(() => {
@@ -121,6 +205,7 @@ const VideoSelectModal: React.FC<VideoSelectModalProps> = ({ planType, visible,
 				setLoading(false);
 			}
 		});
+		if (myReqId !== reqIdRef.current) return;
 		if (res && res.code === 0) {
 			const items = res.data.objs || [];
 			if (mode === 'append') {
@@ -149,6 +234,7 @@ const VideoSelectModal: React.FC<VideoSelectModalProps> = ({ planType, visible,
 
 	useEffect(() => {
 		if (visible) {
+			reqIdRef.current++;
 			setVideoList(selectedVideos);
 			setVideoListAll(selectedVideos);
 			setHasMore(true);
@@ -224,6 +310,7 @@ const VideoSelectModal: React.FC<VideoSelectModalProps> = ({ planType, visible,
 	}, []);
 
 	const jumpToPage = (page: number) => {
+		reqIdRef.current++;
 		setHasMore(true);
 		setVideoList([]);
 		setPageAnchors(new Map());
@@ -234,13 +321,28 @@ const VideoSelectModal: React.FC<VideoSelectModalProps> = ({ planType, visible,
 	};
 
 	const handleSearch = () => {
+		reqIdRef.current++;
 		setHasMore(true);
+		setVideoList([]);
+		setTotal(0);
 		setViewingPage(1);
 		setPageAnchors(new Map());
 		passedPagesRef.current.clear();
 		getVideoList(1, 'replace');
 	};
 
+	const handleChangeSource = (value: string) => {
+		reqIdRef.current++;
+		setSource(value);
+		setHasMore(true);
+		setVideoList([]);
+		setViewingPage(1);
+		setPageAnchors(new Map());
+		passedPagesRef.current.clear();
+		drawerBodyRef.current?.scrollTo({ top: 0 });
+		setTimeout(() => getVideoListRef.current?.(1, 'replace'), 0);
+	};
+
 	const handleSelectVideo = (videoId: number) => {
 		setSelectedVideoIds(prev => {
 			const newSet = new Set(prev);
@@ -336,6 +438,12 @@ const VideoSelectModal: React.FC<VideoSelectModalProps> = ({ planType, visible,
 					</div>
 				}
 			>
+				<Tabs
+					activeKey={source}
+					onChange={handleChangeSource}
+					items={SOURCE_TABS.map(t => ({ key: t.key, label: t.label }))}
+					className="!mb-3"
+				/>
 				<div className="flex flex-wrap gap-2 mb-6">
 					<div className="flex items-center gap-2">
 						<span className="text-gray-600">品类:</span>
@@ -368,7 +476,7 @@ const VideoSelectModal: React.FC<VideoSelectModalProps> = ({ planType, visible,
 						const isSelected = selectedVideoIds.has(video.videoId);
 						const isDisabled = !isSelected && selectedVideoIds.size >= MAX_SELECTION;
 						const startsPage = pageAnchors.get(video.videoId);
-						return (
+						const cardNode = (
 							<Card
 								key={video.videoId}
 								{...(startsPage ? { 'data-page-anchor': startsPage } : {})}
@@ -391,7 +499,11 @@ const VideoSelectModal: React.FC<VideoSelectModalProps> = ({ planType, visible,
 									</div>
 								</div>
 								<div className="p-3 flex justify-between items-center">
-									<Text type="secondary" className="text-sm">推荐指数: <span className="text-gray-700 font-medium">{video.score?.toFixed(2) || '无'}</span></Text>
+									{video.source ? (
+										<Tag color={SOURCE_COLOR[video.source]} icon={SOURCE_ICON[video.source]} className="!mr-0">
+											{SOURCE_LABEL[video.source] || video.source}
+										</Tag>
+									) : <span />}
 									{isSelected ? (
 										<CheckCircleFilled className="text-green-500 text-xl" />
 									) : (
@@ -400,6 +512,20 @@ const VideoSelectModal: React.FC<VideoSelectModalProps> = ({ planType, visible,
 								</div>
 							</Card>
 						);
+						if (IS_NON_PROD) {
+							return (
+								<Popover
+									key={video.videoId}
+									trigger="hover"
+									placement="right"
+									mouseEnterDelay={0.3}
+									content={renderDemandDetail(video)}
+								>
+									{cardNode}
+								</Popover>
+							);
+						}
+						return cardNode;
 					})}
 				</div>
 				<div ref={sentinelRef} className="text-center py-4 text-gray-400 text-xs">