Ver Fonte

卓美马甲功能

yuhongqi há 17 horas atrás
pai
commit
08b650044a

+ 8 - 0
src/api/life/video.js

@@ -53,3 +53,11 @@ export function searchLifeVideoProduct(query) {
     params: query
   })
 }
+
+export function batchAddLifeVideo(data) {
+  return request({
+    url: '/life/video/batch',
+    method: 'post',
+    data: data
+  })
+}

+ 87 - 31
src/components/ImageUpload/index.vue

@@ -58,6 +58,16 @@ export default {
        type: Number,
       default: 500,
     },
+    // 宽高比,如 "16:9";为空则不校验
+    aspectRatio: {
+      type: String,
+      default: null,
+    },
+    // 宽高比允许误差(相对误差)
+    aspectTolerance: {
+      type: Number,
+      default: 0.05,
+    },
     // 文件类型, 例如['png', 'jpg', 'jpeg']
     fileType: {
       type: Array,
@@ -154,39 +164,85 @@ export default {
         );
         return false;
       }
+      // fileSize 默认 500 表示沿用历史 3MB;显式传入且 <=100 时按 MB 校验
+      const maxMb = (this.fileSize != null && this.fileSize > 0 && this.fileSize <= 100) ? this.fileSize : 3;
       return new Promise((resolve, reject) => {
-        if (file.size / 1024 / 1024 > 3) {
-          this.$message.error('上传的图片不能超过3MB');
+        const closeLoading = () => {
+          if (this.loading && typeof this.loading.close === 'function') {
+            this.loading.close();
+            this.loading = null;
+          }
+        };
+        const fail = (msg) => {
+          if (msg) {
+            this.$message.error(msg);
+          }
+          closeLoading();
           reject();
+        };
+        if (file.size / 1024 / 1024 > maxMb) {
+          fail(`上传的图片不能超过${maxMb}MB`);
           return;
         }
-        if (file.size / 1024 / 1024 > 1) {
-          const loadingInstance = Loading.service({ text: '图片内存过大正在压缩图片...' });
-          // 文件大于1MB时进行压缩
-          this.compressImage(file).then((compressedFile) => {
-            loadingInstance.close();
-            if (compressedFile.size / 1024 > 500) {
-              this.$message.error('图片压缩后仍大于500KB');
-              reject();
-            } else {
-              // this.$message.success(`图片压缩成功,最终质量为: ${this.finalQuality.toFixed(2)}`);
-              console.log(`图片压缩成功,最终质量为: ${this.finalQuality.toFixed(2)}`);
-              console.log(`最终内存大小为: ${(compressedFile.size/1024).toFixed(2)}KB`);
-              resolve(compressedFile);
-            }
-          }).catch((err) => {
-            loadingInstance.close();
-            console.error(err);
-            reject();
+        const afterRatioCheck = () => {
+          // 校验通过后再进入「上传中」态
+          this.loading = this.$loading({
+            lock: true,
+            text: "上传中",
+            background: "rgba(0, 0, 0, 0.7)",
           });
+          if (file.size / 1024 / 1024 > 1 && maxMb > 1) {
+            const loadingInstance = Loading.service({ text: '图片内存过大正在压缩图片...' });
+            this.compressImage(file).then((compressedFile) => {
+              loadingInstance.close();
+              if (compressedFile.size / 1024 / 1024 > maxMb) {
+                fail(`图片压缩后仍大于${maxMb}MB`);
+              } else {
+                console.log(`图片压缩成功,最终质量为: ${this.finalQuality.toFixed(2)}`);
+                console.log(`最终内存大小为: ${(compressedFile.size/1024).toFixed(2)}KB`);
+                resolve(compressedFile);
+              }
+            }).catch((err) => {
+              loadingInstance.close();
+              console.error(err);
+              fail();
+            });
+          } else {
+            resolve(file);
+          }
+        };
+        if (this.aspectRatio) {
+          const parts = String(this.aspectRatio).split(':');
+          const rw = Number(parts[0]);
+          const rh = Number(parts[1]);
+          if (!rw || !rh) {
+            afterRatioCheck();
+            return;
+          }
+          const expected = rw / rh;
+          const reader = new FileReader();
+          reader.onload = (e) => {
+            const img = new Image();
+            img.onload = () => {
+              const actual = img.width / img.height;
+              if (Math.abs(actual - expected) / expected > this.aspectTolerance) {
+                fail(`封面比例须为 ${this.aspectRatio}(当前约 ${(actual).toFixed(2)})`);
+                return;
+              }
+              afterRatioCheck();
+            };
+            img.onerror = () => {
+              fail('无法读取图片尺寸');
+            };
+            img.src = e.target.result;
+          };
+          reader.onerror = () => {
+            fail('无法读取图片');
+          };
+          reader.readAsDataURL(file);
         } else {
-          resolve(file);
+          afterRatioCheck();
         }
-        this.loading = this.$loading({
-          lock: true,
-          text: "上传中",
-          background: "rgba(0, 0, 0, 0.7)",
-        });
       });
       // if (this.fileSize) {
       //   const isLt = file.size / 1024  < this.fileSize;
@@ -195,7 +251,7 @@ export default {
       //     return false;
       //   }
       // }
-      
+
     },
     compressImage(file) {
       return new Promise((resolve, reject) => {
@@ -215,16 +271,16 @@ export default {
 
             let quality = 1; // 初始压缩质量
             let dataURL = canvas.toDataURL('image/jpeg', quality);
-            
+
             // 逐步压缩,直到图片大小小于500KB并且压缩质量不再降低
             while (dataURL.length / 1024 > 500 && quality > 0.1) {
               quality -= 0.01;
               dataURL = canvas.toDataURL('image/jpeg', quality);
             }
             this.finalQuality = quality; // 存储最终的压缩质量
-            
-            if (dataURL.length / 1024 > 500) {
-              reject(new Error('压缩后图片仍然大于500KB'));
+
+            if (dataURL.length / 1024 > 1000) {
+              reject(new Error('压缩后图片仍然大于1000KB'));
               return;
             }
 

+ 35 - 20
src/components/VideoUpload/index.vue

@@ -14,13 +14,13 @@
           :auto-upload="false"
           :key="uploadKey"
         >
-          <el-button slot="trigger" size="small" type="primary" >选取视频</el-button>
+          <el-button slot="trigger" size="small" type="primary" >本地选取</el-button>
           <el-button style="margin-left: 10px;" size="small" type="success" @click="submitUpload">点击上传</el-button>
           <!-- 仅当showControl为true时显示视频库选取按钮 -->
-          <el-button v-if="showControl" style="margin-left: 10px;" size="small" type="success" @click="openVideoLibrary">视频库选取</el-button>
+          <el-button v-if="showControl" style="margin-left: 10px;" size="small" type="warning" @click="openVideoLibrary">素材库选取</el-button>
           <!-- 线路一 -->
           <div class="progress-container">
-            <span class="progress-label">线路一</span>
+            <span class="progress-label">上传进度</span>
             <el-progress
               style="margin-top: 5px;"
               class="progress"
@@ -43,7 +43,8 @@
               status="success">
             </el-progress>
           </div>
-          <div slot="tip" class="el-upload__tip">只能上传mp4文件,且不超过500M</div>
+          <div v-if="uploadLoading" class="el-upload__tip" style="color:#E6A23C">处理中,请稍候…</div>
+          <div slot="tip" class="el-upload__tip">支持本地上传 mp4(≤500M)或从素材库选取</div>
         </el-upload>
       </div>
     </el-form-item>
@@ -123,12 +124,12 @@
 
 <script>
 import {getSignature, uploadHuaWeiObs, uploadHuaWeiVod} from "@/api/common";
-import {getThumbnail} from "@/api/course/userVideo";
+// import {getThumbnail} from "@/api/course/userVideo";
 import TcVod from "vod-js-sdk-v6";
 import { uploadObject } from "@/utils/cos.js";
 import { uploadToOBS } from "@/utils/obs.js";
 import Pagination from "@/components/Pagination";
-import { listVideoResource } from '@/api/course/videoResource';
+// import { listVideoResource } from '@/api/course/videoResource';
 
 export default {
   components: {
@@ -254,19 +255,29 @@ export default {
       if (this.fileList.length < 1) {
         return this.$message.error("请先选取视频,再进行上传");
       }
-      await this.getFirstThumbnail();
-      //同时上传个线路
-      await this.uploadVideoToTxPcdn();
-      await this.uploadVideoToHwObs();
-      this.$emit("update:fileName", this.fileList[0].name);
+      this.uploadLoading = true;
+      this.txProgress = 0;
+      try {
+        await this.getFirstThumbnail();
+        await this.uploadVideoToTxPcdn();
+        try {
+          await this.uploadVideoToHwObs();
+        } catch (e) {
+          console.warn('线路二上传失败', e);
+        }
+        this.$emit("update:fileName", this.fileList[0].name);
+        this.$emit("video-ready");
+      } finally {
+        this.uploadLoading = false;
+      }
     },
     //获取第一帧封面
     async getFirstThumbnail(){
       const file = this.fileList[0].raw;
-      getThumbnail(file).then(response => {
-        console.log("获取到第一帧为封面======>",response.url)
-        this.$emit("update:thumbnail", response.url);
-      })
+      // getThumbnail(file).then(response => {
+      //   console.log("获取到第一帧为封面======>",response.url)
+      //   this.$emit("update:thumbnail", response.url);
+      // })
     },
     //更新华为线路进度条
     updateHwProgress(progress) {
@@ -298,9 +309,11 @@ export default {
         this.$emit("update:videoUrl", line_1);
         this.$emit("update:line_1", line_1);
         // this.$emit("update:line_2", line_2);
+        this.$emit("change");
         this.$message.success("线路一上传成功");
       } catch (error) {
         this.$message.error("线路一上传失败");
+        throw error;
       }
     },
     //上传华为云Obs
@@ -320,6 +333,7 @@ export default {
         this.$message.success("线路二上传成功");
       } catch (error) {
         this.$message.error("线路二上传失败");
+        throw error;
       }
     },
     handleRemove(file, fileList) {
@@ -335,11 +349,11 @@ export default {
     /** 查询视频库列表 */
     getLibraryList() {
       this.libraryLoading = true;
-      listVideoResource(this.libraryQueryParams).then(response => {
-        this.libraryList = response.rows;
-        this.libraryTotal = response.total;
-        this.libraryLoading = false;
-      });
+      // listVideoResource(this.libraryQueryParams).then(response => {
+      //   this.libraryList = response.rows;
+      //   this.libraryTotal = response.total;
+      //   this.libraryLoading = false;
+      // });
     },
     /** 搜索视频库按钮操作 */
     handleLibraryQuery() {
@@ -391,6 +405,7 @@ export default {
 
       // 题目
       this.$emit("selectProjects", this.selectedVideo.projectIds)
+      this.$emit("video-ready");
 
       this.libraryOpen = false;
     },

+ 373 - 20
src/views/life/video/index.vue

@@ -6,6 +6,11 @@
           <el-option v-for="v in vestOptions" :key="v.id" :label="v.nickName + '(' + v.id + ')'" :value="v.id"/>
         </el-select>
       </el-form-item>
+      <el-form-item label="内容分类" prop="category">
+        <el-select v-model="queryParams.category" clearable placeholder="全部" size="small" style="width:140px">
+          <el-option v-for="d in categoryOptions" :key="d.dictValue" :label="d.dictLabel" :value="d.dictLabel"/>
+        </el-select>
+      </el-form-item>
       <el-form-item label="关键字" prop="keyword">
         <el-input v-model="queryParams.keyword" placeholder="标题/ID/文件名" clearable size="small" @keyup.enter.native="handleQuery"/>
       </el-form-item>
@@ -25,10 +30,17 @@
       <el-col :span="1.5">
         <el-button type="primary" plain icon="el-icon-plus" size="mini" @click="handleAdd" v-hasPermi="['life:video:add']">新增</el-button>
       </el-col>
+      <el-col :span="1.5">
+        <el-button type="success" plain icon="el-icon-document-copy" size="mini" @click="handleBatchAdd" v-hasPermi="['life:video:add']">批量添加</el-button>
+      </el-col>
+      <el-col :span="1.5">
+        <el-button type="danger" plain icon="el-icon-delete" size="mini" :disabled="multiple" @click="handleBatchDelete" v-hasPermi="['life:video:remove']">批量删除</el-button>
+      </el-col>
       <right-toolbar :showSearch.sync="showSearch" @queryTable="getList"></right-toolbar>
     </el-row>
 
-    <el-table border v-loading="loading" :data="videoList">
+    <el-table border v-loading="loading" :data="videoList" @selection-change="handleSelectionChange">
+      <el-table-column type="selection" width="55" align="center"/>
       <el-table-column label="视频ID" align="center" prop="videoId" width="90"/>
       <el-table-column label="封面" align="center" width="80">
         <template slot-scope="scope">
@@ -36,9 +48,15 @@
         </template>
       </el-table-column>
       <el-table-column label="标题" align="center" prop="title" show-overflow-tooltip/>
+      <el-table-column label="内容分类" align="center" prop="category" width="100"/>
       <el-table-column label="文件名" align="center" prop="fileName" show-overflow-tooltip/>
       <el-table-column label="马甲ID" align="center" prop="vestId" width="80"/>
       <el-table-column label="时长(秒)" align="center" prop="duration" width="90"/>
+      <el-table-column label="上传时间" align="center" prop="createTime" width="160">
+        <template slot-scope="scope">
+          <span>{{ parseTime(scope.row.createTime) }}</span>
+        </template>
+      </el-table-column>
       <el-table-column label="上架" align="center" width="80">
         <template slot-scope="scope">
           <el-tag v-if="scope.row.isShow === 1" type="success">是</el-tag>
@@ -69,13 +87,18 @@
 
     <pagination v-show="total>0" :total="total" :page.sync="queryParams.pageNum" :limit.sync="queryParams.pageSize" @pagination="getList"/>
 
-    <el-dialog :title="title" :visible.sync="open" width="720px" append-to-body>
+    <el-dialog :title="title" :visible.sync="open" width="780px" append-to-body :before-close="handleDialogClose">
       <el-form ref="form" :model="form" :rules="rules" label-width="110px">
         <el-form-item label="马甲号" prop="vestId">
           <el-select v-model="form.vestId" filterable placeholder="请选择马甲号" style="width:100%">
             <el-option v-for="v in vestOptions" :key="v.id" :label="v.nickName + '(' + v.id + ')'" :value="v.id"/>
           </el-select>
         </el-form-item>
+        <el-form-item label="内容分类" prop="category">
+          <el-select v-model="form.category" placeholder="请选择内容分类" style="width:100%">
+            <el-option v-for="d in categoryOptions" :key="d.dictValue" :label="d.dictLabel" :value="d.dictLabel"/>
+          </el-select>
+        </el-form-item>
         <el-form-item label="视频标题" prop="title">
           <el-input v-model="form.title" maxlength="20" show-word-limit placeholder="不超过20字"/>
         </el-form-item>
@@ -83,13 +106,37 @@
           <el-input v-model="form.fileName" placeholder="上传文件名或标识"/>
         </el-form-item>
         <el-form-item label="封面" prop="coverUrl">
-          <image-upload v-model="form.coverUrl" :limit="1"/>
+          <image-upload v-model="form.coverUrl" :limit="1" :file-size="2" aspect-ratio="16:9"/>
+          <div style="color:#909399;font-size:12px;line-height:1.4;margin-top:4px">比例 16:9,大小不超过 2MB</div>
         </el-form-item>
-        <el-form-item label="视频地址" prop="videoUrl">
-          <el-input v-model="form.videoUrl" placeholder="mp4 地址(素材库上传后粘贴)"/>
+        <video-upload
+          :type="3"
+          :show-control="true"
+          :videoUrl.sync="form.videoUrl"
+          :fileName.sync="form.fileName"
+          @video-ready="onVideoReady"
+          @change="onVideoReady"
+          @video-duration="onVideoDuration"
+          ref="videoUpload"
+        />
+        <el-form-item v-if="form.videoUrl" label="上传时间">
+          <el-input :value="uploadTimeDisplay" disabled style="width:220px"/>
         </el-form-item>
         <el-form-item label="时长(秒)" prop="duration">
           <el-input-number v-model="form.duration" :min="0" :controls="false"/>
+          <span style="margin-left:8px;color:#909399;font-size:12px">上传/选取后自动读取,可手动修改</span>
+        </el-form-item>
+        <el-form-item label="播放量" prop="views">
+          <el-input-number v-model="form.views" :min="0" :controls="false" style="width:160px"/>
+        </el-form-item>
+        <el-form-item label="点赞数" prop="likes">
+          <el-input-number v-model="form.likes" :min="0" :controls="false" style="width:160px"/>
+        </el-form-item>
+        <el-form-item label="收藏数" prop="favoriteNum">
+          <el-input-number v-model="form.favoriteNum" :min="0" :controls="false" style="width:160px"/>
+        </el-form-item>
+        <el-form-item label="分享数" prop="shareNum">
+          <el-input-number v-model="form.shareNum" :min="0" :controls="false" style="width:160px"/>
         </el-form-item>
         <el-form-item label="是否上架" prop="isShow">
           <el-radio-group v-model="form.isShow">
@@ -103,8 +150,15 @@
             <el-radio :label="0">否</el-radio>
           </el-radio-group>
         </el-form-item>
-        <el-form-item label="关联商品">
-          <el-button type="primary" size="mini" @click="openProductDialog">选择商品</el-button>
+        <el-form-item label="是否关联商品" prop="isProduct">
+          <el-radio-group v-model="form.isProduct" @change="onIsProductChange">
+            <el-radio :label="1">是</el-radio>
+            <el-radio :label="0">否</el-radio>
+          </el-radio-group>
+        </el-form-item>
+        <el-form-item v-show="form.isProduct === 1" label="关联商品">
+          <el-button type="primary" size="mini" :disabled="selectedProducts.length >= 1" @click="openProductDialog">选择商品</el-button>
+          <span style="margin-left:8px;color:#909399;font-size:12px">最多关联 1 个商品</span>
           <el-table :data="selectedProducts" border size="mini" style="margin-top:8px">
             <el-table-column label="商品ID" prop="productId" width="90"/>
             <el-table-column label="名称" prop="productName" show-overflow-tooltip/>
@@ -124,6 +178,75 @@
       </div>
     </el-dialog>
 
+    <el-dialog title="批量添加视频" :visible.sync="batchOpen" width="860px" append-to-body top="5vh">
+      <el-form label-width="90px" size="small">
+        <el-form-item label="马甲号" required>
+          <el-select v-model="batchVestId" filterable placeholder="请选择马甲号" style="width:320px">
+            <el-option v-for="v in vestOptions" :key="v.id" :label="v.nickName + '(' + v.id + ')'" :value="v.id"/>
+          </el-select>
+          <el-button type="primary" size="mini" style="margin-left:12px" @click="addBatchRow">添加一条</el-button>
+          <span style="margin-left:8px;color:#909399;font-size:12px">每条支持本地上传或素材库选取</span>
+        </el-form-item>
+      </el-form>
+      <div class="batch-list" style="max-height:62vh;overflow:auto;padding-right:4px">
+        <div
+          v-for="(row, index) in batchRows"
+          :key="row._key"
+          style="border:1px solid #EBEEF5;border-radius:4px;padding:12px 12px 4px;margin-bottom:12px;background:#FAFAFA"
+        >
+          <div style="display:flex;justify-content:space-between;align-items:center;margin-bottom:8px">
+            <span style="font-weight:600">第 {{ index + 1 }} 条</span>
+            <el-button type="text" size="mini" style="color:#F56C6C" @click="removeBatchRow(index)">删除</el-button>
+          </div>
+          <el-form label-width="90px" size="small">
+            <el-form-item label="标题" required>
+              <el-input v-model="row.title" maxlength="20" show-word-limit placeholder="不超过20字"/>
+            </el-form-item>
+            <el-form-item label="内容分类" required>
+              <el-select v-model="row.category" placeholder="请选择" style="width:220px">
+                <el-option v-for="d in categoryOptions" :key="d.dictValue" :label="d.dictLabel" :value="d.dictLabel"/>
+              </el-select>
+            </el-form-item>
+            <el-form-item label="封面" required>
+              <image-upload v-model="row.coverUrl" :limit="1" :file-size="2" aspect-ratio="16:9"/>
+              <div style="color:#909399;font-size:12px">比例 16:9,大小不超过 2MB</div>
+            </el-form-item>
+            <video-upload
+              :type="3"
+              :show-control="true"
+              :videoUrl.sync="row.videoUrl"
+              :fileName.sync="row.fileName"
+              @video-ready="onBatchVideoReady(index)"
+              @change="onBatchVideoReady(index)"
+              @video-duration="d => onBatchVideoDuration(index, d)"
+            />
+            <el-form-item v-if="row.videoUrl" label="上传时间">
+              <el-input :value="row.uploadTimeDisplay" disabled style="width:220px"/>
+            </el-form-item>
+            <el-form-item label="时长(秒)">
+              <el-input-number v-model="row.duration" :min="0" :controls="false"/>
+              <span style="margin-left:8px;color:#909399;font-size:12px">自动读取,可改</span>
+            </el-form-item>
+            <el-form-item label="播放量">
+              <el-input-number v-model="row.views" :min="0" :controls="false" style="width:120px"/>
+            </el-form-item>
+            <el-form-item label="赞/藏/享">
+              <el-input-number v-model="row.likes" :min="0" :controls="false" style="width:90px"/>
+              <el-input-number v-model="row.favoriteNum" :min="0" :controls="false" style="width:90px;margin:0 8px"/>
+              <el-input-number v-model="row.shareNum" :min="0" :controls="false" style="width:90px"/>
+            </el-form-item>
+            <el-form-item label="是否上架">
+              <el-switch v-model="row.isShow" :active-value="1" :inactive-value="0"/>
+            </el-form-item>
+          </el-form>
+        </div>
+      </div>
+      <div slot="footer" class="dialog-footer">
+        <el-button type="primary" :loading="batchSubmitting" @click="submitBatch">确 定</el-button>
+        <el-button @click="batchOpen = false">取 消</el-button>
+      </div>
+    </el-dialog>
+
     <el-dialog title="选择商品" :visible.sync="productOpen" width="780px" append-to-body>
       <el-form :inline="true" size="small">
         <el-form-item label="商品名称">
@@ -145,8 +268,13 @@
         <el-table-column label="库存" prop="stock" width="80"/>
         <el-table-column label="操作" width="100">
           <template slot-scope="scope">
-            <el-button type="text" size="mini" :disabled="isSelected(scope.row.productId)" @click="pickProduct(scope.row)">
-              {{ isSelected(scope.row.productId) ? '已选' : '选择' }}
+            <el-button
+              type="text"
+              size="mini"
+              :disabled="isSelected(scope.row.productId) || (selectedProducts.length >= 1 && !isSelected(scope.row.productId))"
+              @click="pickProduct(scope.row)"
+            >
+              {{ isSelected(scope.row.productId) ? '已选' : (selectedProducts.length >= 1 ? '已满' : '选择') }}
             </el-button>
           </template>
         </el-table-column>
@@ -158,33 +286,42 @@
 
 <script>
 import { listLifeVest } from '@/api/life/vest'
-import { listLifeVideo, getLifeVideo, addLifeVideo, updateLifeVideo, delLifeVideo, shelfLifeVideo, searchLifeVideoProduct } from '@/api/life/video'
+import { listLifeVideo, getLifeVideo, addLifeVideo, updateLifeVideo, delLifeVideo, shelfLifeVideo, searchLifeVideoProduct, batchAddLifeVideo } from '@/api/life/video'
+import VideoUpload from '@/components/VideoUpload/index.vue'
 
 export default {
   name: 'LifeVideo',
+  components: { VideoUpload },
   data() {
     return {
       loading: true,
       showSearch: true,
       total: 0,
       videoList: [],
+      ids: [],
+      multiple: true,
       vestOptions: [],
+      categoryOptions: [],
       title: '',
       open: false,
+      uploadTimeDisplay: '',
       queryParams: {
         pageNum: 1,
         pageSize: 10,
         vestId: null,
+        category: null,
         keyword: null,
         isShow: null
       },
       form: {},
       rules: {
         vestId: [{ required: true, message: '请选择马甲号', trigger: 'change' }],
+        category: [{ required: true, message: '请选择内容分类', trigger: 'change' }],
         title: [{ required: true, message: '标题不能为空', trigger: 'blur' }],
         coverUrl: [{ required: true, message: '封面不能为空', trigger: 'change' }],
-        videoUrl: [{ required: true, message: '视频地址不能为空', trigger: 'blur' }],
-        isShow: [{ required: true, message: '请选择是否上架', trigger: 'change' }]
+        videoUrl: [{ required: true, message: '请上传或选择视频', trigger: 'change' }],
+        isShow: [{ required: true, message: '请选择是否上架', trigger: 'change' }],
+        isProduct: [{ required: true, message: '请选择是否关联商品', trigger: 'change' }]
       },
       selectedProducts: [],
       productOpen: false,
@@ -195,22 +332,37 @@ export default {
         pageNum: 1,
         pageSize: 10,
         productName: null
-      }
+      },
+      batchOpen: false,
+      batchVestId: null,
+      batchRows: [],
+      batchSubmitting: false
     }
   },
   created() {
     if (this.$route.query.vestId) {
       this.queryParams.vestId = Number(this.$route.query.vestId)
     }
+    this.loadCategories()
     this.loadVests()
     this.getList()
   },
   methods: {
+    loadCategories() {
+      this.getDicts('life_video_category').then(res => {
+        this.categoryOptions = res.data || []
+      })
+    },
     loadVests() {
-      listLifeVest({ pageNum: 1, pageSize: 500 }).then(res => {
+      // 仅启用中的马甲出现在下拉
+      listLifeVest({ pageNum: 1, pageSize: 500, status: 1 }).then(res => {
         this.vestOptions = res.rows || []
       })
     },
+    defaultCategory() {
+      const def = this.categoryOptions.find(d => d.isDefault === 'Y') || this.categoryOptions[0]
+      return def ? def.dictLabel : '默认分类'
+    },
     getList() {
       this.loading = true
       listLifeVideo(this.queryParams).then(res => {
@@ -220,24 +372,90 @@ export default {
       })
     },
     cancel() {
-      this.open = false
-      this.reset()
+      this.handleDialogClose(() => {
+        this.open = false
+      })
+    },
+    /** 已填写内容时取消需二次确认 */
+    isFormDirty() {
+      const f = this.form || {}
+      return !!(f.title || f.coverUrl || f.videoUrl || f.fileName
+        || (f.likes && f.likes > 0) || (f.favoriteNum && f.favoriteNum > 0) || (f.shareNum && f.shareNum > 0)
+        || (this.selectedProducts && this.selectedProducts.length))
+    },
+    handleDialogClose(done) {
+      const finish = () => {
+        this.reset()
+        if (typeof done === 'function') {
+          done()
+        } else {
+          this.open = false
+        }
+      }
+      if (!this.isFormDirty()) {
+        finish()
+        return
+      }
+      this.$confirm('已填写内容,确认放弃并关闭?', '提示', { type: 'warning' }).then(() => {
+        finish()
+      }).catch(() => {})
+    },
+    formatNow() {
+      const d = new Date()
+      const pad = n => (n < 10 ? '0' + n : '' + n)
+      return d.getFullYear() + '-' + pad(d.getMonth() + 1) + '-' + pad(d.getDate())
+        + ' ' + pad(d.getHours()) + ':' + pad(d.getMinutes()) + ':' + pad(d.getSeconds())
+    },
+    /** 本地上传完成 / 素材库选完后展示上传时间 */
+    onVideoReady() {
+      if (!this.form.videoUrl) {
+        return
+      }
+      if (this.form.videoId) {
+        this.uploadTimeDisplay = this.parseTime(this.form.createTime) || this.formatNow()
+      } else {
+        this.uploadTimeDisplay = this.formatNow()
+      }
+      if (this.form.duration == null) {
+        this.form.duration = 0
+      }
+      this.$nextTick(() => {
+        this.$refs.form && this.$refs.form.validateField('videoUrl')
+      })
+    },
+    onVideoDuration(duration) {
+      const d = Number(duration)
+      if (!isNaN(d) && d >= 0) {
+        this.form.duration = Math.round(d)
+      }
     },
     reset() {
       this.form = {
         videoId: null,
         vestId: this.queryParams.vestId || null,
+        category: this.defaultCategory(),
         title: null,
         fileName: null,
         coverUrl: null,
         videoUrl: null,
         duration: 0,
+        views: 0,
+        likes: 0,
+        favoriteNum: 0,
+        shareNum: 0,
         isShow: 1,
         isHot: 0,
+        isProduct: 0,
         productIds: []
       }
+      this.uploadTimeDisplay = ''
       this.selectedProducts = []
       this.resetForm('form')
+      this.$nextTick(() => {
+        if (this.$refs.videoUpload && this.$refs.videoUpload.resetUpload) {
+          this.$refs.videoUpload.resetUpload()
+        }
+      })
     },
     handleQuery() {
       this.queryParams.pageNum = 1
@@ -246,6 +464,7 @@ export default {
     resetQuery() {
       this.resetForm('queryForm')
       this.queryParams.vestId = null
+      this.queryParams.category = null
       this.handleQuery()
     },
     handleAdd() {
@@ -253,14 +472,115 @@ export default {
       this.open = true
       this.title = '新增生活号视频'
     },
+    handleBatchAdd() {
+      this.batchVestId = this.queryParams.vestId || null
+      this.batchRows = [this.newBatchRow()]
+      this.batchOpen = true
+    },
+    newBatchRow() {
+      return {
+        _key: 'b_' + Date.now() + '_' + Math.random().toString(36).slice(2, 8),
+        title: '',
+        category: this.defaultCategory(),
+        coverUrl: null,
+        videoUrl: null,
+        fileName: null,
+        uploadTimeDisplay: '',
+        duration: 0,
+        views: 0,
+        likes: 0,
+        favoriteNum: 0,
+        shareNum: 0,
+        isShow: 1,
+        isHot: 0
+      }
+    },
+    addBatchRow() {
+      if (this.batchRows.length >= 50) {
+        this.$message.warning('单次最多批量新增50条')
+        return
+      }
+      this.batchRows.push(this.newBatchRow())
+    },
+    removeBatchRow(index) {
+      this.batchRows.splice(index, 1)
+    },
+    onBatchVideoReady(index) {
+      const row = this.batchRows[index]
+      if (!row || !row.videoUrl) {
+        return
+      }
+      row.uploadTimeDisplay = this.formatNow()
+      if (row.duration == null) {
+        row.duration = 0
+      }
+    },
+    onBatchVideoDuration(index, duration) {
+      const row = this.batchRows[index]
+      if (!row) return
+      const d = Number(duration)
+      if (!isNaN(d) && d >= 0) {
+        row.duration = Math.round(d)
+      }
+    },
+    submitBatch() {
+      if (!this.batchVestId) {
+        this.$message.warning('请选择马甲号')
+        return
+      }
+      if (!this.batchRows.length) {
+        this.$message.warning('请至少添加一条视频')
+        return
+      }
+      for (let i = 0; i < this.batchRows.length; i++) {
+        const row = this.batchRows[i]
+        if (!row.title || !row.coverUrl || !row.videoUrl || !row.category) {
+          this.$message.warning(`第 ${i + 1} 条:标题、封面、视频、分类均为必填(视频请本地上传或素材库选取)`)
+          return
+        }
+      }
+      const payload = this.batchRows.map(row => ({
+        vestId: this.batchVestId,
+        title: row.title,
+        category: row.category,
+        coverUrl: row.coverUrl,
+        videoUrl: row.videoUrl,
+        fileName: row.fileName,
+        duration: row.duration || 0,
+        views: row.views || 0,
+        likes: row.likes || 0,
+        favoriteNum: row.favoriteNum || 0,
+        shareNum: row.shareNum || 0,
+        isShow: row.isShow,
+        isHot: row.isHot || 0,
+        productIds: []
+      }))
+      this.batchSubmitting = true
+      batchAddLifeVideo(payload).then(() => {
+        this.msgSuccess('批量添加成功')
+        this.batchOpen = false
+        this.getList()
+      }).finally(() => {
+        this.batchSubmitting = false
+      })
+    },
     handleUpdate(row) {
       this.reset()
       getLifeVideo(row.videoId).then(res => {
         this.form = Object.assign({}, res.data)
+        if (!this.form.category) {
+          this.form.category = this.defaultCategory()
+        }
+        this.form.likes = this.form.likes || 0
+        this.form.favoriteNum = this.form.favoriteNum || 0
+        this.form.shareNum = this.form.shareNum || 0
+        this.form.views = this.form.views || 0
+        this.form.duration = this.form.duration == null ? 0 : this.form.duration
+        this.uploadTimeDisplay = this.parseTime(this.form.createTime) || ''
         const ids = res.data.productIds || []
         this.form.productIds = ids
+        this.form.isProduct = ids.length > 0 ? 1 : 0
         this.selectedProducts = ids.map(id => ({ productId: id, productName: '商品#' + id, price: '-', stock: '-' }))
-        // 回填商品详情
         if (ids.length) {
           searchLifeVideoProduct({ pageNum: 1, pageSize: 100 }).then(p => {
             const map = {}
@@ -275,7 +595,17 @@ export default {
     submitForm() {
       this.$refs['form'].validate(valid => {
         if (!valid) return
-        this.form.productIds = this.selectedProducts.map(p => p.productId)
+        if (this.form.isProduct === 1 && this.selectedProducts.length === 0) {
+          this.$message.warning('请选择关联商品')
+          return
+        }
+        if (this.selectedProducts.length > 1) {
+          this.$message.warning('最多关联一个商品')
+          return
+        }
+        this.form.productIds = this.form.isProduct === 1
+          ? this.selectedProducts.map(p => p.productId)
+          : []
         const req = this.form.videoId != null ? updateLifeVideo(this.form) : addLifeVideo(this.form)
         req.then(() => {
           this.msgSuccess('操作成功')
@@ -291,6 +621,10 @@ export default {
         this.getList()
       })
     },
+    handleSelectionChange(selection) {
+      this.ids = selection.map(item => item.videoId)
+      this.multiple = !selection.length
+    },
     handleDelete(row) {
       this.$confirm('删除后不可恢复,确认删除该视频?', '警告', { type: 'warning' }).then(() => {
         return delLifeVideo(row.videoId)
@@ -299,6 +633,18 @@ export default {
         this.msgSuccess('删除成功')
       }).catch(() => {})
     },
+    handleBatchDelete() {
+      if (!this.ids.length) {
+        this.$message.warning('请选择要删除的视频')
+        return
+      }
+      this.$confirm('删除后不可恢复,确认批量删除选中的 ' + this.ids.length + ' 条视频?', '警告', { type: 'warning' }).then(() => {
+        return delLifeVideo(this.ids.join(','))
+      }).then(() => {
+        this.getList()
+        this.msgSuccess('删除成功')
+      }).catch(() => {})
+    },
     openProductDialog() {
       this.productOpen = true
       this.searchProduct()
@@ -315,9 +661,16 @@ export default {
       return this.selectedProducts.some(p => p.productId === productId)
     },
     pickProduct(row) {
-      if (!this.isSelected(row.productId)) {
-        this.selectedProducts.push(row)
+      if (this.isSelected(row.productId)) {
+        return
+      }
+      if (this.selectedProducts.length >= 1) {
+        this.$message.warning('最多关联一个商品,请先删除已选商品')
+        return
       }
+      this.selectedProducts.push(row)
+      this.form.isProduct = 1
+      this.productOpen = false
     },
     removeProduct(index) {
       this.selectedProducts.splice(index, 1)