소스 검색

fix: 修改相册上传视频

sgc 6 시간 전
부모
커밋
8550526912
2개의 변경된 파일220개의 추가작업 그리고 79개의 파일을 삭제
  1. 11 0
      src/api/modules/storeDecoration.ts
  2. 209 79
      src/views/storeDecoration/officialPhotoAlbum/index.vue

+ 11 - 0
src/api/modules/storeDecoration.ts

@@ -151,6 +151,17 @@ export const getOfficialImgList = (businessId, storeId, imgType = 2) => {
 export const getOfficialVideoByStoreId = (storeId: number | string) => {
   return httpApi.get(`/alienStore/video/getByStoreId`, { storeId: Number(storeId) });
 };
+
+/** 官方相册视频保存/批量保存 POST /alienStore/video/saveOrSaveBatch(参数与商家端 albumDetail 一致) */
+export const saveOfficialVideoSaveOrSaveBatch = (params: {
+  videoUrls: string[];
+  videoIds: (number | string | null)[];
+  coverUrls: string[];
+  storeId: number;
+  businessId: number;
+}) => {
+  return httpApi.post(`/alienStore/video/saveOrSaveBatch`, params);
+};
 //新建或修改菜品
 export const createOrUpdateDish = (params: any) => {
   return httpApi.post(`/alienStorePlatform/menuPlatform/saveOrUpdate`, params);

+ 209 - 79
src/views/storeDecoration/officialPhotoAlbum/index.vue

@@ -211,6 +211,7 @@ import {
   saveOfficialImg,
   getOfficialImgList,
   getOfficialVideoByStoreId,
+  saveOfficialVideoSaveOrSaveBatch,
   deleteOfficialImg
 } from "@/api/modules/storeDecoration";
 import { uploadFilesToOss } from "@/api/upload.js";
@@ -462,6 +463,115 @@ const customImageUploadApi = async (formData: FormData): Promise<any> => {
   return response;
 };
 
+/**
+ * 阿里云 OSS 等支持 x-oss-process 时,用视频快照作封面(与商家端 albumDetail getOssVideoCoverUrl 一致)
+ */
+function tryOssVideoSnapshotCoverUrl(videoUrl: string): string {
+  if (!videoUrl || typeof videoUrl !== "string") return "";
+  if (/upload\.ailien\.shop/i.test(videoUrl)) return "";
+  const isOss =
+    videoUrl.includes("aliyuncs.com") ||
+    videoUrl.includes("oss-cn-") ||
+    videoUrl.includes("oss.") ||
+    /ailien\.shop/i.test(videoUrl) ||
+    videoUrl.includes("alien-volume");
+  if (!isOss) return "";
+  const sep = videoUrl.includes("?") ? "&" : "?";
+  return `${videoUrl}${sep}x-oss-process=video/snapshot,t_0,f_jpg,w_800,h_600,m_fast`;
+}
+
+/** 本地视频 File:canvas 截取首帧为 JPEG Blob */
+function captureVideoFileFirstFrameAsJpegBlob(videoFile: File): Promise<Blob | null> {
+  return new Promise(resolve => {
+    const video = document.createElement("video");
+    video.muted = true;
+    video.playsInline = true;
+    video.setAttribute("playsinline", "true");
+    video.preload = "metadata";
+    const objectUrl = URL.createObjectURL(videoFile);
+    let settled = false;
+    const finish = (blob: Blob | null) => {
+      if (settled) return;
+      settled = true;
+      URL.revokeObjectURL(objectUrl);
+      resolve(blob);
+    };
+
+    const grabFrame = () => {
+      try {
+        const w = video.videoWidth;
+        const h = video.videoHeight;
+        if (!w || !h) {
+          finish(null);
+          return;
+        }
+        const canvas = document.createElement("canvas");
+        canvas.width = w;
+        canvas.height = h;
+        const ctx = canvas.getContext("2d");
+        if (!ctx) {
+          finish(null);
+          return;
+        }
+        ctx.drawImage(video, 0, 0, w, h);
+        canvas.toBlob(b => finish(b), "image/jpeg", 0.88);
+      } catch {
+        finish(null);
+      }
+    };
+
+    video.onerror = () => finish(null);
+
+    video.onloadedmetadata = () => {
+      const t =
+        Number.isFinite(video.duration) && video.duration > 0 ? Math.min(0.1, Math.max(0.01, video.duration * 0.001)) : 0.01;
+      try {
+        video.currentTime = t;
+      } catch {
+        finish(null);
+      }
+    };
+
+    video.onseeked = () => {
+      grabFrame();
+    };
+
+    video.src = objectUrl;
+  });
+}
+
+/** 为 saveOrSaveBatch 准备 cover:已有 coverUrl → OSS 快照 → 本地文件首帧上传 */
+async function resolveVideoCoverUrlForSave(targetVideo: AlbumVideo, videoUrl: string): Promise<string> {
+  const existing = typeof targetVideo.coverUrl === "string" && targetVideo.coverUrl.trim();
+  if (existing) return existing;
+
+  const ossCover = tryOssVideoSnapshotCoverUrl(videoUrl);
+  if (ossCover) {
+    targetVideo.coverUrl = ossCover;
+    return ossCover;
+  }
+
+  const raw = targetVideo.raw;
+  if (raw instanceof File && String(raw.type || "").startsWith("video/")) {
+    const blob = await captureVideoFileFirstFrameAsJpegBlob(raw);
+    if (blob && blob.size > 0) {
+      const coverFile = new File([blob], `video_cover_${Date.now()}.jpg`, { type: "image/jpeg" });
+      try {
+        const urls = await uploadFilesToOss(coverFile, "image");
+        const u = urls[0];
+        if (u) {
+          targetVideo.coverUrl = u;
+          return u;
+        }
+      } catch (e) {
+        console.warn("[officialPhotoAlbum] 封面上传失败", e);
+      }
+    }
+  }
+
+  return "";
+}
+
 // 检测视频时长的辅助函数
 const getVideoDuration = (file: File): Promise<number> => {
   return new Promise(resolve => {
@@ -616,6 +726,81 @@ const saveImageToServer = async (imgUrl: string) => {
   }
 };
 
+/** saveOrSaveBatch 返回的 data 可能是数组、list、records 或单条对象 */
+function pickFirstRowFromVideoSaveResponse(res: any): Record<string, any> | null {
+  const d = res?.data;
+  if (d == null) return null;
+  if (Array.isArray(d)) return (d[0] as Record<string, any>) ?? null;
+  if (typeof d === "object") {
+    const o = d as Record<string, any>;
+    if (Array.isArray(o.list) && o.list.length) return o.list[0] as Record<string, any>;
+    if (Array.isArray(o.records) && o.records.length) return o.records[0] as Record<string, any>;
+    if (o.id != null || o.imgUrl != null || o.videoUrl != null || o.url != null) return o;
+  }
+  return null;
+}
+
+/**
+ * 解析接口 `imgUrl`:
+ * - 若为对象或 JSON 字符串:`cover` = 封面图 URL,`video` = 视频 URL(与 getByStoreId / save 返回一致)
+ * - 若为普通字符串:视为单一视频直链,无封面
+ * - 支持外层多套 JSON 字符串引号包裹(多次序列化)
+ */
+function parseOfficialVideoImgUrlField(imgUrlField: unknown): { videoUrl: string; coverUrl: string } {
+  if (imgUrlField == null || imgUrlField === "") {
+    return { videoUrl: "", coverUrl: "" };
+  }
+  if (typeof imgUrlField === "object" && imgUrlField !== null) {
+    const o = imgUrlField as Record<string, unknown>;
+    return {
+      videoUrl: String(o.video ?? o.videoUrl ?? o.url ?? ""),
+      coverUrl: String(o.cover ?? o.coverUrl ?? o.poster ?? "")
+    };
+  }
+  if (typeof imgUrlField === "string") {
+    let s = imgUrlField.trim();
+    for (let i = 0; i < 5; i++) {
+      if ((s.startsWith('"') && s.endsWith('"')) || (s.startsWith("'") && s.endsWith("'"))) {
+        try {
+          const next = JSON.parse(s);
+          if (typeof next === "string") {
+            s = next.trim();
+            continue;
+          }
+        } catch {
+          break;
+        }
+      }
+      break;
+    }
+    if (s.startsWith("{")) {
+      try {
+        const o = JSON.parse(s) as Record<string, unknown>;
+        if (o && typeof o === "object") {
+          let videoUrl = String(o.video ?? o.videoUrl ?? o.url ?? "").trim();
+          let coverUrl = String(o.cover ?? o.coverUrl ?? o.poster ?? "").trim();
+          if (videoUrl.startsWith("{")) {
+            try {
+              const o2 = JSON.parse(videoUrl) as Record<string, unknown>;
+              videoUrl = String(o2.video ?? o2.videoUrl ?? o2.url ?? "").trim();
+              if (!coverUrl) {
+                coverUrl = String(o2.cover ?? o2.coverUrl ?? o2.poster ?? "").trim();
+              }
+            } catch {
+              /* ignore */
+            }
+          }
+          return { videoUrl, coverUrl };
+        }
+      } catch {
+        /* 非 JSON 则当作直链 */
+      }
+    }
+    return { videoUrl: s, coverUrl: "" };
+  }
+  return { videoUrl: "", coverUrl: "" };
+}
+
 // 保存视频到服务器
 const saveVideoToServer = async (videoUrl: string) => {
   // 如果正在保存,直接返回,防止重复保存
@@ -651,26 +836,32 @@ const saveVideoToServer = async (videoUrl: string) => {
     // 标记为正在保存
     savingImages.value.add(videoUrl);
 
-    const imgSort = currentVideos.filter((video: AlbumVideo) => video.imgId).length + 1; // 排序为已保存视频数量+1
+    const cover = await resolveVideoCoverUrlForSave(targetVideo, videoUrl);
 
-    const params = [
-      {
-        businessId,
-        imgSort: imgSort,
-        imgType: 3, // 视频类型为3
-        imgUrl: videoUrl,
-        storeId: Number(storeId),
-        videoDuration: targetVideo.videoDuration || 0
-      }
-    ];
+    const params = {
+      videoUrls: [videoUrl],
+      videoIds: [targetVideo.imgId != null ? targetVideo.imgId : null],
+      coverUrls: [cover],
+      storeId: Number(storeId),
+      businessId
+    };
 
-    const res: any = await saveOfficialImg(params);
-    if (res && (res.code === 200 || res.code === "200") && res.data && res.data[0]) {
-      // 保存成功后,更新视频的ID和其他信息
-      targetVideo.imgId = res.data[0].id;
-      targetVideo.businessId = res.data[0].businessId;
-      targetVideo.imgSort = res.data[0].imgSort;
-      // 保存成功后,从已处理集合中移除,允许后续更新
+    const res: any = await saveOfficialVideoSaveOrSaveBatch(params);
+    const row = pickFirstRowFromVideoSaveResponse(res);
+    if (res && (res.code === 200 || res.code === "200")) {
+      if (row) {
+        targetVideo.imgId = row.id ?? row.videoId ?? targetVideo.imgId;
+        targetVideo.businessId = row.businessId ?? businessId;
+        targetVideo.imgSort = row.imgSort ?? row.sort;
+        const rawImgUrl = row.imgUrl ?? row.img_url;
+        const parsed = parseOfficialVideoImgUrlField(rawImgUrl != null ? rawImgUrl : row);
+        if (parsed.videoUrl) {
+          targetVideo.url = parsed.videoUrl;
+        }
+        if (parsed.coverUrl) {
+          targetVideo.coverUrl = parsed.coverUrl;
+        }
+      }
       processedImages.value.delete(videoUrl);
     }
   } catch (error) {
@@ -1211,67 +1402,6 @@ const parseOfficialVideoListPayload = (payload: any): any[] => {
   return [];
 };
 
-/**
- * 解析接口 `imgUrl`:
- * - 若为对象或 JSON 字符串:`cover` = 封面图 URL,`video` = 视频 URL(与 getByStoreId 返回一致)
- * - 若为普通字符串:视为单一视频直链,无封面
- * - 支持外层多套 JSON 字符串引号包裹(多次序列化)
- */
-const parseOfficialVideoImgUrlField = (imgUrlField: unknown): { videoUrl: string; coverUrl: string } => {
-  if (imgUrlField == null || imgUrlField === "") {
-    return { videoUrl: "", coverUrl: "" };
-  }
-  if (typeof imgUrlField === "object" && imgUrlField !== null) {
-    const o = imgUrlField as Record<string, unknown>;
-    return {
-      videoUrl: String(o.video ?? o.videoUrl ?? o.url ?? ""),
-      coverUrl: String(o.cover ?? o.coverUrl ?? o.poster ?? "")
-    };
-  }
-  if (typeof imgUrlField === "string") {
-    let s = imgUrlField.trim();
-    for (let i = 0; i < 5; i++) {
-      if ((s.startsWith('"') && s.endsWith('"')) || (s.startsWith("'") && s.endsWith("'"))) {
-        try {
-          const next = JSON.parse(s);
-          if (typeof next === "string") {
-            s = next.trim();
-            continue;
-          }
-        } catch {
-          break;
-        }
-      }
-      break;
-    }
-    if (s.startsWith("{")) {
-      try {
-        const o = JSON.parse(s) as Record<string, unknown>;
-        if (o && typeof o === "object") {
-          let videoUrl = String(o.video ?? o.videoUrl ?? o.url ?? "").trim();
-          let coverUrl = String(o.cover ?? o.coverUrl ?? o.poster ?? "").trim();
-          if (videoUrl.startsWith("{")) {
-            try {
-              const o2 = JSON.parse(videoUrl) as Record<string, unknown>;
-              videoUrl = String(o2.video ?? o2.videoUrl ?? o2.url ?? "").trim();
-              if (!coverUrl) {
-                coverUrl = String(o2.cover ?? o2.coverUrl ?? o2.poster ?? "").trim();
-              }
-            } catch {
-              /* ignore */
-            }
-          }
-          return { videoUrl, coverUrl };
-        }
-      } catch {
-        /* 非 JSON 则当作直链 */
-      }
-    }
-    return { videoUrl: s, coverUrl: "" };
-  }
-  return { videoUrl: "", coverUrl: "" };
-};
-
 const mapOfficialVideoRowToAlbumVideo = (video: any): AlbumVideo => {
   const parsed = parseOfficialVideoImgUrlField(video.imgUrl);
   const fallback = video.videoUrl || video.url || video.fileUrl || "";