| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473 |
- <template>
- <div class="video-post-container">
- <el-row :gutter="20">
- <el-col :md="14" :sm="24">
- <uploader-card
- v-if="uploadConfig"
- ref="videoUploader"
- :upload-config="uploadConfig"
- @before-upload="handleVideoAdded"
- @success="handleVideoSuccess"
- />
- <el-card class="box-card shadow-box" style="margin-top: 20px;">
- <div slot="header" class="clearfix">
- <span>视频封面</span>
- <span class="header-tip">(支持 JPG/PNG,小于 10MB)</span>
- </div>
- <div class="cover-upload-wrapper">
- <el-upload
- v-if="imgOssUrl !== ''"
- class="avatar-uploader"
- :action="imgOssUrl"
- :headers="imgHeaders"
- :data="imgData"
- :show-file-list="false"
- :before-upload="beforeCoverUpload"
- :on-success="handleManualCoverSuccess"
- >
- <div v-if="coverUrl" class="cover-preview">
- <img :src="coverUrl" class="avatar">
- <div class="cover-mask">
- <i class="el-icon-edit" />
- <span>更换封面</span>
- </div>
- </div>
- <i v-else class="el-icon-plus avatar-uploader-icon" />
- </el-upload>
- <div class="cover-info-text">
- <p>温馨提示:</p>
- <ul>
- <li>好的封面能大幅提升点击率</li>
- <li>系统已尝试为您自动截取视频首帧</li>
- </ul>
- </div>
- </div>
- </el-card>
- </el-col>
- <el-col :md="10" :sm="24">
- <el-card class="box-card shadow-box">
- <div slot="header" class="clearfix">
- <span>稿件信息</span>
- <el-button
- type="primary"
- size="small"
- style="float: right;"
- :loading="submitting"
- @click="submitForm"
- >立即发布</el-button>
- </div>
- <el-form ref="postForm" :model="form" :rules="rules" label-position="top">
- <el-form-item label="视频标题" prop="title">
- <el-input
- v-model="form.title"
- placeholder="给视频起个好名字吧"
- maxlength="50"
- show-word-limit
- />
- </el-form-item>
- <el-form-item label="视频简介" prop="description">
- <el-input
- v-model="form.description"
- type="textarea"
- :rows="4"
- placeholder="介绍一下你的视频吧..."
- />
- </el-form-item>
- <el-form-item v-if="!isEdit" label="选择分区" prop="categoryId">
- <el-row :gutter="10">
- <el-col :span="12">
- <el-select v-model="form.categoryPid" placeholder="一级分区" @change="handlePCategoryChange">
- <el-option
- v-for="item in pCategoryList"
- :key="item.value"
- :label="item.label"
- :value="item.value"
- />
- </el-select>
- </el-col>
- <el-col :span="12">
- <el-select v-model="form.categoryId" placeholder="二级分区">
- <el-option
- v-for="item in subCategoryList"
- :key="item.value"
- :label="item.label"
- :value="item.value"
- />
- </el-select>
- </el-col>
- </el-row>
- </el-form-item>
- <el-form-item v-if="!isEdit" label="标签" prop="tags">
- <el-select
- v-model="form.tags"
- multiple
- filterable
- allow-create
- default-first-option
- placeholder="按回车添加标签"
- style="width: 100%;"
- >
- <el-option
- v-for="tag in recommendTags"
- :key="tag"
- :label="tag"
- :value="tag"
- />
- </el-select>
- </el-form-item>
- <el-form-item v-if="!isEdit" label="权限设置">
- <el-radio-group v-model="form.scope">
- <el-radio label="1">私有</el-radio>
- <el-radio label="2">公开</el-radio>
- <el-radio label="3">VIP可见</el-radio>
- </el-radio-group>
- </el-form-item>
- </el-form>
- </el-card>
- </el-col>
- </el-row>
- </div>
- </template>
- <script>
- import UploaderCard from 'components/card/UploaderCard.vue'
- import { addVideoFile, updateVideoCover, updateVideoFile, videoRegion } from '@/api/vod'
- import { getVideoChannelInfo, getVideoCoverChannelInfo } from '@/api/file'
- export default {
- name: 'VideoPostPublish',
- components: { UploaderCard },
- props: {
- // 接收视频信息,null 为发布,非 null 为编辑
- videoInfo: {
- type: Object,
- default: null
- }
- },
- data() {
- return {
- uploadConfig: null,
- // 封面上传相关
- imgOssUrl: '',
- imgHeaders: { Authorization: '' },
- imgData: { clientSha256sum: null },
- coverUrl: null,
- // 分区数据
- pCategoryList: [],
- subCategoryList: [],
- categoryRawData: [],
- recommendTags: ['动画', '生活', '科技', '美食', '游戏'],
- // 表单数据
- submitting: false,
- form: {
- videoId: null,
- coverFileId: null,
- title: '',
- description: '',
- categoryPid: null,
- categoryId: null,
- tags: [],
- scope: '2'
- },
- // 校验规则
- rules: {
- title: [{ required: true, message: '标题不能为空', trigger: 'blur' }],
- categoryId: [{ required: true, message: '请选择完整的分区', trigger: 'change' }],
- tags: [{ type: 'array', required: true, message: '请至少添加一个标签', trigger: 'change' }]
- }
- }
- },
- computed: {
- isEdit() {
- return this.videoInfo !== null
- }
- },
- watch: {
- // 监听 props 变化,防止父组件异步获取数据后子组件不更新
- videoInfo: {
- handler(val) {
- if (val) {
- this.initEditData(val)
- }
- },
- immediate: true
- },
- // 监听原始分区数据加载完毕后,如果处于编辑模式,初始化二级分区列表
- categoryRawData(val) {
- if (val && val.length > 0 && this.form.categoryPid) {
- this.handlePCategoryChange(this.form.categoryPid, true)
- }
- }
- },
- created() {
- this.initCategoryData()
- this.initUploadConfig()
- },
- methods: {
- // 初始化编辑数据
- initEditData(info) {
- this.form = {
- videoId: info.videoId,
- coverFileId: info.coverFileId || null,
- title: info.title || '',
- description: info.description || '',
- categoryPid: info.categoryPid || null,
- categoryId: info.categoryId || null,
- tags: Array.isArray(info.tags) ? info.tags : (info.tags ? info.tags.split(',') : []),
- scope: String(info.scope || '2')
- }
- this.coverUrl = info.coverUrl || null
- // 如果数据已准备好,立即触发二级分区列表
- if (this.categoryRawData.length > 0) {
- this.handlePCategoryChange(this.form.categoryPid, true)
- }
- },
- // 1. 初始化数据
- async initCategoryData() {
- const res = await videoRegion()
- if (res.code === 0) {
- this.categoryRawData = res.data
- this.pCategoryList = res.data.map(item => ({ label: item.label, value: item.value }))
- }
- },
- initUploadConfig() {
- getVideoChannelInfo().then(resp => {
- if (resp.code === 0) {
- this.uploadConfig = resp.data
- this.uploadConfig.title = this.isEdit ? '重新上传视频文件' : '上传视频文件'
- this.uploadConfig.fileAttrs = { accept: 'video/*' }
- } else {
- this.$message.warning(resp.msg)
- }
- })
- getVideoCoverChannelInfo().then(res => {
- if (res.code === 0) {
- this.imgOssUrl = res.data.ossUrl
- this.imgHeaders.Authorization = 'Bearer ' + res.data.token
- } else {
- this.$message.warning(res.msg)
- }
- })
- },
- // 2. 视频上传组件回调
- handleVideoAdded(filename) {
- // 仅在发布模式下,上传视频后自动填充标题
- if (!this.isEdit && !this.form.title) {
- this.form.title = filename.replace(/\.[^/.]+$/, '').substring(0, 50)
- }
- },
- handleVideoSuccess(uploadResult) {
- if (this.isEdit) {
- const videoFile = {
- videoId: this.videoInfo.videoId,
- videoFileId: uploadResult.uploadId
- }
- updateVideoFile(videoFile).then(res => {
- this.$message.info(res.msg)
- }).catch(error => {
- this.$message.error(error.message)
- })
- } else {
- const { uploadId, file } = uploadResult
- // 自动截取视频封面
- this.generateLocalCover(file.file)
- addVideoFile({
- videoFileId: uploadId,
- filename: file.name
- }).then(resp => {
- if (resp.code === 0) {
- this.form.videoId = resp.data
- this.$message.info('视频处理完成')
- } else {
- this.$message.warning(resp.msg)
- }
- })
- }
- },
- generateLocalCover(rawFile) {
- const video = document.createElement('video')
- video.src = URL.createObjectURL(rawFile)
- video.muted = true
- video.play() // 必须播放或设置 currentTime 才能截取
- video.onloadeddata = () => {
- video.currentTime = 1 // 截取第 1 秒的画面
- }
- video.onseeked = () => {
- const canvas = document.createElement('canvas')
- canvas.width = video.videoWidth
- canvas.height = video.videoHeight
- canvas.getContext('2d').drawImage(video, 0, 0, canvas.width, canvas.height)
- canvas.toBlob((blob) => {
- // 这里的 blob 才是 handleAutoFrame 真正需要的参数
- this.handleAutoFrame(blob)
- URL.revokeObjectURL(video.src)
- }, 'image/jpeg', 0.9)
- }
- },
- // 处理自动截取的封面
- async handleAutoFrame(blobFile) {
- // 封装 FormData 手动上传截取的封面
- const formData = new FormData()
- formData.append('file', blobFile)
- formData.append('clientSha256sum', '')
- fetch(this.imgOssUrl, {
- method: 'POST',
- headers: this.imgHeaders,
- body: formData
- })
- .then(res => res.json())
- .then(json => {
- if (json.code === 0) {
- this.form.coverFileId = json.data.uploadId
- this.coverUrl = URL.createObjectURL(blobFile)
- }
- })
- },
- // 3. 封面手动上传逻辑
- beforeCoverUpload(file) {
- const isType = ['image/jpeg', 'image/png'].includes(file.type)
- const isLt10M = file.size / 1024 / 1024 < 10
- if (!isType) this.$message.error('封面只能是 JPG 或 PNG 格式!')
- if (!isLt10M) this.$message.error('封面大小不能超过 10MB!')
- if (isType && isLt10M) {
- this.imgData.clientSha256sum = ''
- }
- return isType && isLt10M
- },
- handleManualCoverSuccess(res, file) {
- if (res.code === 0) {
- if (this.isEdit) {
- const videoCover = {
- videoId: this.videoInfo.videoId,
- coverUrl: res.data.url,
- coverFileId: res.data.uploadId
- }
- updateVideoCover(videoCover).then(res => {
- this.$message.info(res.msg)
- }).catch(error => {
- this.$message.error(error.message)
- })
- } else {
- this.form.coverFileId = res.data.uploadId
- this.coverUrl = URL.createObjectURL(file.raw)
- this.$message.success('封面上传成功')
- }
- }
- },
- // 4. 分区联动
- handlePCategoryChange(pid, isInit = false) {
- if (!isInit) {
- this.form.categoryId = null
- }
- const parent = this.categoryRawData.find(i => i.value === pid)
- this.subCategoryList = parent ? parent.children : []
- },
- // 5. 最终提交
- submitForm() {
- this.$refs.postForm.validate((valid) => {
- if (!valid) return
- if (!this.form.videoId) {
- return this.$message.warning('请等待视频上传完成')
- }
- this.submitting = true
- // 向上抛出事件,带上模式标识
- this.$emit('video-publish', {
- data: this.form,
- mode: this.isEdit ? 'edit' : 'publish'
- })
- setTimeout(() => { this.submitting = false }, 1000)
- })
- }
- }
- }
- </script>
- <style scoped lang="scss">
- .video-post-container {
- padding: 20px;
- background-color: #f4f5f7;
- min-height: 100vh;
- .shadow-box {
- border: none;
- border-radius: 8px;
- box-shadow: 0 2px 12px 0 rgba(0,0,0,0.05);
- }
- .header-tip {
- font-size: 12px;
- color: #9499a0;
- margin-left: 10px;
- }
- }
- /* 封面上传样式 */
- .cover-upload-wrapper {
- display: flex;
- gap: 20px;
- align-items: center;
- .avatar-uploader {
- border: 1px dashed #d9d9d9;
- border-radius: 6px;
- cursor: pointer;
- overflow: hidden;
- width: 240px;
- height: 150px;
- &:hover { border-color: #409EFF; }
- }
- .cover-preview {
- position: relative;
- width: 100%;
- height: 100%;
- .avatar { width: 100%; height: 100%; object-fit: cover; }
- .cover-mask {
- position: absolute; top: 0; left: 0; width: 100%; height: 100%;
- background: rgba(0,0,0,0.5); color: #fff;
- display: flex; flex-direction: column; justify-content: center; align-items: center;
- opacity: 0; transition: opacity 0.3s;
- }
- &:hover .cover-mask { opacity: 1; }
- }
- .avatar-uploader-icon {
- font-size: 28px; color: #8c939d;
- width: 240px; height: 150px; line-height: 150px; text-align: center;
- }
- .cover-info-text {
- flex: 1;
- font-size: 13px; color: #61666d;
- ul { padding-left: 18px; margin-top: 8px; color: #9499a0; }
- }
- }
- </style>
|