index.tsx 8.5 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270
  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. } from 'antd';
  14. import { CheckCircleFilled, CaretRightFilled } from '@ant-design/icons';
  15. import { VideoListResponse } from '@src/views/publishContent/weGZH/components/types';
  16. import http from '@src/http';
  17. import { getVideoContentListApi } from '@src/http/api';
  18. import { useVideoCategoryOptions } from '@src/views/publishContent/weGZH/hooks/useVideoCategoryOptions';
  19. import { WeComPlanType, WeVideoItem } from '@src/views/publishContent/weCom/type'
  20. const { Text, Paragraph } = Typography;
  21. interface VideoSelectModalProps {
  22. planType: WeComPlanType;
  23. visible: boolean;
  24. onClose: () => void;
  25. onOk: (selectedVideos: WeVideoItem[]) => void;
  26. initialSelectedIds?: number[];
  27. }
  28. const VideoSelectModal: React.FC<VideoSelectModalProps> = ({ planType, visible, onClose, onOk, initialSelectedIds = [] }) => {
  29. const { videoCategoryOptions } = useVideoCategoryOptions();
  30. const [category, setCategory] = useState<string>();
  31. const [searchTerm, setSearchTerm] = useState<string>('');
  32. const [currentPage, setCurrentPage] = useState(1);
  33. const [pageSize, setPageSize] = useState(10);
  34. const [total, setTotal] = useState(0);
  35. const [loading, setLoading] = useState(false);
  36. const [videoList, setVideoList] = useState<WeVideoItem[]>([]);
  37. const [videoListAll, setVideoListAll] = useState<WeVideoItem[]>([]);
  38. const [selectedVideoIds, setSelectedVideoIds] = useState<Set<number>>(new Set(initialSelectedIds));
  39. const [playingVideo, setPlayingVideo] = useState<WeVideoItem | null>(null);
  40. const MAX_SELECTION = 3;
  41. const getVideoList = async (pageNum?: number, _pageSize?: number) => {
  42. setLoading(true);
  43. setCurrentPage(pageNum || currentPage);
  44. setPageSize(_pageSize || pageSize);
  45. const res = await http.post<VideoListResponse>(getVideoContentListApi, {
  46. category,
  47. title: searchTerm,
  48. pageNum: pageNum || currentPage,
  49. pageSize: _pageSize || pageSize,
  50. }).catch(() => {
  51. message.error('获取视频列表失败');
  52. }).finally(() => {
  53. setLoading(false);
  54. });
  55. if (res && res.code === 0) {
  56. const mappedVideos = res.data.objs.map(video => ({ ...video, scene: 0 as const }));
  57. setVideoList(mappedVideos);
  58. setVideoListAll(old => [...old, ...mappedVideos]);
  59. setTotal(res.data.totalSize);
  60. }
  61. }
  62. useEffect(() => {
  63. getVideoList();
  64. }, []);
  65. useEffect(() => {
  66. if (visible) {
  67. setSelectedVideoIds(new Set(initialSelectedIds));
  68. }
  69. }, [visible, initialSelectedIds]);
  70. const handleSearch = () => {
  71. console.log('Searching for:', { category, searchTerm });
  72. setCurrentPage(1);
  73. getVideoList();
  74. };
  75. const handleSelectVideo = (videoId: number) => {
  76. setSelectedVideoIds(prev => {
  77. const newSet = new Set(prev);
  78. if (newSet.has(videoId)) {
  79. newSet.delete(videoId);
  80. } else {
  81. if (newSet.size >= MAX_SELECTION) {
  82. message.warning(`最多只能选择 ${MAX_SELECTION} 条视频`);
  83. return prev;
  84. }
  85. newSet.add(videoId);
  86. }
  87. return newSet;
  88. });
  89. };
  90. const handleOk = () => {
  91. const selectedVideos = videoListAll.filter(video => selectedVideoIds.has(video.videoId));
  92. // 去重
  93. const uniqueSelectedVideos = selectedVideos.filter((video, index, self) =>
  94. index === self.findIndex((t) => t.videoId === video.videoId)
  95. );
  96. onOk(uniqueSelectedVideos);
  97. };
  98. const playVideo = (video: WeVideoItem) => {
  99. setPlayingVideo(video);
  100. };
  101. const closeVideoPlayer = () => {
  102. setPlayingVideo(null);
  103. };
  104. const handleChangeSelectVideo = (videoId: number, scene: number) => {
  105. setVideoList((prev: WeVideoItem[]) => {
  106. const newList = prev.map(video => {
  107. if (video.videoId === videoId) {
  108. return { ...video, scene: scene as 0 | 1 };
  109. }
  110. return video;
  111. });
  112. return newList;
  113. });
  114. setVideoListAll((prev: WeVideoItem[]) => {
  115. const newList = prev.map(video => {
  116. if (video.videoId === videoId) {
  117. return { ...video, scene: scene as 0 | 1 };
  118. }
  119. return video;
  120. });
  121. return newList;
  122. });
  123. }
  124. return (
  125. <>
  126. <Drawer
  127. title="内容选取"
  128. open={visible}
  129. onClose={onClose}
  130. width={800}
  131. placement="right"
  132. loading={loading}
  133. destroyOnClose
  134. styles={{ footer: { textAlign: 'right', padding: '10px 24px' } }}
  135. footer={
  136. <div className="flex justify-between items-center">
  137. <Pagination
  138. current={currentPage}
  139. pageSize={pageSize}
  140. total={total}
  141. onChange={(page, size) => {
  142. setCurrentPage(page);
  143. setPageSize(size);
  144. getVideoList(page, size);
  145. }}
  146. pageSizeOptions={['10', '20', '50']}
  147. size="small"
  148. showSizeChanger
  149. showTotal={(total) => `共 ${total} 条`}
  150. />
  151. <Space>
  152. <Text>已选 {selectedVideoIds.size} / {MAX_SELECTION} 条视频</Text>
  153. <Button onClick={onClose}>取消</Button>
  154. <Button type="primary" onClick={handleOk}>确定</Button>
  155. </Space>
  156. </div>
  157. }
  158. >
  159. <div className="flex flex-wrap gap-4 mb-6">
  160. <div className="flex items-center gap-2">
  161. <span className="text-gray-600">品类:</span>
  162. <Select
  163. placeholder="选择品类"
  164. style={{ width: 180 }}
  165. value={category}
  166. allowClear
  167. onChange={setCategory}
  168. options={videoCategoryOptions.map(option => ({ label: option, value: option }))}
  169. />
  170. </div>
  171. <div className="flex items-center gap-2">
  172. <span className="text-gray-600">视频标题:</span>
  173. <Input
  174. placeholder="搜索视频标题"
  175. style={{ width: 200 }}
  176. value={searchTerm}
  177. onChange={e => setSearchTerm(e.target.value)}
  178. />
  179. </div>
  180. <Button type="primary" onClick={handleSearch}>搜索</Button>
  181. </div>
  182. <div className="grid grid-cols-1 sm:grid-cols-2 md:grid-cols-3 gap-4">
  183. {videoList.map((video) => {
  184. const isSelected = selectedVideoIds.has(video.videoId);
  185. const isDisabled = !isSelected && selectedVideoIds.size >= MAX_SELECTION;
  186. return (
  187. <Card
  188. key={video.videoId}
  189. className={`relative ${isDisabled ? 'opacity-50' : 'cursor-pointer'} ${isSelected ? 'border-blue-500 border-2 bg-blue-50 shadow-md' : 'hover:shadow-sm'}`}
  190. styles={{ body: { padding: 0 } }}
  191. onClick={() => !isDisabled && handleSelectVideo(video.videoId)}
  192. >
  193. <div className="p-3">
  194. <Text type="secondary" className="text-xs">票圈 | 3亿人喜欢的视频平台</Text>
  195. <Paragraph className="mt-1 !mb-1" ellipsis={{ rows: 2, tooltip: true }} title={video.title}>{video.title}</Paragraph>
  196. </div>
  197. <div
  198. className="relative"
  199. style={{ paddingBottom: '79.8%' }}
  200. onClick={(e) => { e.stopPropagation(); playVideo(video); }}
  201. >
  202. <img src={video.cover} alt={video.title} referrerPolicy="no-referrer" className="absolute inset-0 w-full h-full object-cover" />
  203. <div className="absolute inset-0 flex justify-center items-center cursor-pointer">
  204. <CaretRightFilled className="!text-white text-4xl bg-black/20 rounded-full p-1 pl-2" />
  205. </div>
  206. </div>
  207. <div className="p-3 flex justify-between items-center">
  208. <Text type="secondary" className="text-xs">传播效率: {video.score?.toFixed(2)}</Text>
  209. {isSelected ? (
  210. <CheckCircleFilled className="text-green-500 text-xl" />
  211. ) : (
  212. <div className={`w-5 h-5 border-2 ${isDisabled ? 'border-gray-200 bg-gray-100' : 'border-gray-300' } rounded-full`}></div>
  213. )}
  214. </div>
  215. {isSelected && (
  216. <div className="p-3 flex justify-between items-center relative z-10">
  217. <Select
  218. placeholder="选择场景"
  219. style={{ width: 180 }}
  220. defaultValue={video.scene || 0}
  221. value={video.scene}
  222. onChange={(value) => handleChangeSelectVideo(video.videoId, value)}
  223. options={ planType === WeComPlanType.每日推送 ?
  224. [{ label: '群发', value: 0 }, { label: '单发', value: 1 }]
  225. : [{ label: '关注回复', value: 0 }]
  226. }
  227. onClick={(e) => e.stopPropagation()}
  228. />
  229. </div>
  230. )}
  231. </Card>
  232. );
  233. })}
  234. </div>
  235. </Drawer>
  236. <Modal
  237. open={!!playingVideo}
  238. onCancel={closeVideoPlayer}
  239. title={playingVideo?.title}
  240. footer={null}
  241. destroyOnClose
  242. width={720}
  243. styles={{ body: { padding: 0, background: '#000' } }}
  244. >
  245. {playingVideo && (
  246. <video controls autoPlay className="w-full h-auto max-h-[80vh] block" src={playingVideo.video}>
  247. Your browser does not support the video tag.
  248. </video>
  249. )}
  250. </Modal>
  251. </>
  252. );
  253. };
  254. export default VideoSelectModal;