index.tsx 19 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630
  1. import { Button, Modal, Upload, Progress, message, Form, Input, Space } from "antd";
  2. import { UploadOutlined, ReloadOutlined, PlusOutlined } from "@ant-design/icons";
  3. import React, { useState, useEffect, useCallback } from "react";
  4. import OSSSDK from "../../../../../utils/OSSSDK";
  5. import http from "../../../../../http";
  6. import styles from "./index.module.css";
  7. import type { UploadFile, UploadProps } from "antd/es/upload/interface";
  8. import { getAccessToken } from "../../../../../http/sso";
  9. import { adFileUpload, getTempStsToken, uploadPublishVideo } from "../../../../../http/api";
  10. interface UploadVideoModalProps {
  11. visible: boolean;
  12. onClose: () => void;
  13. onOk?: (videoInfo: any) => void;
  14. isLoading?: boolean;
  15. videoInfo?: any;
  16. }
  17. interface UploadStatus {
  18. isUploading: boolean;
  19. isUploaded: boolean;
  20. isError: boolean;
  21. errType?: string;
  22. }
  23. interface UploadCreds {
  24. host: string;
  25. hosts: string[];
  26. fileName: string;
  27. upload: string;
  28. accessKeyId: string;
  29. accessKeySecret: string;
  30. securityToken: string;
  31. expiration: string;
  32. }
  33. const UploadVideoModal: React.FC<UploadVideoModalProps> = ({
  34. visible,
  35. onClose,
  36. onOk,
  37. videoInfo
  38. }) => {
  39. // 视频文件状态
  40. const [videoFile, setVideoFile] = useState<File & { localUrl?: string } | null>(null);
  41. const [videoUploadProgress, setVideoUploadProgress] = useState(0);
  42. const [videoUploadStatus, setVideoUploadStatus] = useState<UploadStatus>({
  43. isUploading: false,
  44. isUploaded: false,
  45. isError: false
  46. });
  47. // 封面文件状态
  48. const [coverFileList, setCoverFileList] = useState<UploadFile[]>([]);
  49. const [coverUploadStatus, setCoverUploadStatus] = useState<UploadStatus>({
  50. isUploading: false,
  51. isUploaded: false,
  52. isError: false
  53. });
  54. // OSS相关状态
  55. const [videoCreds, setVideoCreds] = useState<UploadCreds | null>(null);
  56. const [videoUploader, setVideoUploader] = useState<OSSSDK | null>(null);
  57. const [videoUrl, setVideoUrl] = useState('');
  58. // 重试相关状态
  59. const [speedTimer, setSpeedTimer] = useState<NodeJS.Timeout | null>(null);
  60. // 表单引用
  61. const [form] = Form.useForm();
  62. const [isEditMode, setIsEditMode] = useState(false);
  63. // 视频预览
  64. const [videoPreviewOpen, setVideoPreviewOpen] = useState(false);
  65. const [isVideoHovering, setIsVideoHovering] = useState(false);
  66. // 发布视频loading状态
  67. const [publishLoading, setPublishLoading] = useState(false);
  68. // 重置状态
  69. const resetStates = useCallback(() => {
  70. // 重置视频文件状态
  71. setVideoFile(null);
  72. setVideoUploadProgress(0);
  73. setVideoUploadStatus({
  74. isUploading: false,
  75. isUploaded: false,
  76. isError: false
  77. });
  78. // 重置封面文件状态
  79. setCoverFileList([]);
  80. setCoverUploadStatus({
  81. isUploading: false,
  82. isUploaded: false,
  83. isError: false
  84. });
  85. // 重置OSS状态
  86. setVideoCreds(null);
  87. setVideoUploader(null);
  88. setVideoUrl('');
  89. setIsEditMode(false);
  90. if (speedTimer) {
  91. clearInterval(speedTimer);
  92. setSpeedTimer(null);
  93. }
  94. form.resetFields();
  95. }, [speedTimer, form]);
  96. // 组件卸载时清理
  97. useEffect(() => {
  98. return () => {
  99. if (speedTimer) clearInterval(speedTimer);
  100. if (videoUploader) {
  101. videoUploader.cancelUpload();
  102. }
  103. };
  104. }, [speedTimer, videoUploader]);
  105. useEffect(() => {
  106. if (videoFile) {
  107. startVideoUpload(videoFile);
  108. }
  109. }, [videoFile]);
  110. // 当videoInfo发生变化时,初始化表单和状态
  111. useEffect(() => {
  112. if (visible && videoInfo) {
  113. setIsEditMode(true);
  114. setVideoUploadProgress(100);
  115. // 填充表单数据
  116. form.setFieldsValue({
  117. title: videoInfo.title,
  118. });
  119. // 设置视频URL和状态
  120. setVideoUrl(videoInfo.videoUrl);
  121. setVideoUploadStatus({
  122. isUploading: false,
  123. isUploaded: true,
  124. isError: false
  125. });
  126. // 设置封面文件
  127. if (videoInfo.coverUrl) {
  128. setCoverFileList([{
  129. uid: '-1',
  130. name: videoInfo.coverName || 'cover.jpg',
  131. status: 'done',
  132. url: videoInfo.coverUrl,
  133. response: {
  134. data: {
  135. fileUrl: videoInfo.coverUrl
  136. }
  137. }
  138. }]);
  139. setCoverUploadStatus({
  140. isUploading: false,
  141. isUploaded: true,
  142. isError: false
  143. });
  144. }
  145. // 对于编辑模式,我们需要创建一个模拟的videoFile对象来显示视频预览
  146. if (videoInfo.videoUrl) {
  147. // 提取文件名作为显示名称
  148. const fileName = videoInfo.videoName || videoInfo.videoUrl.split('/').pop() || 'video.mp4';
  149. setVideoFile({
  150. localUrl: videoInfo.videoUrl,
  151. name: fileName,
  152. type: 'video/mp4',
  153. size: 0 // 实际项目中可能需要从服务器获取文件大小
  154. } as File & { localUrl?: string });
  155. }
  156. }
  157. }, [visible, videoInfo, form]);
  158. // 获取上传凭证
  159. const getSignature = async (fileType: number, uploadId?: string): Promise<UploadCreds> => {
  160. try {
  161. const params: any = { fileType };
  162. if (uploadId) {
  163. params.uploadId = uploadId;
  164. }
  165. // 这里需要根据实际API接口调整
  166. const response = await http.post<any>(getTempStsToken, params);
  167. if (response.code === 0) {
  168. const credsData = response.data;
  169. if (fileType === 2) { // 视频文件
  170. setVideoUrl(credsData.fileName);
  171. }
  172. return credsData;
  173. } else {
  174. throw new Error(response.data.msg || '获取签名失败');
  175. }
  176. } catch (error) {
  177. console.error('获取签名失败:', error);
  178. throw error;
  179. }
  180. };
  181. // 初始化视频上传器
  182. const initVideoUploader = async (creds: UploadCreds): Promise<OSSSDK> => {
  183. if (!videoFile) {
  184. throw new Error('视频文件不存在');
  185. }
  186. const uploader = new OSSSDK(videoFile, creds, (checkpoint: any[]) => {
  187. // 更新上传进度
  188. const progress = Number((checkpoint[checkpoint.length - 1].percent * 100).toFixed(2));
  189. setVideoUploadProgress(progress);
  190. });
  191. return uploader;
  192. };
  193. // 开始视频上传
  194. const startVideoUpload = async (file?: File) => {
  195. const targetFile = file || videoFile;
  196. if (!targetFile) {
  197. message.error('请先选择视频文件');
  198. return;
  199. }
  200. try {
  201. setVideoUploadStatus(prev => ({ ...prev, isUploading: true, isError: false }));
  202. // 获取上传凭证
  203. const uploadCreds = await getSignature(2); // 2表示视频文件
  204. setVideoCreds(uploadCreds);
  205. // 初始化上传器
  206. const uploaderInstance = await initVideoUploader(uploadCreds);
  207. setVideoUploader(uploaderInstance);
  208. // 开始上传
  209. await uploaderInstance.multipartUpload();
  210. // 上传完成
  211. setVideoUploadStatus(prev => ({ ...prev, isUploading: false, isUploaded: true }));
  212. if (!isEditMode) {
  213. message.success('视频上传成功');
  214. }
  215. } catch (error: any) {
  216. console.error('视频上传失败:', error);
  217. setVideoUploadStatus(prev => ({ ...prev, isUploading: false, isError: true, errType: 'uploadError' }));
  218. message.error('视频上传失败,请重试');
  219. }
  220. };
  221. // 封面上传处理函数
  222. const handleCoverUploadChange: UploadProps['onChange'] = ({ fileList: newFileList }) => {
  223. // 只保留最新的文件
  224. setCoverFileList(newFileList.slice(-1));
  225. // 如果上传成功,更新状态
  226. if (newFileList.length > 0 && newFileList[0].status === 'done') {
  227. setCoverUploadStatus(prev => ({ ...prev, isUploaded: true, isError: false }));
  228. message.success('封面上传成功');
  229. } else if (newFileList.length > 0 && newFileList[0].status === 'error') {
  230. setCoverUploadStatus(prev => ({ ...prev, isError: true }));
  231. message.error('封面上传失败');
  232. } else if (newFileList.length > 0 && newFileList[0].status === 'uploading') {
  233. setCoverUploadStatus(prev => ({ ...prev, isUploading: true, isError: false }));
  234. }
  235. };
  236. // 封面文件验证
  237. const checkCoverFile = (file: UploadFile) => {
  238. if ((file.size || 0) > 5 * 1024 * 1024) {
  239. message.error('图片大小不能超过5MB');
  240. return Upload.LIST_IGNORE; // 阻止上传
  241. }
  242. return true; // 允许上传
  243. };
  244. // 重试视频上传
  245. const handleVideoRetry = async () => {
  246. setVideoUploadStatus(prev => ({ ...prev, isError: false }));
  247. if (videoUploader && videoCreds) {
  248. try {
  249. setVideoUploadStatus(prev => ({ ...prev, isUploading: true }));
  250. // 重新获取凭证
  251. const newCreds = await getSignature(2, videoCreds.upload);
  252. setVideoCreds(newCreds);
  253. videoUploader.updateConfig(newCreds);
  254. // 断点续传
  255. await videoUploader.resumeMultipartUpload();
  256. setVideoUploadStatus(prev => ({ ...prev, isUploading: false, isUploaded: true }));
  257. message.success('视频上传成功');
  258. } catch (error) {
  259. console.error('重试视频上传失败:', error);
  260. setVideoUploadStatus(prev => ({ ...prev, isUploading: false, isError: true }));
  261. message.error('重试失败');
  262. }
  263. }
  264. };
  265. // 取消视频上传
  266. const cancelVideoUpload = () => {
  267. if (videoUploader) {
  268. videoUploader.cancelUpload();
  269. }
  270. setVideoUploadStatus(prev => ({ ...prev, isUploading: false }));
  271. message.info('已取消视频上传');
  272. };
  273. // 发布视频
  274. const publishVideo = async () => {
  275. if (!videoUploadStatus.isUploaded) {
  276. message.warning('请等待视频上传完成');
  277. return;
  278. }
  279. // 表单校验
  280. try {
  281. await form.validateFields();
  282. } catch (error) {
  283. message.warning('请填写完整的视频信息');
  284. return;
  285. }
  286. // 校验封面是否上传
  287. if (coverFileList.length === 0 || !coverUploadStatus.isUploaded) {
  288. message.warning('请上传视频封面');
  289. return;
  290. }
  291. try {
  292. // 设置loading状态为true
  293. setPublishLoading(true);
  294. const formData = form.getFieldsValue();
  295. const publishData = {
  296. ...formData,
  297. videoUrl: isEditMode ? videoUrl : (videoCreds?.fileName || videoUrl),
  298. coverUrl: coverFileList.length > 0 ? coverFileList[0].response.data.fileUrl : '',
  299. fileExtensions: 'mp4', // 可以根据文件类型动态设置
  300. ...(isEditMode && videoInfo?.videoId && { videoId: videoInfo.videoId })
  301. };
  302. // 这里需要根据实际API接口调整
  303. const response = await http.post<any>(uploadPublishVideo, publishData);
  304. if (response.code === 0) {
  305. message.success('发布成功');
  306. onOk?.(response.data);
  307. onClose();
  308. resetStates();
  309. } else {
  310. message.error(response.msg || '发布失败');
  311. }
  312. } catch (error) {
  313. console.error('发布失败:', error);
  314. message.error('发布失败,请重试');
  315. } finally {
  316. // 请求结束后(无论成功或失败),设置loading状态为false
  317. setPublishLoading(false);
  318. }
  319. };
  320. // 视频文件上传前处理
  321. const beforeVideoUpload = (file: File & { localUrl?: string }) => {
  322. // 验证文件类型
  323. const isVideo = file.type.startsWith('video/');
  324. if (!isVideo) {
  325. message.error('只能上传视频文件!');
  326. return false;
  327. }
  328. // 验证文件大小 (例如:限制500MB)
  329. const isLt500M = file.size / 1024 / 1024 < 500;
  330. if (!isLt500M) {
  331. message.error('视频大小不能超过500MB!');
  332. return false;
  333. }
  334. file.localUrl = URL.createObjectURL(file);
  335. setVideoFile(file);
  336. setVideoUploadProgress(0);
  337. setVideoUploadStatus({
  338. isUploading: false,
  339. isUploaded: false,
  340. isError: false
  341. });
  342. return false; // 阻止自动上传
  343. };
  344. // 删除视频文件
  345. const handleVideoRemove = () => {
  346. setVideoFile(null);
  347. setVideoUploadProgress(0);
  348. setVideoUploadStatus({
  349. isUploading: false,
  350. isUploaded: false,
  351. isError: false
  352. });
  353. if (videoUploader) {
  354. videoUploader.cancelUpload();
  355. }
  356. };
  357. // 删除封面文件
  358. const handleCoverRemove = () => {
  359. setCoverFileList([]);
  360. setCoverUploadStatus({
  361. isUploading: false,
  362. isUploaded: false,
  363. isError: false
  364. });
  365. };
  366. // 获取视频进度文本
  367. const getVideoProgressText = () => {
  368. if (videoUploadStatus.isError) {
  369. return '上传失败,点击重试重新上传';
  370. } else if (!videoUploadStatus.isUploading && videoUploadStatus.isUploaded) {
  371. return '上传完成';
  372. } else if (!videoUploadStatus.isUploading && !videoUploadStatus.isUploaded) {
  373. return '等待中...';
  374. } else {
  375. return '';
  376. }
  377. };
  378. // 获取封面进度文本
  379. const getCoverProgressText = () => {
  380. if (coverUploadStatus.isError) {
  381. return '上传失败,请重新选择文件';
  382. } else if (!coverUploadStatus.isUploading && coverUploadStatus.isUploaded) {
  383. return '上传完成';
  384. } else if (coverFileList.length > 0 && coverFileList[0].status === 'uploading') {
  385. return '上传中...';
  386. } else {
  387. return '';
  388. }
  389. };
  390. return (
  391. <>
  392. <Modal
  393. open={visible}
  394. onCancel={() => {
  395. onClose();
  396. resetStates();
  397. }}
  398. footer={null}
  399. width={800}
  400. title={isEditMode ? "修改视频" : "上传视频"}
  401. destroyOnHidden
  402. >
  403. <div className={styles['upload-video-modal']}>
  404. {/* 视频上传区域:上传成功后隐藏选择模块 */}
  405. {!videoFile && (
  406. <div className={styles['upload-section']}>
  407. <h4>视频文件</h4>
  408. <Upload
  409. fileList={[]}
  410. beforeUpload={beforeVideoUpload}
  411. onRemove={handleVideoRemove}
  412. accept="video/*"
  413. maxCount={1}
  414. showUploadList={false}
  415. >
  416. <Button icon={<UploadOutlined />} disabled={videoUploadStatus.isUploading}>
  417. 选择视频文件
  418. </Button>
  419. </Upload>
  420. </div>
  421. )}
  422. {/* 视频上传进度区域 */}
  423. {videoFile && (
  424. <div className={styles['upload-progress-section']}>
  425. <div style={{ display: 'flex', gap: 16, alignItems: 'center' }}>
  426. <div
  427. style={{ width: 104, height: 104, background: '#fafafa', border: '1px dashed #d9d9d9', borderRadius: 8, display: 'flex', alignItems: 'center', justifyContent: 'center', overflow: 'hidden', position: 'relative' }}
  428. onMouseEnter={() => setIsVideoHovering(true)}
  429. onMouseLeave={() => setIsVideoHovering(false)}
  430. >
  431. <video src={videoFile.localUrl} style={{ width: '100%', height: '100%', objectFit: 'cover' }} muted />
  432. {!videoUploadStatus.isUploading && isVideoHovering && (
  433. <div style={{ position: 'absolute', inset: 0, background: 'rgba(0,0,0,0.35)' }}>
  434. <div style={{ position: 'absolute', left: 0, right: 0, bottom: 0, display: 'flex', justifyContent: 'space-between', padding: 8 }}>
  435. <Button size="small" type="primary" onClick={() => setVideoPreviewOpen(true)}>
  436. 预览
  437. </Button>
  438. <Button size="small" danger onClick={handleVideoRemove}>
  439. 删除
  440. </Button>
  441. </div>
  442. </div>
  443. )}
  444. </div>
  445. <div style={{ flex: 1 }}>
  446. <div className={styles['progress-info']}>
  447. <span className={styles['file-name']}>{videoFile.name}</span>
  448. </div>
  449. <Progress
  450. percent={videoUploadProgress}
  451. status={videoUploadStatus.isError ? 'exception' : 'active'}
  452. strokeColor={videoUploadStatus.isError ? '#F2584F' : '#FF4383'}
  453. />
  454. {!isEditMode ? <div className={styles['progress-text']}>{getVideoProgressText()}</div> : null}
  455. </div>
  456. </div>
  457. </div>
  458. )}
  459. {/* 封面上传区域 */}
  460. <div className={styles['upload-section']}>
  461. <h4>封面图片</h4>
  462. <Upload
  463. action={adFileUpload}
  464. headers={{
  465. token: getAccessToken()
  466. }}
  467. accept="image/*"
  468. listType="picture-card"
  469. beforeUpload={checkCoverFile}
  470. onChange={handleCoverUploadChange}
  471. fileList={coverFileList}
  472. showUploadList={{ showPreviewIcon: false }}
  473. maxCount={1}
  474. data={{ fileType: 'PICTURE' }}
  475. onRemove={handleCoverRemove}
  476. >
  477. {coverFileList.length >= 1 ? null : (
  478. <button style={{ border: 0, background: 'none' }} type="button">
  479. <PlusOutlined />
  480. <div style={{ marginTop: 8 }}>
  481. {coverUploadStatus.isUploaded ? '已选择封面' : '上传封面'}
  482. </div>
  483. </button>
  484. )}
  485. </Upload>
  486. {!isEditMode && getCoverProgressText() && (
  487. <div className={styles['progress-text']}>{getCoverProgressText()}</div>
  488. )}
  489. </div>
  490. {/* 视频信息编辑区域 */}
  491. <div className={styles['video-info-section']}>
  492. <Form form={form} layout="vertical">
  493. <Form.Item
  494. label="视频标题"
  495. name="title"
  496. rules={[{ required: true, message: '请输入视频标题' }]}
  497. >
  498. <Input placeholder="请输入视频标题" />
  499. </Form.Item>
  500. </Form>
  501. </div>
  502. {/* 操作按钮区域 */}
  503. <div className={styles['action-section']}>
  504. <div className={styles['right-actions']}>
  505. <Space>
  506. {/* 视频上传操作 */}
  507. {videoFile && (
  508. <>
  509. {videoUploadStatus.isUploading && (
  510. <Button onClick={cancelVideoUpload}>
  511. 取消视频上传
  512. </Button>
  513. )}
  514. {videoUploadStatus.isError && (
  515. <Button
  516. type="primary"
  517. danger
  518. icon={<ReloadOutlined />}
  519. onClick={handleVideoRetry}
  520. className={styles['retry-btn']}
  521. >
  522. 重试视频
  523. </Button>
  524. )}
  525. {!videoUploadStatus.isUploading && videoUploadStatus.isUploaded && (
  526. <Button
  527. danger
  528. onClick={handleVideoRemove}
  529. >
  530. 删除已上传视频
  531. </Button>
  532. )}
  533. </>
  534. )}
  535. {/* 发布按钮 */}
  536. {videoUploadStatus.isUploaded && (
  537. <Button
  538. type="primary"
  539. onClick={publishVideo}
  540. loading={publishLoading}
  541. >
  542. 发布视频
  543. </Button>
  544. )}
  545. </Space>
  546. </div>
  547. </div>
  548. </div>
  549. </Modal>
  550. {/* 视频预览弹窗 */}
  551. <Modal
  552. open={videoPreviewOpen}
  553. onCancel={() => setVideoPreviewOpen(false)}
  554. footer={null}
  555. width={720}
  556. title="预览视频"
  557. destroyOnHidden
  558. >
  559. <video src={videoFile?.localUrl} style={{ height: '100%', margin: '0 auto' }} controls />
  560. </Modal>
  561. </>
  562. );
  563. };
  564. export default UploadVideoModal;