index.tsx 14 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420
  1. import React, { useState, useEffect } from 'react';
  2. import {
  3. Drawer,
  4. Button,
  5. Select,
  6. Input,
  7. Card,
  8. Typography,
  9. Pagination,
  10. Space,
  11. message,
  12. Modal,
  13. Radio,
  14. RadioChangeEvent,
  15. Tooltip,
  16. Tag,
  17. } from 'antd';
  18. import { CheckCircleFilled, CaretRightFilled, QuestionCircleOutlined } from '@ant-design/icons';
  19. import { VideoItem, VideoListResponse } from '@src/views/publishContent/weGZH/components/types';
  20. import http from '@src/http';
  21. import { getVideoContentListApi, getUploadVideoContentListApi } from '@src/http/api';
  22. import { useVideoCategoryOptions } from '@src/views/publishContent/weGZH/hooks/useVideoCategoryOptions';
  23. import { WeComPlanType, WeVideoItem, VideoSearchPlanType } from '@src/views/publishContent/weCom/type'
  24. import { enumToOptions } from '@src/utils/helper';
  25. import { isNil } from 'lodash';
  26. import { GzhPlanType } from '@src/views/publishContent/weGZH/hooks/useGzhPlanList';
  27. export enum VideoLibraryType {
  28. 平台视频库 = 0,
  29. 我的上传 = 1,
  30. }
  31. const { Text, Paragraph } = Typography;
  32. interface VideoSelectModalProps {
  33. planType: WeComPlanType | GzhPlanType;
  34. visible: boolean;
  35. onClose: () => void;
  36. onOk: (selectedVideos: (VideoItem | WeVideoItem)[]) => void;
  37. initialSelectedIds?: number[];
  38. selectedVideos?: (VideoItem | WeVideoItem)[];
  39. defaultVideoLibraryType?: VideoLibraryType;
  40. }
  41. export enum VideoSortType {
  42. 平台推荐 = 0,
  43. 行业裂变率 = 1,
  44. 本渠道传播率 = 2,
  45. 推荐指数 = 3,
  46. }
  47. export enum RecentNotUsedType {
  48. 历史 = 0,
  49. 近30天 = 1,
  50. 近14天 = 2,
  51. 近7天 = 3,
  52. 近3天 = 4,
  53. }
  54. export enum SortTypeEnum {
  55. 推荐指数 = 0,
  56. 更新时间 = 1,
  57. }
  58. export const Tags = [
  59. '票圈受欢迎',
  60. '同类用户喜欢',
  61. '你的用户爱看',
  62. '猜TA想看',
  63. ];
  64. interface GetVideoListParams {
  65. pageNum?: number;
  66. _pageSize?: number;
  67. _sortType?: SortTypeEnum;
  68. _recentNotUsed?: RecentNotUsedType;
  69. _category?: string;
  70. _tags?: string[];
  71. _videoLibraryType?: VideoLibraryType;
  72. }
  73. const VideoSelectModal: React.FC<VideoSelectModalProps> = ({ visible, onClose, onOk, planType, initialSelectedIds = [], selectedVideos = [], defaultVideoLibraryType }) => {
  74. const { videoCategoryOptions } = useVideoCategoryOptions();
  75. const [category, setCategory] = useState<string>();
  76. // const [sort, setSort] = useState<VideoSortType>(VideoSortType.推荐指数);
  77. const [sortType, setSortType] = useState<SortTypeEnum>(SortTypeEnum.推荐指数);
  78. const [recentNotUsed, setRecentNotUsed] = useState<RecentNotUsedType>(RecentNotUsedType.历史);
  79. const [tags, setTags] = useState<string[]>([]);
  80. const [searchTerm, setSearchTerm] = useState<string>('');
  81. const [currentPage, setCurrentPage] = useState(1);
  82. const [pageSize, setPageSize] = useState(10);
  83. const [total, setTotal] = useState(0);
  84. const [loading, setLoading] = useState(false);
  85. const [videoList, setVideoList] = useState<(WeVideoItem | VideoItem)[]>([]);
  86. const [videoListAll, setVideoListAll] = useState<(WeVideoItem | VideoItem)[]>([]);
  87. const [selectedVideoIds, setSelectedVideoIds] = useState<Set<number>>(new Set(initialSelectedIds));
  88. const [playingVideo, setPlayingVideo] = useState<WeVideoItem | VideoItem | null>(null);
  89. const [videoLibraryType, setVideoLibraryType] = useState<VideoLibraryType>(defaultVideoLibraryType || VideoLibraryType.平台视频库);
  90. const MAX_SELECTION = 3;
  91. const getVideoListType = (planType: GzhPlanType | WeComPlanType) => {
  92. if (planType === GzhPlanType.自动回复) {
  93. return VideoSearchPlanType.自动回复;
  94. } else if (planType === GzhPlanType.公众号推送) {
  95. return VideoSearchPlanType.公众号推送;
  96. } else if (planType === GzhPlanType.服务号推送) {
  97. return VideoSearchPlanType.服务号推送;
  98. } else if (planType === WeComPlanType.社群) {
  99. return VideoSearchPlanType.企微社群;
  100. } else {
  101. return VideoSearchPlanType.企微自动回复;
  102. }
  103. }
  104. useEffect(() => {
  105. if (defaultVideoLibraryType) {
  106. setVideoLibraryType(defaultVideoLibraryType);
  107. }
  108. }, [defaultVideoLibraryType]);
  109. const getVideoList = async ({pageNum, _pageSize, _sortType, _recentNotUsed, _category, _tags, _videoLibraryType}: GetVideoListParams) => {
  110. setLoading(true);
  111. setCurrentPage(pageNum || currentPage);
  112. setPageSize(_pageSize || pageSize);
  113. // 根据视频库类型选择不同的API
  114. const currentVideoLibraryType = isNil(_videoLibraryType) ? videoLibraryType : _videoLibraryType;
  115. const apiUrl = currentVideoLibraryType === VideoLibraryType.平台视频库 ? getVideoContentListApi : getUploadVideoContentListApi;
  116. const requestParams = {
  117. category: isNil(_category) ? category : _category,
  118. title: searchTerm,
  119. recentNotUsed: isNil(_recentNotUsed) ? recentNotUsed : _recentNotUsed,
  120. sortType: isNil(_sortType) ? sortType : _sortType,
  121. tags: isNil(_tags) ? tags : _tags,
  122. type: getVideoListType(planType),
  123. pageNum: pageNum || currentPage,
  124. pageSize: _pageSize || pageSize,
  125. };
  126. const res = await http.post<VideoListResponse>(apiUrl, requestParams).catch(() => {
  127. message.error('获取视频列表失败');
  128. }).finally(() => {
  129. setLoading(false);
  130. });
  131. if (res && res.code === 0) {
  132. setVideoList([...selectedVideos, ...res.data.objs.filter(v => !selectedVideos.find(ov => ov.videoId === v.videoId))]);
  133. setVideoListAll(old => [...old, ...res.data.objs.filter(v => !old.find(ov => ov.videoId === v.videoId))]);
  134. setTotal(res.data.totalSize);
  135. }
  136. }
  137. useEffect(() => {
  138. if (visible) {
  139. console.log('selectedVideos', selectedVideos);
  140. // 初始化时设置 selectedVideos
  141. setVideoList(selectedVideos);
  142. setVideoListAll(selectedVideos);
  143. getVideoList({});
  144. }
  145. }, [visible]);
  146. useEffect(() => {
  147. if (visible) {
  148. setSelectedVideoIds(new Set(initialSelectedIds));
  149. }
  150. }, [visible, initialSelectedIds]);
  151. const handleSearch = () => {
  152. console.log('Searching for:', { category, searchTerm });
  153. const newPageNum = 1;
  154. setCurrentPage(newPageNum);
  155. getVideoList({pageNum: newPageNum});
  156. };
  157. const handleSelectVideo = (videoId: number) => {
  158. setSelectedVideoIds(prev => {
  159. const newSet = new Set(prev);
  160. if (newSet.has(videoId)) {
  161. newSet.delete(videoId);
  162. } else {
  163. if (newSet.size >= MAX_SELECTION) {
  164. message.warning(`最多只能选择 ${MAX_SELECTION} 条视频`);
  165. return prev;
  166. }
  167. newSet.add(videoId);
  168. }
  169. return newSet;
  170. });
  171. };
  172. const handleOk = () => {
  173. const selectedVideos = videoListAll.filter(video => selectedVideoIds.has(video.videoId));
  174. // 去重
  175. const uniqueSelectedVideos = selectedVideos.filter((video, index, self) =>
  176. index === self.findIndex((t) => t.videoId === video.videoId)
  177. );
  178. console.log('uniqueSelectedVideos', uniqueSelectedVideos);
  179. onOk(uniqueSelectedVideos);
  180. };
  181. const playVideo = (video: WeVideoItem | VideoItem) => {
  182. setPlayingVideo(video);
  183. };
  184. const closeVideoPlayer = () => {
  185. setPlayingVideo(null);
  186. };
  187. const handleVideoLibraryTypeChange = (v: VideoLibraryType) => {
  188. setVideoLibraryType(v);
  189. getVideoList({pageNum: 1, _videoLibraryType: v});
  190. }
  191. const handleSortTypeChange = (v: RadioChangeEvent) => {
  192. setSortType(v.target.value as SortTypeEnum);
  193. console.log('handleSortTypeChange', v.target.value);
  194. getVideoList({pageNum: 1, _sortType: v.target.value as SortTypeEnum});
  195. }
  196. const onCategoryChange = (v: string) => {
  197. setCategory(v);
  198. console.log('onCategoryChange', v);
  199. getVideoList({pageNum: 1, _category: v || '' as string});
  200. }
  201. const onRecentNotUsedChange = (v: RecentNotUsedType) => {
  202. setRecentNotUsed(v);
  203. getVideoList({pageNum: 1, _recentNotUsed: v});
  204. }
  205. const handleTagChange = (tag: string, checked: boolean) => {
  206. const newTags = checked ? [...tags, tag] : tags.filter(t => t !== tag);
  207. setTags(newTags);
  208. getVideoList({pageNum: 1, _tags: newTags});
  209. }
  210. return (
  211. <>
  212. <Drawer
  213. title="内容选取"
  214. open={visible}
  215. onClose={onClose}
  216. width={900}
  217. placement="right"
  218. loading={loading}
  219. destroyOnClose
  220. styles={{ footer: { textAlign: 'right', padding: '10px 24px' } }}
  221. footer={
  222. <div className="flex justify-between items-center">
  223. <Pagination
  224. current={currentPage}
  225. pageSize={pageSize}
  226. total={total}
  227. onChange={(page, size) => {
  228. setCurrentPage(page);
  229. setPageSize(size);
  230. getVideoList({pageNum: page, _pageSize: size});
  231. }}
  232. pageSizeOptions={['10', '20', '50']}
  233. size="small"
  234. showSizeChanger
  235. showTotal={(total) => `共 ${total} 条`}
  236. />
  237. <Space>
  238. <Text>已选 {selectedVideoIds.size} / {MAX_SELECTION} 条视频</Text>
  239. <Button onClick={onClose}>取消</Button>
  240. <Button type="primary" onClick={handleOk}>确定</Button>
  241. </Space>
  242. </div>
  243. }
  244. >
  245. <div className="flex flex-wrap gap-2 mb-2">
  246. <div className="flex items-center gap-2">
  247. <span className="text-gray-600">视频来源:</span>
  248. <Select
  249. style={{ width: 120 }}
  250. value={videoLibraryType}
  251. onChange={handleVideoLibraryTypeChange}
  252. options={enumToOptions(VideoLibraryType)}
  253. />
  254. </div>
  255. <div className="flex items-center gap-2">
  256. <span className="text-gray-600">品类:</span>
  257. <Select
  258. placeholder="选择品类"
  259. style={{ width: 120 }}
  260. value={category}
  261. allowClear
  262. onChange={onCategoryChange}
  263. options={videoCategoryOptions.map(option => ({ label: option, value: option }))}
  264. />
  265. </div>
  266. <div className="flex items-center gap-2">
  267. <span className="text-gray-600">近期未使用:</span>
  268. <Select
  269. style={{ width: 120 }}
  270. value={recentNotUsed}
  271. onChange={onRecentNotUsedChange}
  272. options={enumToOptions(RecentNotUsedType)}
  273. />
  274. </div>
  275. <div className="flex items-center gap-2">
  276. <span className="text-gray-600">视频标题:</span>
  277. <Input
  278. placeholder="搜索视频标题"
  279. style={{ width: 130 }}
  280. value={searchTerm}
  281. allowClear
  282. onChange={e => setSearchTerm(e.target.value)}
  283. onPressEnter={handleSearch}
  284. />
  285. </div>
  286. <Button type="primary" onClick={handleSearch}>搜索</Button>
  287. </div>
  288. <div className="flex">
  289. <div className="flex flex-wrap gap-2 mb-6 items-center">
  290. <span className="text-gray-600 ">排序选项:</span>
  291. <Radio.Group
  292. onChange={handleSortTypeChange}
  293. value={sortType}
  294. buttonStyle="solid"
  295. >
  296. <Radio.Button value={SortTypeEnum.推荐指数}>推荐指数 <Tooltip title="结合视频在票圈、同业务场景、本账号下的历史表现综合得分"><QuestionCircleOutlined /></Tooltip></Radio.Button>
  297. <Radio.Button value={SortTypeEnum.更新时间}>更新时间</Radio.Button>
  298. </Radio.Group>
  299. </div>
  300. <div className="flex flex-wrap gap-2 mb-6 ml-10 items-center">
  301. <span className="text-gray-600 ">推荐标签:</span>
  302. {Tags.map<React.ReactNode>((tag) => (
  303. <Tag.CheckableTag
  304. key={tag}
  305. checked={tags.includes(tag)}
  306. onChange={(checked) => handleTagChange(tag, checked)}
  307. >
  308. {tag}
  309. </Tag.CheckableTag>
  310. ))}
  311. </div>
  312. </div>
  313. <div className="grid grid-cols-1 sm:grid-cols-2 md:grid-cols-3 gap-4">
  314. {videoList.map((video) => {
  315. const isSelected = selectedVideoIds.has(video.videoId);
  316. const isDisabled = !isSelected && selectedVideoIds.size >= MAX_SELECTION;
  317. return (
  318. <Card
  319. key={video.videoId}
  320. className={`relative ${isDisabled ? 'opacity-50' : 'cursor-pointer'} ${isSelected ? 'border-blue-500 border-2 bg-blue-50 shadow-md' : 'hover:shadow-sm'}`}
  321. styles={{ body: { padding: 0 } }}
  322. onClick={() => !isDisabled && handleSelectVideo(video.videoId)}
  323. >
  324. <div className="p-3">
  325. <Text type="secondary" className="text-xs">票圈 | 3亿人喜欢的视频平台</Text>
  326. <Paragraph className="mt-1 !mb-1" ellipsis={{ rows: 1, tooltip: true }} title={video.customTitle || video.title}>{video.customTitle || video.title}</Paragraph>
  327. </div>
  328. <div
  329. className="relative"
  330. style={{ paddingBottom: '79.8%' }}
  331. onClick={(e) => { e.stopPropagation(); playVideo(video); }}
  332. >
  333. <img src={video.customCover || video.cover} alt={video.customTitle || video.title} referrerPolicy="no-referrer" className="absolute inset-0 w-full h-full object-cover" />
  334. <div className="absolute inset-0 flex justify-center items-center cursor-pointer">
  335. <CaretRightFilled className="!text-white text-4xl bg-black/20 rounded-full p-1 pl-2" />
  336. </div>
  337. </div>
  338. <div className="p-3 flex justify-between items-center">
  339. <div className="flex flex-1 flex-col gap-1">
  340. <Text type="secondary" className="text-xs">推荐指数: {video.recommendScore?.toFixed(2) || '无'}</Text>
  341. <Text type="secondary" className="text-xs">推荐标签: {'tags' in video && video.tags && video.tags.length > 0 ?
  342. video.tags.map(tag => <Tag key={tag} className="text-xs">{tag}</Tag>) : '无'}</Text>
  343. </div>
  344. {isSelected ? (
  345. <CheckCircleFilled className="text-green-500 text-xl" />
  346. ) : (
  347. <div className={`w-5 h-5 border-2 ${isDisabled ? 'border-gray-200 bg-gray-100' : 'border-gray-300' } rounded-full`}></div>
  348. )}
  349. </div>
  350. {/* {isSelected && (
  351. <div className="p-3 flex justify-between items-center relative z-10">
  352. <Select
  353. placeholder="选择场景"
  354. style={{ width: 180 }}
  355. defaultValue={video.scene || 0}
  356. value={video.scene}
  357. onChange={(value) => handleChangeSelectVideo(video.videoId, value)}
  358. options={ planType === WeComPlanType.社群 ?
  359. [{ label: '群发', value: 0 }, { label: '单发', value: 1 }]
  360. : [{ label: '关注回复', value: 0 }]
  361. }
  362. onClick={(e) => e.stopPropagation()}
  363. />
  364. </div>
  365. )} */}
  366. </Card>
  367. );
  368. })}
  369. </div>
  370. </Drawer>
  371. <Modal
  372. open={!!playingVideo}
  373. onCancel={closeVideoPlayer}
  374. title={playingVideo?.title}
  375. footer={null}
  376. destroyOnClose
  377. width={720}
  378. styles={{ body: { padding: 0, background: '#000' } }}
  379. >
  380. {playingVideo && (
  381. <video controls autoPlay className="w-full h-auto max-h-[80vh] block" src={playingVideo.video}>
  382. Your browser does not support the video tag.
  383. </video>
  384. )}
  385. </Modal>
  386. </>
  387. );
  388. };
  389. export default VideoSelectModal;