VodUpload.tsx 8.5 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294
  1. import React, { useRef, useState } from "react";
  2. import TcVod from "vod-js-sdk-v6";
  3. import { toast } from "sonner";
  4. import { vodClient } from "@/client/api";
  5. import { Button } from "@/client/components/ui/button";
  6. import { Card, CardContent, CardHeader, CardTitle } from "@/client/components/ui/card";
  7. import { Progress } from "@/client/components/ui/progress";
  8. import { Badge } from "@/client/components/ui/badge";
  9. interface UploadTask {
  10. file: File;
  11. progress: number;
  12. status: "pending" | "uploading" | "success" | "error" | "canceled";
  13. fileId?: string;
  14. videoUrl?: string;
  15. coverUrl?: string;
  16. cancel?: () => void;
  17. }
  18. interface VodUploadProps {
  19. onUploadSuccess?: (result: { fileId: string; videoUrl: string; coverUrl?: string }) => void;
  20. onUploadError?: (error: Error) => void;
  21. }
  22. export const VodUpload: React.FC<VodUploadProps> = ({
  23. onUploadSuccess,
  24. onUploadError
  25. }) => {
  26. const videoInputRef = useRef<HTMLInputElement>(null);
  27. const coverInputRef = useRef<HTMLInputElement>(null);
  28. const [uploadTasks, setUploadTasks] = useState<UploadTask[]>([]);
  29. const [selectedVideo, setSelectedVideo] = useState<File | null>(null);
  30. const [selectedCover, setSelectedCover] = useState<File | null>(null);
  31. // 获取上传签名
  32. const getSignature = async () => {
  33. try {
  34. const response = await vodClient.signature.$get();
  35. if (response.status !== 200) {
  36. throw new Error("获取上传签名失败");
  37. }
  38. const { signature } = await response.json();
  39. return signature;
  40. } catch (error) {
  41. toast.error("获取上传签名失败");
  42. throw error;
  43. }
  44. };
  45. // 初始化VOD SDK
  46. const tcVod = new TcVod({
  47. getSignature: getSignature,
  48. });
  49. // 处理视频文件选择
  50. const handleVideoSelect = (e: React.ChangeEvent<HTMLInputElement>) => {
  51. if (e.target.files && e.target.files[0]) {
  52. setSelectedVideo(e.target.files[0]);
  53. }
  54. };
  55. // 处理封面文件选择
  56. const handleCoverSelect = (e: React.ChangeEvent<HTMLInputElement>) => {
  57. if (e.target.files && e.target.files[0]) {
  58. setSelectedCover(e.target.files[0]);
  59. }
  60. };
  61. // 开始上传
  62. const startUpload = async () => {
  63. if (!selectedVideo) {
  64. toast.warning("请先选择视频文件");
  65. return;
  66. }
  67. const newTask: UploadTask = {
  68. file: selectedVideo,
  69. progress: 0,
  70. status: "pending",
  71. };
  72. setUploadTasks((prev) => [...prev, newTask]);
  73. try {
  74. const uploader = tcVod.upload({
  75. mediaFile: selectedVideo,
  76. coverFile: selectedCover || undefined,
  77. });
  78. const updatedTask: UploadTask = {
  79. ...newTask,
  80. status: "uploading",
  81. cancel: () => {
  82. uploader.cancel();
  83. setUploadTasks((prev) =>
  84. prev.map((task) =>
  85. task.file === newTask.file
  86. ? { ...task, status: "canceled" }
  87. : task
  88. )
  89. );
  90. },
  91. };
  92. setUploadTasks((prev) =>
  93. prev.map((task) => task.file === newTask.file ? updatedTask : task)
  94. );
  95. // 监听上传进度
  96. uploader.on("media_progress", (info) => {
  97. setUploadTasks((prev) =>
  98. prev.map((task) =>
  99. task.file === newTask.file
  100. ? { ...task, progress: info.percent * 100 }
  101. : task
  102. )
  103. );
  104. });
  105. // 监听上传完成
  106. uploader.on("media_upload", (info) => {
  107. setUploadTasks((prev) =>
  108. prev.map((task) =>
  109. task.file === newTask.file ? { ...task, status: "success" } : task
  110. )
  111. );
  112. });
  113. // 执行上传
  114. const result = await uploader.done();
  115. const successTask = {
  116. ...updatedTask,
  117. fileId: result.fileId,
  118. videoUrl: result.video.url,
  119. coverUrl: result.cover?.url,
  120. status: "success",
  121. };
  122. setUploadTasks((prev) =>
  123. prev.map((task) =>
  124. task.file === newTask.file ? successTask : task
  125. )
  126. );
  127. toast.success("视频上传成功");
  128. // 调用成功回调
  129. if (onUploadSuccess) {
  130. onUploadSuccess({
  131. fileId: result.fileId,
  132. videoUrl: result.video.url,
  133. coverUrl: result.cover?.url
  134. });
  135. }
  136. } catch (error) {
  137. setUploadTasks((prev) =>
  138. prev.map((task) =>
  139. task.file === newTask.file ? { ...task, status: "error" } : task
  140. )
  141. );
  142. toast.error("视频上传失败");
  143. // 调用错误回调
  144. if (onUploadError) {
  145. onUploadError(error as Error);
  146. }
  147. } finally {
  148. setSelectedVideo(null);
  149. setSelectedCover(null);
  150. if (videoInputRef.current) videoInputRef.current.value = "";
  151. if (coverInputRef.current) coverInputRef.current.value = "";
  152. }
  153. };
  154. // 渲染上传状态标签
  155. const renderStatusTag = (status: UploadTask["status"]) => {
  156. switch (status) {
  157. case "pending":
  158. return <Badge variant="outline">等待上传</Badge>;
  159. case "uploading":
  160. return <Badge variant="secondary">上传中</Badge>;
  161. case "success":
  162. return <Badge variant="default">上传成功</Badge>;
  163. case "error":
  164. return <Badge variant="destructive">上传失败</Badge>;
  165. case "canceled":
  166. return <Badge variant="outline">已取消</Badge>;
  167. default:
  168. return <Badge variant="outline">未知状态</Badge>;
  169. }
  170. };
  171. return (
  172. <Card className="mb-6">
  173. <CardHeader>
  174. <CardTitle>视频上传</CardTitle>
  175. </CardHeader>
  176. <CardContent className="space-y-4">
  177. <div className="flex flex-wrap items-center gap-3">
  178. <input
  179. type="file"
  180. ref={videoInputRef}
  181. onChange={handleVideoSelect}
  182. accept="video/*"
  183. className="hidden"
  184. />
  185. <Button
  186. onClick={() => videoInputRef.current?.click()}
  187. variant="default"
  188. >
  189. {selectedVideo ? selectedVideo.name : "选择视频文件"}
  190. </Button>
  191. <input
  192. type="file"
  193. ref={coverInputRef}
  194. onChange={handleCoverSelect}
  195. accept="image/*"
  196. className="hidden"
  197. />
  198. <Button
  199. onClick={() => coverInputRef.current?.click()}
  200. variant="outline"
  201. >
  202. {selectedCover ? selectedCover.name : "选择封面(可选)"}
  203. </Button>
  204. <Button
  205. onClick={startUpload}
  206. disabled={!selectedVideo}
  207. >
  208. 开始上传
  209. </Button>
  210. </div>
  211. <div className="space-y-4">
  212. {uploadTasks.map((task, index) => (
  213. <div key={index} className="border rounded-lg p-4">
  214. <div className="flex items-center gap-2 mb-3">
  215. <span className="font-medium">{task.file.name}</span>
  216. {renderStatusTag(task.status)}
  217. {task.status === "uploading" && task.cancel && (
  218. <Button
  219. variant="ghost"
  220. size="sm"
  221. onClick={task.cancel}
  222. className="text-destructive hover:text-destructive"
  223. >
  224. 取消上传
  225. </Button>
  226. )}
  227. </div>
  228. {task.status === "uploading" && (
  229. <Progress value={task.progress} className="w-full" />
  230. )}
  231. {task.fileId && (
  232. <div className="space-y-2 text-sm mt-3">
  233. <div className="font-medium">File ID: {task.fileId}</div>
  234. {task.videoUrl && (
  235. <div>
  236. 视频地址:{" "}
  237. <a
  238. href={task.videoUrl}
  239. target="_blank"
  240. rel="noreferrer"
  241. className="text-primary hover:underline break-all"
  242. >
  243. {task.videoUrl}
  244. </a>
  245. </div>
  246. )}
  247. {task.coverUrl && (
  248. <div>
  249. 封面地址:{" "}
  250. <a
  251. href={task.coverUrl}
  252. target="_blank"
  253. rel="noreferrer"
  254. className="text-primary hover:underline break-all"
  255. >
  256. {task.coverUrl}
  257. </a>
  258. </div>
  259. )}
  260. </div>
  261. )}
  262. </div>
  263. ))}
  264. </div>
  265. </CardContent>
  266. </Card>
  267. );
  268. };