Przeglądaj źródła

选视频弹窗支持来源筛选+悬浮详情,移除群体召回开关

- 工具栏新增 source 选择器(prior/posterior/hot/全部),仅 test 环境显示
- 卡片左上角展示来源标签(Tag),prod 与 test 都显示
- 鼠标 hover 卡片弹出 Popover 展示完整 demand 字段,仅 test 环境启用
- weCom 移除"群体召回(测试)"开关,改走统一的 source 入口
- VideoItem / WeVideoItem 新增 demand 相关可选字段
刘立冬 18 godzin temu
rodzic
commit
89d7e532a5

+ 80 - 14
src/views/publishContent/weCom/components/videoSelectModal/index.tsx

@@ -8,15 +8,17 @@ import {
 	Typography,
 	Typography,
 	Space,
 	Space,
 	Spin,
 	Spin,
-	Switch,
 	Pagination,
 	Pagination,
 	message,
 	message,
 	Modal,
 	Modal,
+	Tag,
+	Popover,
+	Descriptions,
 } from 'antd';
 } from 'antd';
 import { CheckCircleFilled, CaretRightFilled } from '@ant-design/icons';
 import { CheckCircleFilled, CaretRightFilled } from '@ant-design/icons';
 import { VideoListResponse } from '@src/views/publishContent/weGZH/components/types';
 import { VideoListResponse } from '@src/views/publishContent/weGZH/components/types';
 import http from '@src/http';
 import http from '@src/http';
-import { getVideoContentListApi, getDemandVideoContentListApi } from '@src/http/api';
+import { getVideoContentListApi } from '@src/http/api';
 import { useVideoCategoryOptions } from '@src/views/publishContent/weGZH/hooks/useVideoCategoryOptions';
 import { useVideoCategoryOptions } from '@src/views/publishContent/weGZH/hooks/useVideoCategoryOptions';
 import { WeComPlanType, WeVideoItem, VideoSearchPlanType } from '@src/views/publishContent/weCom/type'
 import { WeComPlanType, WeVideoItem, VideoSearchPlanType } from '@src/views/publishContent/weCom/type'
 
 
