|
|
@@ -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 || "";
|