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