@@ -26,6 +28,46 @@ console.log('[VideoSelectModal] MODE=', import.meta.env.MODE, 'IS_NON_PROD=', IS
 
 
 type LoadMode = 'replace' | 'append' | 'jump';
 type LoadMode = 'replace' | 'append' | 'jump';
 
 
+const SOURCE_OPTIONS = [
+	{ label: '全部', value: '' },
+	{ label: '先验', value: 'prior' },
+	{ label: '后验', value: 'posterior' },
+	{ label: '全局热门', value: 'hot' },
+];
+const SOURCE_LABEL: Record<string, string> = { prior: '先验', posterior: '后验', hot: '热门' };
+const SOURCE_COLOR: Record<string, string> = { prior: 'blue', posterior: 'green', hot: 'orange' };
+
+const renderDemandDetail = (video: WeVideoItem) => (
+	<div style={{ maxWidth: 460 }}>
+		<Descriptions size="small" column={1} bordered labelStyle={{ width: 110 }}>
+			<Descriptions.Item label="来源">{video.source ? SOURCE_LABEL[video.source] || video.source : '-'}</Descriptions.Item>
+			<Descriptions.Item label="人群分组">{video.crowdSegment || '-'}</Descriptions.Item>
+			<Descriptions.Item label="需求策略">{video.demandStrategy || '-'}</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="需求过滤排序">{video.demandFilterSortStrategy || '-'}</Descriptions.Item>
+			<Descriptions.Item label="需求类型">{video.demandType || '-'}</Descriptions.Item>
+			<Descriptions.Item label="驱动维度时间">{video.driveDimensionTime || '-'}</Descriptions.Item>
+			<Descriptions.Item label="实验id">{video.experimentId || '-'}</Descriptions.Item>
+			<Descriptions.Item label="score">{video.score != null ? video.score.toFixed(4) : '-'}</Descriptions.Item>
+			<Descriptions.Item label="行业裂变率">{video.industryFissionRate != null ? video.industryFissionRate.toFixed(4) : '-'}</Descriptions.Item>
+			<Descriptions.Item label="本渠道裂变率">{video.channelFissionRate != null ? video.channelFissionRate.toFixed(4) : '-'}</Descriptions.Item>
+			<Descriptions.Item label="推荐指数">{video.recommendScore != null ? video.recommendScore.toFixed(4) : '-'}</Descriptions.Item>
+		</Descriptions>
+	</div>
+);
+
 interface VideoSelectModalProps {
 interface VideoSelectModalProps {
 	planType: WeComPlanType;
 	planType: WeComPlanType;
 	visible: boolean;
 	visible: boolean;
@@ -58,7 +100,7 @@ const VideoSelectModal: React.FC<VideoSelectModalProps> = ({ visible, onClose, o
 	const [playingVideo, setPlayingVideo] = useState<WeVideoItem | null>(null);
 	const [playingVideo, setPlayingVideo] = useState<WeVideoItem | null>(null);
 	const [viewingPage, setViewingPage] = useState(1);
 	const [viewingPage, setViewingPage] = useState(1);
 	const [pageAnchors, setPageAnchors] = useState<Map<number, number>>(new Map()); // videoId -> page
 	const [pageAnchors, setPageAnchors] = useState<Map<number, number>>(new Map()); // videoId -> page
-	const [useDemandRecall, setUseDemandRecall] = useState(false); // 测试入口:群体特征召回
+	const [source, setSource] = useState<string>(''); // 测试入口:数据来源筛选(prior/posterior/hot/空)
 	const observerRef = useRef<IntersectionObserver | null>(null);
 	const observerRef = useRef<IntersectionObserver | null>(null);
 	const pageObserverRef = useRef<IntersectionObserver | null>(null);
 	const pageObserverRef = useRef<IntersectionObserver | null>(null);
 	const passedPagesRef = useRef<Set<number>>(new Set());
 	const passedPagesRef = useRef<Set<number>>(new Set());
@@ -87,12 +129,9 @@ const VideoSelectModal: React.FC<VideoSelectModalProps> = ({ visible, onClose, o
 		setCurrentPage(pageNum);
 		setCurrentPage(pageNum);
 
 
 		const type = planType === WeComPlanType.社群 ? VideoSearchPlanType.企微社群 : VideoSearchPlanType.企微自动回复;
 		const type = planType === WeComPlanType.社群 ? VideoSearchPlanType.企微社群 : VideoSearchPlanType.企微自动回复;
-		const apiUrl = useDemandRecall ? getDemandVideoContentListApi : getVideoContentListApi;
-		const requestParams = useDemandRecall
-			? { title: searchTerm, sort, type, pageNum, pageSize: PAGE_SIZE }
-			: { category, title: searchTerm, sort, type, pageNum, pageSize: PAGE_SIZE };
+		const requestParams = { category, title: searchTerm, sort, type, pageNum, pageSize: PAGE_SIZE, ...(source ? { source } : {}) };
 
 
-		const res = await http.post<VideoListResponse>(apiUrl, requestParams).catch(() => {
+		const res = await http.post<VideoListResponse>(getVideoContentListApi, requestParams).catch(() => {
 			message.error('获取视频列表失败');
 			message.error('获取视频列表失败');
 		}).finally(() => {
 		}).finally(() => {
 			if (isAppend) {
 			if (isAppend) {
@@ -211,15 +250,14 @@ const VideoSelectModal: React.FC<VideoSelectModalProps> = ({ visible, onClose, o
 		getVideoList(1, 'replace');
 		getVideoList(1, 'replace');
 	};
 	};
 
 
-	const handleToggleDemandRecall = (checked: boolean) => {
-		setUseDemandRecall(checked);
+	const handleChangeSource = (value: string) => {
+		setSource(value);
 		setHasMore(true);
 		setHasMore(true);
 		setVideoList([]);
 		setVideoList([]);
 		setViewingPage(1);
 		setViewingPage(1);
 		setPageAnchors(new Map());
 		setPageAnchors(new Map());
 		passedPagesRef.current.clear();
 		passedPagesRef.current.clear();
 		drawerBodyRef.current?.scrollTo({ top: 0 });
 		drawerBodyRef.current?.scrollTo({ top: 0 });
-		// 切换后重新请求(用最新 state 值)
 		setTimeout(() => getVideoListRef.current?.(1, 'replace'), 0);
 		setTimeout(() => getVideoListRef.current?.(1, 'replace'), 0);
 	};
 	};
 
 
@@ -336,8 +374,13 @@ const VideoSelectModal: React.FC<VideoSelectModalProps> = ({ visible, onClose, o
 					<Button type="primary" loading={loading} onClick={handleSearch}>搜索</Button>
 					<Button type="primary" loading={loading} onClick={handleSearch}>搜索</Button>
 					{IS_NON_PROD && (
 					{IS_NON_PROD && (
 						<div className="flex items-center gap-2 ml-auto">
 						<div className="flex items-center gap-2 ml-auto">
-							<span className="text-gray-600">群体召回(测试):</span>
-							<Switch checked={useDemandRecall} onChange={handleToggleDemandRecall} />
+							<span className="text-gray-600">来源(测试):</span>
+							<Select
+								value={source}
+								style={{ width: 110 }}
+								onChange={handleChangeSource}
+								options={SOURCE_OPTIONS}
+							/>
 						</div>
 						</div>
 					)}
 					)}
 				</div>
 				</div>
@@ -348,7 +391,7 @@ const VideoSelectModal: React.FC<VideoSelectModalProps> = ({ visible, onClose, o
 						const isSelected = selectedVideoIds.has(video.videoId);
 						const isSelected = selectedVideoIds.has(video.videoId);
 						const isDisabled = !isSelected && selectedVideoIds.size >= MAX_SELECTION;
 						const isDisabled = !isSelected && selectedVideoIds.size >= MAX_SELECTION;
 						const startsPage = pageAnchors.get(video.videoId);
 						const startsPage = pageAnchors.get(video.videoId);
-						return (
+						const cardNode = (
 							<Card
 							<Card
 								key={video.videoId}
 								key={video.videoId}
 								{...(startsPage ? { 'data-page-anchor': startsPage } : {})}
 								{...(startsPage ? { 'data-page-anchor': startsPage } : {})}
@@ -356,6 +399,15 @@ const VideoSelectModal: React.FC<VideoSelectModalProps> = ({ visible, onClose, o
 								styles={{ body: { padding: 0 } }}
 								styles={{ body: { padding: 0 } }}
 								onClick={() => !isDisabled && handleSelectVideo(video.videoId)}
 								onClick={() => !isDisabled && handleSelectVideo(video.videoId)}
 							>
 							>
+								{video.source && (
+									<Tag
+										color={SOURCE_COLOR[video.source]}
+										className="!absolute !mr-0 z-10"
+										style={{ top: 8, left: 8 }}
+									>
+										{SOURCE_LABEL[video.source] || video.source}
+									</Tag>
+								)}
 								<div className="p-3">
 								<div className="p-3">
 									<Text type="secondary" className="text-xs">票圈 | 3亿人喜欢的视频平台</Text>
 									<Text type="secondary" className="text-xs">票圈 | 3亿人喜欢的视频平台</Text>
 									<Paragraph className="mt-1 !mb-1" ellipsis={{ rows: 2, tooltip: true }} title={video.title}>{video.title}</Paragraph>
 									<Paragraph className="mt-1 !mb-1" ellipsis={{ rows: 2, tooltip: true }} title={video.title}>{video.title}</Paragraph>
@@ -396,6 +448,20 @@ const VideoSelectModal: React.FC<VideoSelectModalProps> = ({ visible, onClose, o
 								)} */}
 								)} */}
 							</Card>
 							</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>
 				<div ref={sentinelRef} className="text-center py-4 text-gray-400 text-xs">
 				<div ref={sentinelRef} className="text-center py-4 text-gray-400 text-xs">

+ 22 - 0
src/views/publishContent/weCom/type.ts

@@ -38,6 +38,28 @@ export interface WeVideoItem {
 	industryFissionRate?: number;
 	industryFissionRate?: number;
 	channelFissionRate?: number;
 	channelFissionRate?: number;
 	recommendScore?: number;
 	recommendScore?: 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 {
 export interface AddWeComPlanParam {

+ 23 - 1
src/views/publishContent/weGZH/components/types.ts

@@ -12,7 +12,29 @@ export interface VideoItem {
 	industryFissionRate: number,
 	industryFissionRate: number,
 	channelFissionRate: number,
 	channelFissionRate: number,
 	recommendScore: number,
 	recommendScore: 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 {
 export interface VideoListResponse {
 	objs: VideoItem[],
 	objs: VideoItem[],

+ 92 - 1
src/views/publishContent/weGZH/components/videoSelectModal/index.tsx

@@ -11,6 +11,9 @@ import {
 	Pagination,
 	Pagination,
 	message,
 	message,
 	Modal,
 	Modal,
+	Tag,
+	Popover,
+	Descriptions,
 } from 'antd';
 } from 'antd';
 import { CheckCircleFilled, CaretRightFilled } from '@ant-design/icons';
 import { CheckCircleFilled, CaretRightFilled } from '@ant-design/icons';
 import { VideoItem, VideoListResponse } from '../types';
 import { VideoItem, VideoListResponse } from '../types';
@@ -22,9 +25,50 @@ import { GzhPlanType } from '../../hooks/useGzhPlanList';
 import { VideoSearchPlanType } from '@src/views/publishContent/weCom/type';
 import { VideoSearchPlanType } from '@src/views/publishContent/weCom/type';
 
 
 const { Paragraph, Text } = Typography;
 const { Paragraph, Text } = Typography;
+const IS_NON_PROD = import.meta.env.MODE !== 'production';
 
 
 type LoadMode = 'replace' | 'append' | 'jump';
 type LoadMode = 'replace' | 'append' | 'jump';
 
 
+const SOURCE_OPTIONS = [
+	{ label: '全部', value: '' },
+	{ label: '先验', value: 'prior' },
+	{ label: '后验', value: 'posterior' },
+	{ label: '全局热门', value: 'hot' },
+];
+const SOURCE_LABEL: Record<string, string> = { prior: '先验', posterior: '后验', hot: '热门' };
+const SOURCE_COLOR: Record<string, string> = { prior: 'blue', posterior: 'green', hot: 'orange' };
+
+const renderDemandDetail = (video: VideoItem) => (
+	<div style={{ maxWidth: 460 }}>
+		<Descriptions size="small" column={1} bordered labelStyle={{ width: 110 }}>
+			<Descriptions.Item label="来源">{video.source ? SOURCE_LABEL[video.source] || video.source : '-'}</Descriptions.Item>
+			<Descriptions.Item label="人群分组">{video.crowdSegment || '-'}</Descriptions.Item>
+			<Descriptions.Item label="需求策略">{video.demandStrategy || '-'}</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="需求过滤排序">{video.demandFilterSortStrategy || '-'}</Descriptions.Item>
+			<Descriptions.Item label="需求类型">{video.demandType || '-'}</Descriptions.Item>
+			<Descriptions.Item label="驱动维度时间">{video.driveDimensionTime || '-'}</Descriptions.Item>
+			<Descriptions.Item label="实验id">{video.experimentId || '-'}</Descriptions.Item>
+			<Descriptions.Item label="score">{video.score != null ? video.score.toFixed(4) : '-'}</Descriptions.Item>
+			<Descriptions.Item label="行业裂变率">{video.industryFissionRate != null ? video.industryFissionRate.toFixed(4) : '-'}</Descriptions.Item>
+			<Descriptions.Item label="本渠道裂变率">{video.channelFissionRate != null ? video.channelFissionRate.toFixed(4) : '-'}</Descriptions.Item>
+			<Descriptions.Item label="推荐指数">{video.recommendScore != null ? video.recommendScore.toFixed(4) : '-'}</Descriptions.Item>
+		</Descriptions>
+	</div>
+);
+
 interface VideoSelectModalProps {
 interface VideoSelectModalProps {
 	planType: GzhPlanType;
 	planType: GzhPlanType;
 	visible: boolean;
 	visible: boolean;
@@ -51,6 +95,7 @@ const VideoSelectModal: React.FC<VideoSelectModalProps> = ({ planType, visible,
 	const [playingVideo, setPlayingVideo] = useState<VideoItem | null>(null);
 	const [playingVideo, setPlayingVideo] = useState<VideoItem | null>(null);
 	const [viewingPage, setViewingPage] = useState(1);
 	const [viewingPage, setViewingPage] = useState(1);
 	const [pageAnchors, setPageAnchors] = useState<Map<number, number>>(new Map()); // videoId -> page
 	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 observerRef = useRef<IntersectionObserver | null>(null);
 	const pageObserverRef = useRef<IntersectionObserver | null>(null);
 	const pageObserverRef = useRef<IntersectionObserver | null>(null);
 	const passedPagesRef = useRef<Set<number>>(new Set());
 	const passedPagesRef = useRef<Set<number>>(new Set());
@@ -95,6 +140,7 @@ const VideoSelectModal: React.FC<VideoSelectModalProps> = ({ planType, visible,
 			type: getVideoListType(planType),
 			type: getVideoListType(planType),
 			pageNum,
 			pageNum,
 			pageSize: PAGE_SIZE,
 			pageSize: PAGE_SIZE,
+			...(source ? { source } : {}),
 		};
 		};
 
 
 		const res = await http.post<VideoListResponse>(getVideoContentListApi, requestParams).catch(() => {
 		const res = await http.post<VideoListResponse>(getVideoContentListApi, requestParams).catch(() => {
@@ -227,6 +273,17 @@ const VideoSelectModal: React.FC<VideoSelectModalProps> = ({ planType, visible,
 		getVideoList(1, 'replace');
 		getVideoList(1, 'replace');
 	};
 	};
 
 
+	const handleChangeSource = (value: string) => {
+		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) => {
 	const handleSelectVideo = (videoId: number) => {
 		setSelectedVideoIds(prev => {
 		setSelectedVideoIds(prev => {
 			const newSet = new Set(prev);
 			const newSet = new Set(prev);
@@ -313,6 +370,17 @@ const VideoSelectModal: React.FC<VideoSelectModalProps> = ({ planType, visible,
 						/>
 						/>
 					</div>
 					</div>
 					<Button type="primary" loading={loading} onClick={handleSearch}>搜索</Button>
 					<Button type="primary" loading={loading} onClick={handleSearch}>搜索</Button>
+					{IS_NON_PROD && (
+						<div className="flex items-center gap-2 ml-auto">
+							<span className="text-gray-600">来源(测试):</span>
+							<Select
+								value={source}
+								style={{ width: 110 }}
+								onChange={handleChangeSource}
+								options={SOURCE_OPTIONS}
+							/>
+						</div>
+					)}
 				</div>
 				</div>
 
 
 				<Spin spinning={loading}>
 				<Spin spinning={loading}>
@@ -321,7 +389,7 @@ const VideoSelectModal: React.FC<VideoSelectModalProps> = ({ planType, visible,
 						const isSelected = selectedVideoIds.has(video.videoId);
 						const isSelected = selectedVideoIds.has(video.videoId);
 						const isDisabled = !isSelected && selectedVideoIds.size >= MAX_SELECTION;
 						const isDisabled = !isSelected && selectedVideoIds.size >= MAX_SELECTION;
 						const startsPage = pageAnchors.get(video.videoId);
 						const startsPage = pageAnchors.get(video.videoId);
-						return (
+						const cardNode = (
 							<Card
 							<Card
 								key={video.videoId}
 								key={video.videoId}
 								{...(startsPage ? { 'data-page-anchor': startsPage } : {})}
 								{...(startsPage ? { 'data-page-anchor': startsPage } : {})}
@@ -329,6 +397,15 @@ const VideoSelectModal: React.FC<VideoSelectModalProps> = ({ planType, visible,
 								styles={{ body: { padding: 0 } }}
 								styles={{ body: { padding: 0 } }}
 								onClick={() => !isDisabled && handleSelectVideo(video.videoId)}
 								onClick={() => !isDisabled && handleSelectVideo(video.videoId)}
 							>
 							>
+								{video.source && (
+									<Tag
+										color={SOURCE_COLOR[video.source]}
+										className="!absolute !mr-0 z-10"
+										style={{ top: 8, left: 8 }}
+									>
+										{SOURCE_LABEL[video.source] || video.source}
+									</Tag>
+								)}
 								<div className="p-2">
 								<div className="p-2">
 									<Text type="secondary" className="text-xs">票圈 | 3亿人喜欢的视频平台</Text>
 									<Text type="secondary" className="text-xs">票圈 | 3亿人喜欢的视频平台</Text>
 									<Paragraph className="mt-1 !mb-1" ellipsis={{ rows: 2, tooltip: true }} title={video.customTitle || video.title}>{video.customTitle || video.title}</Paragraph>
 									<Paragraph className="mt-1 !mb-1" ellipsis={{ rows: 2, tooltip: true }} title={video.customTitle || video.title}>{video.customTitle || video.title}</Paragraph>
@@ -353,6 +430,20 @@ const VideoSelectModal: React.FC<VideoSelectModalProps> = ({ planType, visible,
 								</div>
 								</div>
 							</Card>
 							</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>
 				<div ref={sentinelRef} className="text-center py-4 text-gray-400 text-xs">
 				<div ref={sentinelRef} className="text-center py-4 text-gray-400 text-xs">