소스 검색

卓美马甲功能

yuhongqi 1 일 전
부모
커밋
bdee6fb6d5
5개의 변경된 파일433개의 추가작업 그리고 83개의 파일을 삭제
  1. 8 0
      src/api/life/video.js
  2. 68 20
      src/components/ImageUpload/index.vue
  3. 50 47
      src/components/VideoUpload/index.vue
  4. 306 16
      src/views/life/video/index.vue
  5. 1 0
      src/views/live/liveVideo/index.vue

+ 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
+  })
+}

+ 68 - 20
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,33 +164,71 @@ 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');
+        if (file.size / 1024 / 1024 > maxMb) {
+          this.$message.error(`上传的图片不能超过${maxMb}MB`);
           reject();
           return;
         }
-        if (file.size / 1024 / 1024 > 1) {
-          const loadingInstance = Loading.service({ text: '图片内存过大正在压缩图片...' });
-          // 文件大于1MB时进行压缩
-          this.compressImage(file).then((compressedFile) => {
-            loadingInstance.close();
-            if (compressedFile.size / 1024 > 1000) {
-              this.$message.error('图片压缩后仍大于1000KB');
+        const afterRatioCheck = () => {
+          if (file.size / 1024 / 1024 > 1 && maxMb > 1) {
+            const loadingInstance = Loading.service({ text: '图片内存过大正在压缩图片...' });
+            // 文件大于1MB时进行压缩
+            this.compressImage(file).then((compressedFile) => {
+              loadingInstance.close();
+              if (compressedFile.size / 1024 / 1024 > maxMb) {
+                this.$message.error(`图片压缩后仍大于${maxMb}MB`);
+                reject();
+              } else {
+                console.log(`图片压缩成功,最终质量为: ${this.finalQuality.toFixed(2)}`);
+                console.log(`最终内存大小为: ${(compressedFile.size/1024).toFixed(2)}KB`);
+                resolve(compressedFile);
+              }
+            }).catch((err) => {
+              loadingInstance.close();
+              console.error(err);
               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);
+            });
+          } 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) {
+                this.$message.error(`封面比例须为 ${this.aspectRatio}(当前约 ${(actual).toFixed(2)})`);
+                reject();
+                return;
+              }
+              afterRatioCheck();
+            };
+            img.onerror = () => {
+              this.$message.error('无法读取图片尺寸');
+              reject();
+            };
+            img.src = e.target.result;
+          };
+          reader.onerror = () => {
+            this.$message.error('无法读取图片');
             reject();
-          });
+          };
+          reader.readAsDataURL(file);
         } else {
-          resolve(file);
+          afterRatioCheck();
         }
         this.loading = this.$loading({
           lock: true,

+ 50 - 47
src/components/VideoUpload/index.vue

@@ -2,49 +2,37 @@
   <div>
     <el-form-item label="视频上传">
       <div class="upload_video" id="upload_video">
-<!--        <el-upload-->
-<!--          class="upload-demo"-->
-<!--          ref="upload"-->
-<!--          action="#"-->
-<!--          :http-request="uploadVideoToTxPcdn"-->
-<!--          accept=".mp4"-->
-<!--          :limit="1"-->
-<!--          :on-remove="handleRemove"-->
-<!--          :on-change="handleChange"-->
-<!--          :auto-upload="false"-->
-<!--          :key="uploadKey"-->
-<!--        >-->
-<!--          <el-button slot="trigger" size="small" type="primary" >选取视频</el-button>-->
-<!--          <el-button style="margin-left: 10px;" size="small" type="success" @click="submitUpload">点击上传</el-button>-->
+        <el-upload
+          class="upload-demo"
+          ref="upload"
+          action="#"
+          :http-request="uploadVideoToTxPcdn"
+          accept=".mp4"
+          :limit="1"
+          :on-remove="handleRemove"
+          :on-change="handleChange"
+          :auto-upload="false"
+          :key="uploadKey"
+        >
+          <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" size="small" type="success" @click="openVideoLibrary">视频库选取</el-button>
-          <!-- 线路一 -->
-<!--          <div class="progress-container">-->
-<!--            <span class="progress-label">线路一</span>-->
-<!--            <el-progress-->
-<!--              style="margin-top: 5px;"-->
-<!--              class="progress"-->
-<!--              :text-inside="true"-->
-<!--              :stroke-width="18"-->
-<!--              :percentage="txProgress"-->
-<!--              status="success">-->
-<!--            </el-progress>-->
-<!--          </div>-->
-
-<!--          &lt;!&ndash; 线路二 &ndash;&gt;-->
-<!--          <div class="progress-container">-->
-<!--            <span class="progress-label">线路二</span>-->
-<!--            <el-progress-->
-<!--              style="margin-top: 5px;"-->
-<!--              class="progress"-->
-<!--              :text-inside="true"-->
-<!--              :stroke-width="18"-->
-<!--              :percentage="hwProgress"-->
-<!--              status="success">-->
-<!--            </el-progress>-->
-<!--          </div>-->
-<!--          <div slot="tip" class="el-upload__tip">只能上传mp4文件,且不超过500M</div>-->
-<!--        </el-upload>-->
+          <el-button v-if="showControl" style="margin-left: 10px;" size="small" type="warning" @click="openVideoLibrary">素材库选取</el-button>
+          <!-- 线路一进度 -->
+          <div class="progress-container" style="width:100% !important;">
+            <span class="progress-label">上传进度</span>
+            <el-progress
+              style="margin-top: 5px;"
+              class="progress"
+              :text-inside="true"
+              :stroke-width="18"
+              :percentage="txProgress"
+              status="success">
+            </el-progress>
+          </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>
     <el-form-item label="视频播放">
@@ -311,11 +299,23 @@ 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();
+        // 本地上传展示进度(线路一 COS)
+        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(){
@@ -355,9 +355,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
@@ -453,6 +455,7 @@ export default {
 
       // 题目
       this.$emit("selectProjects", this.selectedVideo.projectIds)
+      this.$emit("video-ready");
 
       this.libraryOpen = false;
     },

+ 306 - 16
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,6 +30,9 @@
       <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>
       <right-toolbar :showSearch.sync="showSearch" @queryTable="getList"></right-toolbar>
     </el-row>
 
@@ -36,9 +44,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 +83,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 +102,33 @@
           <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"
+          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">默认 0,可手动修改</span>
+        </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">
@@ -104,7 +143,8 @@
           </el-radio-group>
         </el-form-item>
         <el-form-item label="关联商品">
-          <el-button type="primary" size="mini" @click="openProductDialog">选择商品</el-button>
+          <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 +164,71 @@
       </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)"
+            />
+            <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">默认 0,可手动修改</span>
+            </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 +250,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,10 +268,12 @@
 
 <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,
@@ -169,21 +281,25 @@ export default {
       total: 0,
       videoList: [],
       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' }],
+        videoUrl: [{ required: true, message: '请上传或选择视频', trigger: 'change' }],
         isShow: [{ required: true, message: '请选择是否上架', trigger: 'change' }]
       },
       selectedProducts: [],
@@ -195,22 +311,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 +351,83 @@ 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())
+    },
+    /** 本地上传完成 / 素材库选完后展示上传时间;时长保持默认 0,可手动改 */
+    onVideoReady() {
+      if (!this.form.videoUrl) {
+        return
+      }
+      // 新增:展示当前选择/上传时间;修改:保留原 createTime
+      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')
+      })
     },
     reset() {
       this.form = {
         videoId: null,
         vestId: this.queryParams.vestId || null,
+        category: this.defaultCategory(),
         title: null,
         fileName: null,
         coverUrl: null,
         videoUrl: null,
         duration: 0,
+        likes: 0,
+        favoriteNum: 0,
+        shareNum: 0,
         isShow: 1,
         isHot: 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 +436,7 @@ export default {
     resetQuery() {
       this.resetForm('queryForm')
       this.queryParams.vestId = null
+      this.queryParams.category = null
       this.handleQuery()
     },
     handleAdd() {
@@ -253,14 +444,103 @@ 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,
+        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
+      }
+    },
+    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,
+        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.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.selectedProducts = ids.map(id => ({ productId: id, productName: '商品#' + id, price: '-', stock: '-' }))
-        // 回填商品详情
         if (ids.length) {
           searchLifeVideoProduct({ pageNum: 1, pageSize: 100 }).then(p => {
             const map = {}
@@ -275,6 +555,10 @@ export default {
     submitForm() {
       this.$refs['form'].validate(valid => {
         if (!valid) return
+        if (this.selectedProducts.length > 1) {
+          this.$message.warning('最多关联一个商品')
+          return
+        }
         this.form.productIds = this.selectedProducts.map(p => p.productId)
         const req = this.form.videoId != null ? updateLifeVideo(this.form) : addLifeVideo(this.form)
         req.then(() => {
@@ -315,9 +599,15 @@ 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.productOpen = false
     },
     removeProduct(index) {
       this.selectedProducts.splice(index, 1)

+ 1 - 0
src/views/live/liveVideo/index.vue

@@ -55,6 +55,7 @@
           ></video>
         </template>
       </el-table-column>
+      <el-table-column label="视频地址链接" align="center" prop="videoUrl" />
       <el-table-column label="时长" align="center" prop="duration" :formatter="formatDuration"/>
       <el-table-column label="转码状态" align="center" prop="finishStatus">
         <template slot-scope="scope">