index.vue 16 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554
  1. <template>
  2. <div>
  3. <el-form-item label="视频上传">
  4. <div class="upload_video" id="upload_video">
  5. <div class="upload-toolbar">
  6. <el-upload
  7. class="upload-picker"
  8. ref="upload"
  9. action="#"
  10. accept=".mp4"
  11. :limit="1"
  12. :show-file-list="false"
  13. :on-remove="handleRemove"
  14. :on-change="handleChange"
  15. :auto-upload="false"
  16. :key="uploadKey"
  17. >
  18. <el-button slot="trigger" size="small" type="primary">选取视频</el-button>
  19. </el-upload>
  20. <el-button
  21. size="small"
  22. type="success"
  23. :loading="uploadLoading"
  24. :disabled="!hasLocalFile"
  25. @click="submitUpload"
  26. >点击上传</el-button>
  27. <el-button
  28. v-if="showControl"
  29. size="small"
  30. type="success"
  31. plain
  32. @click="openVideoLibrary"
  33. >视频库选取</el-button>
  34. </div>
  35. <p class="upload-tip">只能上传mp4文件,且不超过500M</p>
  36. <div v-if="displayFileName" class="upload-file-info">
  37. <i class="el-icon-document" />
  38. <span class="upload-file-name" :title="displayFileName">{{ displayFileName }}</span>
  39. </div>
  40. <div class="progress-container">
  41. <div class="progress-row">
  42. <span class="progress-label">线路一</span>
  43. <span class="progress-percent">{{ txProgress }}%</span>
  44. </div>
  45. <el-progress
  46. class="progress-bar"
  47. :percentage="txProgress"
  48. :stroke-width="18"
  49. :show-text="false"
  50. :status="txProgress >= 100 ? 'success' : undefined"
  51. />
  52. </div>
  53. <div class="progress-container">
  54. <div class="progress-row">
  55. <span class="progress-label">线路二</span>
  56. <span class="progress-percent">{{ hwProgress }}%</span>
  57. </div>
  58. <el-progress
  59. class="progress-bar"
  60. :percentage="hwProgress"
  61. :stroke-width="18"
  62. :show-text="false"
  63. :status="hwProgress >= 100 ? 'success' : undefined"
  64. />
  65. </div>
  66. </div>
  67. </el-form-item>
  68. <el-form-item label="视频播放">
  69. <video v-if="videoUrl" ref="myvideo" :src="videoUrl" id="video" width="100%" height="300px" controls></video>
  70. <div v-if="fileName">视频文件名: {{ fileName }}</div>
  71. <div v-if="fileKey">文件Key: {{ fileKey }}</div>
  72. <div v-if="fileSize">文件大小(MB): {{ (fileSize / (1024 * 1024)).toFixed(2) }} MB</div>
  73. </el-form-item>
  74. <!-- 仅当showControl为true时显示播放线路选择器 -->
  75. <el-form-item v-if="showControl" label="播放线路">
  76. <el-radio-group v-model="localUploadType">
  77. <el-radio :label="1" >线路一</el-radio>
  78. <el-radio :label="2" >线路二</el-radio>
  79. <!-- <el-radio :label="3" >线路三</el-radio>-->
  80. </el-radio-group>
  81. </el-form-item>
  82. <!-- 视频库选择对话框 -->
  83. <el-dialog title="视频库选择" :visible.sync="libraryOpen" width="900px" append-to-body>
  84. <!-- 搜索条件 -->
  85. <el-form :inline="true" :model="libraryQueryParams" class="library-search">
  86. <el-form-item label="素材名称">
  87. <el-input
  88. v-model="libraryQueryParams.resourceName"
  89. placeholder="请输入素材名称"
  90. clearable
  91. size="small"
  92. @keyup.enter.native="handleLibraryQuery"
  93. />
  94. </el-form-item>
  95. <el-form-item>
  96. <el-button type="primary" icon="el-icon-search" size="mini" @click="handleLibraryQuery">搜索</el-button>
  97. <el-button icon="el-icon-refresh" size="mini" @click="resetLibraryQuery">重置</el-button>
  98. </el-form-item>
  99. </el-form>
  100. <!-- 视频列表 -->
  101. <el-table v-loading="libraryLoading" :data="libraryList" @row-click="handleLibrarySelect" highlight-current-row>
  102. <el-table-column label="素材名称" align="center" prop="resourceName" />
  103. <el-table-column label="文件名称" align="center" prop="fileName" />
  104. <el-table-column label="缩略图" align="center">
  105. <template slot-scope="scope">
  106. <el-popover
  107. placement="right"
  108. title=""
  109. trigger="hover"
  110. >
  111. <img alt="" slot="reference" :src="scope.row.thumbnail" style="width: 80px; height: 50px" />
  112. <img alt="" :src="scope.row.thumbnail" style="max-width: 150px;" />
  113. </el-popover>
  114. </template>
  115. </el-table-column>
  116. <el-table-column label="视频时长" align="center">
  117. <template slot-scope="scope">
  118. <span>{{ formatDuration(scope.row.duration) }}</span>
  119. </template>
  120. </el-table-column>
  121. </el-table>
  122. <!-- 分页 -->
  123. <pagination
  124. v-show="libraryTotal>0"
  125. :total="libraryTotal"
  126. :page.sync="libraryQueryParams.pageNum"
  127. :limit.sync="libraryQueryParams.pageSize"
  128. @pagination="getLibraryList"
  129. />
  130. <div slot="footer" class="dialog-footer">
  131. <el-button type="primary" @click="confirmVideoSelection">确 定</el-button>
  132. <el-button @click="cancelVideoSelection">取 消</el-button>
  133. </div>
  134. </el-dialog>
  135. </div>
  136. </template>
  137. <script>
  138. import {getThumbnail} from "@/api/course/userVideo";
  139. import { uploadObject } from "@/utils/cos.js";
  140. import { uploadToHSY } from "@/utils/hsy.js";
  141. import Pagination from "@/components/Pagination";
  142. import { listVideoResource } from '@/api/course/videoResource';
  143. import Vue from 'vue';
  144. export default {
  145. components: {
  146. Pagination
  147. },
  148. props: {
  149. videoUrl: {
  150. type: String,
  151. default: "",
  152. },
  153. fileKey: {
  154. type: String,
  155. default: "",
  156. },
  157. fileSize: {
  158. type: Number,
  159. default: null,
  160. },
  161. fileName: {
  162. type: String,
  163. default: "",
  164. },
  165. line_1: {
  166. type: String,
  167. default: "",
  168. },
  169. line_2: {
  170. type: String,
  171. default: "",
  172. },
  173. line_3: {
  174. type: String,
  175. default: "",
  176. },
  177. thumbnail: {
  178. type: String,
  179. default: "",
  180. },
  181. uploadType: {
  182. type: Number,
  183. default: null,
  184. },
  185. type: {
  186. type: Number,
  187. default: null,
  188. },
  189. isPrivate: {
  190. type: Number,
  191. default: 0,
  192. },
  193. // 使用一个变量控制显示,默认为true显示所有控制项
  194. showControl: {
  195. type: Boolean,
  196. default: true,
  197. }
  198. },
  199. data() {
  200. return {
  201. videoName:'',
  202. isHidden : false,
  203. localUploadType: this.uploadType,
  204. duration: 0,
  205. fileId: "",
  206. uploadTypeOptions: [
  207. { dictLabel: "线路一", dictValue: 1 }, // 腾讯云 COS
  208. { dictLabel: "线路二", dictValue: 2 }, // 火山云点播
  209. // { dictLabel: "华为云VOD", dictValue: 4 },
  210. // { dictLabel: "腾讯云VOD", dictValue: 5 },
  211. ],
  212. fileList: [],
  213. txProgress: 0,
  214. hwProgress: 0,
  215. uploadLoading: false,
  216. uploadKey: 0,
  217. // 视频库选择相关数据
  218. libraryOpen: false,
  219. libraryLoading: false,
  220. libraryTotal: 0,
  221. libraryList: [],
  222. selectedVideo: null,
  223. libraryQueryParams: {
  224. pageNum: 1,
  225. pageSize: 10,
  226. resourceName: null
  227. }
  228. };
  229. },
  230. computed: {
  231. hasLocalFile() {
  232. return this.fileList.length > 0;
  233. },
  234. displayFileName() {
  235. if (this.fileList.length > 0) {
  236. return this.fileList[0].name;
  237. }
  238. return this.fileName || "";
  239. }
  240. },
  241. watch: {
  242. localUploadType(newType) {
  243. this.$emit("update:uploadType", newType);
  244. this.$emit("change");
  245. },
  246. uploadType(newType) {
  247. this.localUploadType = newType;
  248. },
  249. },
  250. methods: {
  251. normalizeProgress(value) {
  252. const num = Number(value);
  253. if (!Number.isFinite(num)) {
  254. return 0;
  255. }
  256. if (num <= 1) {
  257. return Math.min(100, Math.max(0, Math.round(num * 100)));
  258. }
  259. return Math.min(100, Math.max(0, Math.round(num)));
  260. },
  261. // 打开视频库对话框
  262. openVideoLibrary() {
  263. this.libraryOpen = true;
  264. this.selectedVideo = null;
  265. this.getLibraryList();
  266. },
  267. handleChange(file, fileList) {
  268. this.fileList = fileList;
  269. this.getVideoDuration(file.raw);
  270. },
  271. getVideoDuration(file) {
  272. const video = document.createElement("video");
  273. video.preload = "metadata";
  274. video.onloadedmetadata = () => {
  275. window.URL.revokeObjectURL(video.src);
  276. this.duration = parseInt(video.duration.toFixed(2));
  277. console.log("视频时长=========>",this.duration);
  278. this.$emit("video-duration", this.duration);
  279. console.log("文件大小=====>",file.size);
  280. this.$emit("update:fileSize", file.size);
  281. };
  282. video.src = URL.createObjectURL(file);
  283. },
  284. async submitUpload() {
  285. if (this.fileList.length < 1) {
  286. return this.$message.error("请先选取视频,再进行上传");
  287. }
  288. this.uploadLoading = true;
  289. this.txProgress = 0;
  290. this.hwProgress = 0;
  291. try {
  292. await this.getFirstThumbnail();
  293. await this.uploadVideoToTxPcdn();
  294. await this.uploadVideoToHsy();
  295. this.$emit("update:fileName", this.fileList[0].name);
  296. } finally {
  297. this.uploadLoading = false;
  298. }
  299. },
  300. //获取第一帧封面
  301. async getFirstThumbnail(){
  302. const file = this.fileList[0].raw;
  303. getThumbnail(file).then(response => {
  304. console.log("获取到第一帧为封面======>",response.url)
  305. this.$emit("update:thumbnail", response.url);
  306. })
  307. },
  308. // 更新火山云线路进度条
  309. updateHwProgress(progress) {
  310. if (typeof progress === "number") {
  311. this.hwProgress = this.normalizeProgress(progress);
  312. return;
  313. }
  314. if (progress && typeof progress.percent === "number") {
  315. this.hwProgress = this.normalizeProgress(progress.percent);
  316. }
  317. },
  318. // 更新腾讯线路进度条
  319. updateTxProgress(progressData) {
  320. if (progressData && typeof progressData.percent === "number") {
  321. this.txProgress = this.normalizeProgress(progressData.percent);
  322. }
  323. },
  324. //上传腾讯云Pcdn
  325. async uploadVideoToTxPcdn() {
  326. try {
  327. const file = this.fileList[0].raw;
  328. const data = await uploadObject(file, this.updateTxProgress,this.type);
  329. const lineBase = Vue.prototype.$runtimeConfig.VUE_APP_VIDEO_LINE_1 || process.env.VUE_APP_VIDEO_LINE_1 || '';
  330. const line_1 = `${lineBase}${data.urlPath}`;
  331. const urlPathWithoutFirstSlash = data.urlPath.startsWith('/') ? data.urlPath.substring(1) : data.urlPath;
  332. this.$emit("update:fileKey", urlPathWithoutFirstSlash);
  333. this.$emit("update:videoUrl", line_1);
  334. this.$emit("update:line_1", line_1);
  335. this.txProgress = 100;
  336. this.$message.success("线路一上传成功");
  337. } catch (error) {
  338. this.txProgress = 0;
  339. console.error('线路一上传失败', error);
  340. this.$message.error(error?.message || "线路一上传失败");
  341. }
  342. },
  343. // 上传火山云点播(线路二)
  344. async uploadVideoToHsy() {
  345. try {
  346. const file = this.fileList[0].raw;
  347. const data = await uploadToHSY(file, this.updateHwProgress, this.type);
  348. const videoBase = Vue.prototype.$runtimeConfig.VUE_APP_VIDEO_URL || process.env.VUE_APP_VIDEO_URL || '';
  349. if (!videoBase) {
  350. throw new Error('未配置火山云视频地址,请在「系统参数 → 前端配置」中填写')
  351. }
  352. const line_2 = `${videoBase}/${data.SourceInfo.FileName}`;
  353. this.$emit("update:line_2", line_2);
  354. this.hwProgress = 100;
  355. this.$message.success("线路二上传成功");
  356. } catch (error) {
  357. this.hwProgress = 0;
  358. console.error('线路二上传失败', error);
  359. const msg = error?.message || error?.extra?.message || "线路二上传失败";
  360. this.$message.error(msg);
  361. }
  362. },
  363. handleRemove(file, fileList) {
  364. console.log(file, fileList.length);
  365. },
  366. resetUpload() {
  367. // 重置内部状态
  368. this.txProgress = 0;
  369. this.hwProgress = 0;
  370. this.fileList = [];
  371. this.uploadKey++;
  372. this.localUploadType = this.uploadType;
  373. },
  374. /** 查询视频库列表 */
  375. getLibraryList() {
  376. this.libraryLoading = true;
  377. listVideoResource(this.libraryQueryParams).then(response => {
  378. this.libraryList = response.rows;
  379. this.libraryTotal = response.total;
  380. this.libraryLoading = false;
  381. });
  382. },
  383. /** 搜索视频库按钮操作 */
  384. handleLibraryQuery() {
  385. this.libraryQueryParams.pageNum = 1;
  386. this.getLibraryList();
  387. },
  388. /** 重置视频库查询按钮操作 */
  389. resetLibraryQuery() {
  390. this.libraryQueryParams = {
  391. pageNum: 1,
  392. pageSize: 10,
  393. resourceName: null
  394. };
  395. this.handleLibraryQuery();
  396. },
  397. /** 视频库选择行点击 */
  398. handleLibrarySelect(row) {
  399. this.selectedVideo = row;
  400. },
  401. /** 格式化视频时长 */
  402. formatDuration(seconds) {
  403. if (!seconds) return '00:00';
  404. const minutes = Math.floor(seconds / 60);
  405. const remainingSeconds = seconds % 60;
  406. return `${minutes.toString().padStart(2, '0')}:${remainingSeconds.toString().padStart(2, '0')}`;
  407. },
  408. /** 确认选择视频 */
  409. confirmVideoSelection() {
  410. if (!this.selectedVideo) {
  411. this.$message.warning("请选择一个视频");
  412. return;
  413. }
  414. // 更新组件内部数据
  415. this.$emit("update:fileName", this.selectedVideo.fileName);
  416. this.$emit("update:thumbnail", this.selectedVideo.thumbnail);
  417. this.$emit("update:line_1", this.selectedVideo.line1);
  418. this.$emit("update:line_2", this.selectedVideo.line2);
  419. this.$emit("update:line_3", this.selectedVideo.line3);
  420. this.$emit("update:fileSize", this.selectedVideo.fileSize);
  421. this.$emit("update:fileKey", this.selectedVideo.fileKey);
  422. this.$emit("update:uploadType", this.selectedVideo.uploadType);
  423. this.$emit("video-duration", this.selectedVideo.duration);
  424. // 设置预览URL
  425. this.$emit("update:videoUrl", this.selectedVideo.videoUrl);
  426. // 题目
  427. this.$emit("selectProjects", this.selectedVideo.projectIds)
  428. this.libraryOpen = false;
  429. },
  430. /** 取消视频选择 */
  431. cancelVideoSelection() {
  432. this.libraryOpen = false;
  433. this.selectedVideo = null;
  434. }
  435. }
  436. };
  437. </script>
  438. <style scoped>
  439. .upload_video {
  440. width: 100%;
  441. max-width: 560px;
  442. }
  443. .upload-toolbar {
  444. display: flex;
  445. flex-wrap: wrap;
  446. align-items: center;
  447. gap: 10px;
  448. }
  449. .upload_video ::v-deep .upload-picker {
  450. display: inline-block;
  451. line-height: normal;
  452. }
  453. .upload_video ::v-deep .upload-picker .el-upload {
  454. display: inline-block;
  455. margin: 0;
  456. border: none;
  457. }
  458. .upload-tip {
  459. margin: 8px 0 0;
  460. font-size: 12px;
  461. line-height: 1.5;
  462. color: #909399;
  463. }
  464. .upload-file-info {
  465. display: flex;
  466. align-items: center;
  467. gap: 6px;
  468. margin-top: 8px;
  469. padding: 8px 10px;
  470. font-size: 13px;
  471. line-height: 1.4;
  472. color: #606266;
  473. background: #f5f7fa;
  474. border: 1px solid #e4e7ed;
  475. border-radius: 4px;
  476. }
  477. .upload-file-info .el-icon-document {
  478. flex-shrink: 0;
  479. font-size: 16px;
  480. color: #409eff;
  481. }
  482. .upload-file-name {
  483. overflow: hidden;
  484. text-overflow: ellipsis;
  485. white-space: nowrap;
  486. }
  487. .progress-container {
  488. width: 100%;
  489. margin-top: 12px;
  490. }
  491. .progress-row {
  492. display: flex;
  493. align-items: center;
  494. justify-content: space-between;
  495. margin-bottom: 6px;
  496. }
  497. .progress-label {
  498. font-weight: bold;
  499. font-size: 13px;
  500. color: #303133;
  501. }
  502. .progress-percent {
  503. font-size: 12px;
  504. color: #606266;
  505. }
  506. .progress-bar {
  507. width: 100%;
  508. }
  509. .progress-bar ::v-deep .el-progress-bar__outer {
  510. background-color: #ebeef5;
  511. }
  512. .progress-bar ::v-deep .el-progress-bar__inner {
  513. transition: width 0.3s ease;
  514. }
  515. </style>
  516. <style>
  517. /* 视频库弹窗表格行可点击(弹窗 append-to-body,不宜 scoped) */
  518. .library-search {
  519. margin-bottom: 15px;
  520. }
  521. .el-table .el-table__row:hover {
  522. cursor: pointer;
  523. }
  524. </style>