VideoPostPublish.vue 14 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473
  1. <template>
  2. <div class="video-post-container">
  3. <el-row :gutter="20">
  4. <el-col :md="14" :sm="24">
  5. <uploader-card
  6. v-if="uploadConfig"
  7. ref="videoUploader"
  8. :upload-config="uploadConfig"
  9. @before-upload="handleVideoAdded"
  10. @success="handleVideoSuccess"
  11. />
  12. <el-card class="box-card shadow-box" style="margin-top: 20px;">
  13. <div slot="header" class="clearfix">
  14. <span>视频封面</span>
  15. <span class="header-tip">(支持 JPG/PNG,小于 10MB)</span>
  16. </div>
  17. <div class="cover-upload-wrapper">
  18. <el-upload
  19. v-if="imgOssUrl !== ''"
  20. class="avatar-uploader"
  21. :action="imgOssUrl"
  22. :headers="imgHeaders"
  23. :data="imgData"
  24. :show-file-list="false"
  25. :before-upload="beforeCoverUpload"
  26. :on-success="handleManualCoverSuccess"
  27. >
  28. <div v-if="coverUrl" class="cover-preview">
  29. <img :src="coverUrl" class="avatar">
  30. <div class="cover-mask">
  31. <i class="el-icon-edit" />
  32. <span>更换封面</span>
  33. </div>
  34. </div>
  35. <i v-else class="el-icon-plus avatar-uploader-icon" />
  36. </el-upload>
  37. <div class="cover-info-text">
  38. <p>温馨提示:</p>
  39. <ul>
  40. <li>好的封面能大幅提升点击率</li>
  41. <li>系统已尝试为您自动截取视频首帧</li>
  42. </ul>
  43. </div>
  44. </div>
  45. </el-card>
  46. </el-col>
  47. <el-col :md="10" :sm="24">
  48. <el-card class="box-card shadow-box">
  49. <div slot="header" class="clearfix">
  50. <span>稿件信息</span>
  51. <el-button
  52. type="primary"
  53. size="small"
  54. style="float: right;"
  55. :loading="submitting"
  56. @click="submitForm"
  57. >立即发布</el-button>
  58. </div>
  59. <el-form ref="postForm" :model="form" :rules="rules" label-position="top">
  60. <el-form-item label="视频标题" prop="title">
  61. <el-input
  62. v-model="form.title"
  63. placeholder="给视频起个好名字吧"
  64. maxlength="50"
  65. show-word-limit
  66. />
  67. </el-form-item>
  68. <el-form-item label="视频简介" prop="description">
  69. <el-input
  70. v-model="form.description"
  71. type="textarea"
  72. :rows="4"
  73. placeholder="介绍一下你的视频吧..."
  74. />
  75. </el-form-item>
  76. <el-form-item v-if="!isEdit" label="选择分区" prop="categoryId">
  77. <el-row :gutter="10">
  78. <el-col :span="12">
  79. <el-select v-model="form.categoryPid" placeholder="一级分区" @change="handlePCategoryChange">
  80. <el-option
  81. v-for="item in pCategoryList"
  82. :key="item.value"
  83. :label="item.label"
  84. :value="item.value"
  85. />
  86. </el-select>
  87. </el-col>
  88. <el-col :span="12">
  89. <el-select v-model="form.categoryId" placeholder="二级分区">
  90. <el-option
  91. v-for="item in subCategoryList"
  92. :key="item.value"
  93. :label="item.label"
  94. :value="item.value"
  95. />
  96. </el-select>
  97. </el-col>
  98. </el-row>
  99. </el-form-item>
  100. <el-form-item v-if="!isEdit" label="标签" prop="tags">
  101. <el-select
  102. v-model="form.tags"
  103. multiple
  104. filterable
  105. allow-create
  106. default-first-option
  107. placeholder="按回车添加标签"
  108. style="width: 100%;"
  109. >
  110. <el-option
  111. v-for="tag in recommendTags"
  112. :key="tag"
  113. :label="tag"
  114. :value="tag"
  115. />
  116. </el-select>
  117. </el-form-item>
  118. <el-form-item v-if="!isEdit" label="权限设置">
  119. <el-radio-group v-model="form.scope">
  120. <el-radio label="1">私有</el-radio>
  121. <el-radio label="2">公开</el-radio>
  122. <el-radio label="3">VIP可见</el-radio>
  123. </el-radio-group>
  124. </el-form-item>
  125. </el-form>
  126. </el-card>
  127. </el-col>
  128. </el-row>
  129. </div>
  130. </template>
  131. <script>
  132. import UploaderCard from 'components/card/UploaderCard.vue'
  133. import { addVideoFile, updateVideoCover, updateVideoFile, videoRegion } from '@/api/vod'
  134. import { getVideoChannelInfo, getVideoCoverChannelInfo } from '@/api/file'
  135. export default {
  136. name: 'VideoPostPublish',
  137. components: { UploaderCard },
  138. props: {
  139. // 接收视频信息,null 为发布,非 null 为编辑
  140. videoInfo: {
  141. type: Object,
  142. default: null
  143. }
  144. },
  145. data() {
  146. return {
  147. uploadConfig: null,
  148. // 封面上传相关
  149. imgOssUrl: '',
  150. imgHeaders: { Authorization: '' },
  151. imgData: { clientSha256sum: null },
  152. coverUrl: null,
  153. // 分区数据
  154. pCategoryList: [],
  155. subCategoryList: [],
  156. categoryRawData: [],
  157. recommendTags: ['动画', '生活', '科技', '美食', '游戏'],
  158. // 表单数据
  159. submitting: false,
  160. form: {
  161. videoId: null,
  162. coverFileId: null,
  163. title: '',
  164. description: '',
  165. categoryPid: null,
  166. categoryId: null,
  167. tags: [],
  168. scope: '2'
  169. },
  170. // 校验规则
  171. rules: {
  172. title: [{ required: true, message: '标题不能为空', trigger: 'blur' }],
  173. categoryId: [{ required: true, message: '请选择完整的分区', trigger: 'change' }],
  174. tags: [{ type: 'array', required: true, message: '请至少添加一个标签', trigger: 'change' }]
  175. }
  176. }
  177. },
  178. computed: {
  179. isEdit() {
  180. return this.videoInfo !== null
  181. }
  182. },
  183. watch: {
  184. // 监听 props 变化,防止父组件异步获取数据后子组件不更新
  185. videoInfo: {
  186. handler(val) {
  187. if (val) {
  188. this.initEditData(val)
  189. }
  190. },
  191. immediate: true
  192. },
  193. // 监听原始分区数据加载完毕后,如果处于编辑模式,初始化二级分区列表
  194. categoryRawData(val) {
  195. if (val && val.length > 0 && this.form.categoryPid) {
  196. this.handlePCategoryChange(this.form.categoryPid, true)
  197. }
  198. }
  199. },
  200. created() {
  201. this.initCategoryData()
  202. this.initUploadConfig()
  203. },
  204. methods: {
  205. // 初始化编辑数据
  206. initEditData(info) {
  207. this.form = {
  208. videoId: info.videoId,
  209. coverFileId: info.coverFileId || null,
  210. title: info.title || '',
  211. description: info.description || '',
  212. categoryPid: info.categoryPid || null,
  213. categoryId: info.categoryId || null,
  214. tags: Array.isArray(info.tags) ? info.tags : (info.tags ? info.tags.split(',') : []),
  215. scope: String(info.scope || '2')
  216. }
  217. this.coverUrl = info.coverUrl || null
  218. // 如果数据已准备好,立即触发二级分区列表
  219. if (this.categoryRawData.length > 0) {
  220. this.handlePCategoryChange(this.form.categoryPid, true)
  221. }
  222. },
  223. // 1. 初始化数据
  224. async initCategoryData() {
  225. const res = await videoRegion()
  226. if (res.code === 0) {
  227. this.categoryRawData = res.data
  228. this.pCategoryList = res.data.map(item => ({ label: item.label, value: item.value }))
  229. }
  230. },
  231. initUploadConfig() {
  232. getVideoChannelInfo().then(resp => {
  233. if (resp.code === 0) {
  234. this.uploadConfig = resp.data
  235. this.uploadConfig.title = this.isEdit ? '重新上传视频文件' : '上传视频文件'
  236. this.uploadConfig.fileAttrs = { accept: 'video/*' }
  237. } else {
  238. this.$message.warning(resp.msg)
  239. }
  240. })
  241. getVideoCoverChannelInfo().then(res => {
  242. if (res.code === 0) {
  243. this.imgOssUrl = res.data.ossUrl
  244. this.imgHeaders.Authorization = 'Bearer ' + res.data.token
  245. } else {
  246. this.$message.warning(res.msg)
  247. }
  248. })
  249. },
  250. // 2. 视频上传组件回调
  251. handleVideoAdded(filename) {
  252. // 仅在发布模式下,上传视频后自动填充标题
  253. if (!this.isEdit && !this.form.title) {
  254. this.form.title = filename.replace(/\.[^/.]+$/, '').substring(0, 50)
  255. }
  256. },
  257. handleVideoSuccess(uploadResult) {
  258. if (this.isEdit) {
  259. const videoFile = {
  260. videoId: this.videoInfo.videoId,
  261. videoFileId: uploadResult.uploadId
  262. }
  263. updateVideoFile(videoFile).then(res => {
  264. this.$message.info(res.msg)
  265. }).catch(error => {
  266. this.$message.error(error.message)
  267. })
  268. } else {
  269. const { uploadId, file } = uploadResult
  270. // 自动截取视频封面
  271. this.generateLocalCover(file.file)
  272. addVideoFile({
  273. videoFileId: uploadId,
  274. filename: file.name
  275. }).then(resp => {
  276. if (resp.code === 0) {
  277. this.form.videoId = resp.data
  278. this.$message.info('视频处理完成')
  279. } else {
  280. this.$message.warning(resp.msg)
  281. }
  282. })
  283. }
  284. },
  285. generateLocalCover(rawFile) {
  286. const video = document.createElement('video')
  287. video.src = URL.createObjectURL(rawFile)
  288. video.muted = true
  289. video.play() // 必须播放或设置 currentTime 才能截取
  290. video.onloadeddata = () => {
  291. video.currentTime = 1 // 截取第 1 秒的画面
  292. }
  293. video.onseeked = () => {
  294. const canvas = document.createElement('canvas')
  295. canvas.width = video.videoWidth
  296. canvas.height = video.videoHeight
  297. canvas.getContext('2d').drawImage(video, 0, 0, canvas.width, canvas.height)
  298. canvas.toBlob((blob) => {
  299. // 这里的 blob 才是 handleAutoFrame 真正需要的参数
  300. this.handleAutoFrame(blob)
  301. URL.revokeObjectURL(video.src)
  302. }, 'image/jpeg', 0.9)
  303. }
  304. },
  305. // 处理自动截取的封面
  306. async handleAutoFrame(blobFile) {
  307. // 封装 FormData 手动上传截取的封面
  308. const formData = new FormData()
  309. formData.append('file', blobFile)
  310. formData.append('clientSha256sum', '')
  311. fetch(this.imgOssUrl, {
  312. method: 'POST',
  313. headers: this.imgHeaders,
  314. body: formData
  315. })
  316. .then(res => res.json())
  317. .then(json => {
  318. if (json.code === 0) {
  319. this.form.coverFileId = json.data.uploadId
  320. this.coverUrl = URL.createObjectURL(blobFile)
  321. }
  322. })
  323. },
  324. // 3. 封面手动上传逻辑
  325. beforeCoverUpload(file) {
  326. const isType = ['image/jpeg', 'image/png'].includes(file.type)
  327. const isLt10M = file.size / 1024 / 1024 < 10
  328. if (!isType) this.$message.error('封面只能是 JPG 或 PNG 格式!')
  329. if (!isLt10M) this.$message.error('封面大小不能超过 10MB!')
  330. if (isType && isLt10M) {
  331. this.imgData.clientSha256sum = ''
  332. }
  333. return isType && isLt10M
  334. },
  335. handleManualCoverSuccess(res, file) {
  336. if (res.code === 0) {
  337. if (this.isEdit) {
  338. const videoCover = {
  339. videoId: this.videoInfo.videoId,
  340. coverUrl: res.data.url,
  341. coverFileId: res.data.uploadId
  342. }
  343. updateVideoCover(videoCover).then(res => {
  344. this.$message.info(res.msg)
  345. }).catch(error => {
  346. this.$message.error(error.message)
  347. })
  348. } else {
  349. this.form.coverFileId = res.data.uploadId
  350. this.coverUrl = URL.createObjectURL(file.raw)
  351. this.$message.success('封面上传成功')
  352. }
  353. }
  354. },
  355. // 4. 分区联动
  356. handlePCategoryChange(pid, isInit = false) {
  357. if (!isInit) {
  358. this.form.categoryId = null
  359. }
  360. const parent = this.categoryRawData.find(i => i.value === pid)
  361. this.subCategoryList = parent ? parent.children : []
  362. },
  363. // 5. 最终提交
  364. submitForm() {
  365. this.$refs.postForm.validate((valid) => {
  366. if (!valid) return
  367. if (!this.form.videoId) {
  368. return this.$message.warning('请等待视频上传完成')
  369. }
  370. this.submitting = true
  371. // 向上抛出事件,带上模式标识
  372. this.$emit('video-publish', {
  373. data: this.form,
  374. mode: this.isEdit ? 'edit' : 'publish'
  375. })
  376. setTimeout(() => { this.submitting = false }, 1000)
  377. })
  378. }
  379. }
  380. }
  381. </script>
  382. <style scoped lang="scss">
  383. .video-post-container {
  384. padding: 20px;
  385. background-color: #f4f5f7;
  386. min-height: 100vh;
  387. .shadow-box {
  388. border: none;
  389. border-radius: 8px;
  390. box-shadow: 0 2px 12px 0 rgba(0,0,0,0.05);
  391. }
  392. .header-tip {
  393. font-size: 12px;
  394. color: #9499a0;
  395. margin-left: 10px;
  396. }
  397. }
  398. /* 封面上传样式 */
  399. .cover-upload-wrapper {
  400. display: flex;
  401. gap: 20px;
  402. align-items: center;
  403. .avatar-uploader {
  404. border: 1px dashed #d9d9d9;
  405. border-radius: 6px;
  406. cursor: pointer;
  407. overflow: hidden;
  408. width: 240px;
  409. height: 150px;
  410. &:hover { border-color: #409EFF; }
  411. }
  412. .cover-preview {
  413. position: relative;
  414. width: 100%;
  415. height: 100%;
  416. .avatar { width: 100%; height: 100%; object-fit: cover; }
  417. .cover-mask {
  418. position: absolute; top: 0; left: 0; width: 100%; height: 100%;
  419. background: rgba(0,0,0,0.5); color: #fff;
  420. display: flex; flex-direction: column; justify-content: center; align-items: center;
  421. opacity: 0; transition: opacity 0.3s;
  422. }
  423. &:hover .cover-mask { opacity: 1; }
  424. }
  425. .avatar-uploader-icon {
  426. font-size: 28px; color: #8c939d;
  427. width: 240px; height: 150px; line-height: 150px; text-align: center;
  428. }
  429. .cover-info-text {
  430. flex: 1;
  431. font-size: 13px; color: #61666d;
  432. ul { padding-left: 18px; margin-top: 8px; color: #9499a0; }
  433. }
  434. }
  435. </style>