Kaynağa Gözat

修复视频 图片

sunshibo 1 gün önce
ebeveyn
işleme
a94cc0a3cf

+ 116 - 2
src/api/modules/aiImageUpload.ts

@@ -3,7 +3,7 @@
  */
 import { useUserStore } from "@/stores/modules/user";
 import { ResultEnum } from "@/enums/httpEnum";
-import { AI_UPLOAD_FILES_PUBLIC_BASE, BASE_AI_MODERATE_URL, BASE_AI_URL } from "@/utils/config";
+import { AI_UPLOAD_FILES_PUBLIC_BASE, BASE_AI_MODERATE_URL, BASE_AI_URL, BASE_DEV_UPLOAD_SIMPLE } from "@/utils/config";
 
 const TUS_VERSION = "1.0.0";
 const TUS_CHUNK_SIZE = 1024 * 1024;
@@ -87,6 +87,119 @@ function pickFileUrlFromBody(body: unknown): string {
   return typeof d === "string" && d.startsWith("http") ? d : "";
 }
 
+/** 从 simple 上传或列表接口的 data 中取封面地址 */
+function pickCoverUrlFromBody(body: unknown): string {
+  const d = pickPayload(body);
+  let cur: any = d;
+  if (cur && typeof cur === "object" && cur.data !== undefined && !Array.isArray(cur.data)) {
+    cur = cur.data;
+  }
+  if (!cur || typeof cur !== "object") return "";
+  let cover = String(cur.cover || cur.coverUrl || cur.poster || cur.thumbnailUrl || cur.thumbnail || "").trim();
+  const imgUrlRaw = cur.imgUrl;
+  if (!cover && imgUrlRaw != null) {
+    if (typeof imgUrlRaw === "object" && imgUrlRaw !== null) {
+      const o = imgUrlRaw as Record<string, string>;
+      cover = String(o.cover || o.coverUrl || o.poster || "").trim();
+    } else if (typeof imgUrlRaw === "string") {
+      const s = imgUrlRaw.trim();
+      if (s.startsWith("{")) {
+        try {
+          const o = JSON.parse(s) as Record<string, string>;
+          cover = String(o.cover || o.coverUrl || o.poster || "").trim();
+        } catch {
+          /* ignore */
+        }
+      }
+    }
+  }
+  return cover;
+}
+
+/** simple 上传返回的 fileUrl 可能为 JSON 字符串 {"cover","video"} */
+function normalizeSimpleUploadUrls(body: unknown, fileUrl: string): { fileUrl: string; coverUrl?: string } {
+  let url = (fileUrl || "").trim();
+  let coverUrl = pickCoverUrlFromBody(body);
+  if (url.startsWith("{")) {
+    try {
+      const o = JSON.parse(url) as Record<string, string>;
+      if (o && typeof o === "object") {
+        const v = String(o.video || o.videoUrl || o.url || "").trim();
+        const c = String(o.cover || o.coverUrl || o.poster || "").trim();
+        if (v) url = v;
+        if (c && !coverUrl) coverUrl = c;
+      }
+    } catch {
+      /* keep url as-is */
+    }
+  }
+  return { fileUrl: url, coverUrl: coverUrl || undefined };
+}
+
+const DEV_SIMPLE_UPLOAD_PATH = "/dev-upload-ailien/upload/simple";
+
+function buildDevSimpleUploadRequestUrl(): string {
+  const base = String(BASE_DEV_UPLOAD_SIMPLE || "").replace(/\/$/, "");
+  if (base) {
+    return `${base}${DEV_SIMPLE_UPLOAD_PATH}`;
+  }
+  return DEV_SIMPLE_UPLOAD_PATH;
+}
+
+/**
+ * 官方相册等:multipart 直传(替代 Tus `POST .../upload`)
+ * POST `/dev-upload-ailien/upload/simple`,表单字段 `file`
+ */
+export async function uploadFileViaDevSimpleEndpoint(file: File): Promise<{ fileUrl: string; coverUrl?: string }> {
+  const reqUrl = buildDevSimpleUploadRequestUrl();
+  const fd = new FormData();
+  fd.append("file", file, file.name);
+
+  const res = await fetch(reqUrl, {
+    method: "POST",
+    headers: {
+      Authorization: authHeader()
+    },
+    body: fd,
+    credentials: "include"
+  });
+
+  let body: unknown = null;
+  const ct = res.headers.get("content-type") || "";
+  if (ct.includes("application/json")) {
+    try {
+      body = await res.json();
+    } catch {
+      body = null;
+    }
+  } else {
+    const t = await res.text();
+    try {
+      body = t ? JSON.parse(t) : null;
+    } catch {
+      body = t ? { raw: t } : null;
+    }
+  }
+
+  if (res.status < 200 || res.status >= 300) {
+    const msg =
+      body && typeof body === "object" && (body as any).msg != null ? String((body as any).msg) : `上传失败(${res.status})`;
+    throw new Error(msg);
+  }
+  if (body && typeof body === "object" && (body as any).code !== undefined) {
+    const c = (body as any).code;
+    if (c !== 200 && c !== 0) {
+      throw new Error((body as any).msg || (body as any).message || "上传失败");
+    }
+  }
+
+  const rawUrl = pickFileUrlFromBody(body);
+  if (!rawUrl) {
+    throw new Error("上传完成但未返回文件地址");
+  }
+  return normalizeSimpleUploadUrls(body, rawUrl);
+}
+
 /** 创建上传会话 POST /upload */
 export async function createUploadSession(data: {
   filename: string;
@@ -666,7 +779,8 @@ export const aiImageApi = {
   moderateVideo,
   getVideoModerateResult,
   uploadFileViaAi,
-  uploadFilesViaAi
+  uploadFilesViaAi,
+  uploadFileViaDevSimpleEndpoint
 };
 
 export default aiImageApi;

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

@@ -156,6 +156,10 @@ export const deleteOfficialImg = (params: any) => {
 export const getOfficialImgList = (businessId, storeId, imgType = 2) => {
   return httpApi.get(`/alienStore/img/getByBusinessId?businessId=${businessId}&imgType=${imgType}&storeId=${storeId}`);
 };
+/** 门店官方视频列表 GET /alienStore/video/getByStoreId?storeId= */
+export const getOfficialVideoByStoreId = (storeId: number | string) => {
+  return httpApi.get(`/alienStore/video/getByStoreId`, { storeId: Number(storeId) });
+};
 //新建或修改菜品
 export const createOrUpdateDish = (params: any) => {
   return httpApi.post(`/alienStorePlatform/menuPlatform/saveOrUpdate`, params);

+ 21 - 3
src/components/Upload/Imgs.vue

@@ -24,7 +24,15 @@
         </slot>
       </div>
       <template #file="{ file }">
-        <video v-if="isVideoFile(file) && file.url" :src="file.url" class="upload-image" muted preload="metadata" playsinline />
+        <video
+          v-if="isVideoFile(file) && file.url"
+          :src="file.url"
+          :poster="(file as any).coverUrl || undefined"
+          class="upload-image"
+          muted
+          preload="metadata"
+          playsinline
+        />
         <img
           v-else-if="file.url && file.uid !== undefined && !imageLoadError.has(file.uid)"
           :src="file.url"
@@ -201,14 +209,21 @@ const handleHttpUpload = async (options: UploadRequestOptions) => {
   try {
     const api = props.api ?? uploadImg;
     const response = await api(formData);
-    // 从 response.fileUrl 取值
-    const fileUrl = response?.fileUrl || "";
+    // 从 response.fileUrl / response.data.fileUrl 取值
+    const fileUrl = response?.fileUrl || response?.data?.fileUrl || "";
 
     if (fileUrl) {
+      const coverUrlRaw =
+        (response as any)?.coverUrl ?? (response as any)?.data?.coverUrl ?? (response as any)?.data?.cover ?? "";
+      const coverUrl = typeof coverUrlRaw === "string" && coverUrlRaw.trim() ? coverUrlRaw.trim() : "";
+
       // 更新 options.file(Element Plus 传入的文件对象)
       (options.file as unknown as UploadFile).url = fileUrl;
       (options.file as unknown as UploadFile).status = "success";
       (options.file as unknown as UploadFile).response = response;
+      if (coverUrl) {
+        (options.file as any).coverUrl = coverUrl;
+      }
 
       // 同步更新 _fileList 中对应的文件
       const fileIndex = _fileList.value.findIndex(item => item.uid === fileUid);
@@ -216,6 +231,9 @@ const handleHttpUpload = async (options: UploadRequestOptions) => {
         _fileList.value[fileIndex].url = fileUrl;
         _fileList.value[fileIndex].status = "success";
         (_fileList.value[fileIndex] as any).response = response;
+        if (coverUrl) {
+          (_fileList.value[fileIndex] as any).coverUrl = coverUrl;
+        }
       }
     }
 

+ 2 - 0
src/typings/global.d.ts

@@ -70,6 +70,8 @@ declare interface ViteEnv {
   VITE_AI_FILES_PUBLIC_BASE?: string;
   /** 设为 false 时图片仍走原 OSS / uploadMore,不经过 Tus + AI 审核 */
   VITE_UPLOAD_IMAGE_AI_MODERATE?: string;
+  /** 可选;官方相册视频 simple 上传服务根,不配则开发走相对路径 /dev-upload-ailien/...、生产默认 upload.ailien.shop:8443 */
+  VITE_DEV_UPLOAD_SIMPLE_BASE?: string;
 }
 
 interface ImportMetaEnv extends ViteEnv {

+ 9 - 0
src/utils/config.ts

@@ -18,3 +18,12 @@ export const BASE_AI_MODERATE_URL = trimSlash(
 export const AI_UPLOAD_FILES_PUBLIC_BASE = trimSlash(
   String(import.meta.env.VITE_AI_FILES_PUBLIC_BASE || "").trim() || "https://upload.ailien.shop:8443/files"
 );
+
+/**
+ * 官方相册视频等:POST multipart 至 `/dev-upload-ailien/upload/simple`(非 Tus)
+ * 不配时:开发环境请求同源相对路径(需在 VITE_PROXY 中把 `/dev-upload-ailien` 指到上传服务);生产默认 upload.ailien.shop:8443
+ */
+export const BASE_DEV_UPLOAD_SIMPLE = trimSlash(
+  String(import.meta.env.VITE_DEV_UPLOAD_SIMPLE_BASE || "").trim() ||
+    (import.meta.env.DEV ? "" : "https://upload.ailien.shop:8443")
+);

+ 400 - 193
src/views/storeDecoration/officialPhotoAlbum/index.vue

@@ -21,10 +21,12 @@
       </div>
     </div>
 
-    <!-- 内容区域 -->
+    <!-- 内容区域:isFixed 为「视频」的相册走视频链路,其余相册走原图片链路 -->
     <div class="content-section">
-      <!-- 环境/相册 Tab:图片上传说明 -->
-      <div v-if="isImageTab" class="upload-tips">
+      <div v-if="(isAlbumTab && currentAlbumIsVideo) || activeTab === 'video'" class="upload-tips">
+        <span>单个视频上传大小不超过50M,时长建议小于1分钟</span>
+      </div>
+      <div v-else-if="isAlbumTab && currentAlbumRef && !currentAlbumIsVideo" class="upload-tips">
         <span>照片宽高不小于500像素</span>
         <span>大小不超过10M</span>
         <span>可拖拽排序</span>
@@ -32,21 +34,11 @@
         <span>不可上传手机截屏、含清晰人脸/电话/二维码/第三方水印/LOGO等图片。</span>
       </div>
 
-      <!-- 视频 Tab:视频上传说明 -->
-      <div v-if="activeTab === 'video'" class="upload-tips">
-        <span>单个视频上传大小不超过500MB</span>
-        <span>可拖拽排序</span>
-        <span>时长建议小于1分钟</span>
-      </div>
-
-      <!-- 环境/相册 Tab:图片上传和展示区域 -->
-      <div v-if="isImageTab" class="media-grid">
-        <!-- 上传按钮 -->
+      <!-- 相册 Tab:图片(非视频相册) -->
+      <div v-if="isAlbumTab && currentAlbumRef && !currentAlbumIsVideo" class="media-grid">
         <div class="upload-item upload-box">
-          <!-- 环境tab:直接绑定 environmentImages -->
           <UploadImgs
-            v-if="activeTab === 'environment'"
-            v-model:file-list="environmentImages"
+            v-model:file-list="currentAlbumRef.images"
             :limit="50"
             :file-size="10"
             :file-type="['image/jpeg', 'image/png', 'image/gif', 'image/webp']"
@@ -56,48 +48,75 @@
             :api="customImageUploadApi"
           >
             <template #tip>
-              <div class="upload-tip-text">上传图片 ({{ environmentImages.length }}/50)</div>
+              <div class="upload-tip-text">上传图片 ({{ currentAlbumRef.images?.length || 0 }}/50)</div>
             </template>
           </UploadImgs>
-          <!-- 相册tab:根据当前相册动态绑定 -->
+        </div>
+
+        <div v-for="(image, index) in displayImages" :key="image.uid || index" class="media-item">
+          <div class="media-wrapper">
+            <img :src="image.url || ''" alt="相册图片" />
+            <div class="media-overlay">
+              <el-button type="primary" link @click="viewImage(image.url || '')"> 查看 </el-button>
+              <el-button type="primary" link @click="deleteImage(index)"> 删除 </el-button>
+            </div>
+            <el-tag v-if="isFirstImage(index)" type="danger" class="cover-tag"> 封面 </el-tag>
+          </div>
+        </div>
+      </div>
+
+      <!-- 相册 Tab:视频(isFixed 为视频) -->
+      <div v-else-if="isAlbumTab && currentAlbumRef && currentAlbumIsVideo" class="media-grid">
+        <div class="upload-item upload-box">
           <UploadImgs
-            v-else-if="currentAlbumRef"
-            v-model:file-list="currentAlbumRef.images"
-            :limit="50"
-            :file-size="10"
-            :file-type="['image/jpeg', 'image/png', 'image/gif', 'image/webp']"
+            v-model:file-list="currentAlbumRef.videos"
+            :limit="10"
+            :file-size="50"
+            :file-type="['video/mp4', 'video/webm', 'video/ogg', 'video/quicktime'] as any"
             :width="'100%'"
             :height="'200px'"
             :border-radius="'8px'"
-            :api="customImageUploadApi"
+            :api="customVideoUploadApi"
+            :on-success="handleVideoUploadSuccess"
           >
             <template #tip>
-              <div class="upload-tip-text">上传图片 ({{ currentAlbumRef.images?.length || 0 }}/50)</div>
+              <div class="upload-tip-text">上传视频 ({{ currentAlbumRef.videos?.length || 0 }}/10)</div>
             </template>
           </UploadImgs>
         </div>
 
-        <!-- 已上传的图片 -->
-        <div v-for="(image, index) in displayImages" :key="image.uid || index" class="media-item">
+        <div v-for="(video, index) in displayVideos" :key="video.uid || index" class="media-item">
           <div class="media-wrapper">
-            <img :src="image.url || ''" alt="相册图片" />
+            <video
+              v-if="video.url"
+              :src="video.url"
+              :poster="video.coverUrl || undefined"
+              class="media-preview"
+              muted
+              preload="metadata"
+              playsinline
+            />
+            <!-- imgUrl JSON 内 cover 为封面图,叠在 video 上保证缩略图稳定展示 -->
+            <img v-if="video.coverUrl" :src="video.coverUrl" class="media-cover-thumb" alt="视频封面" />
+            <div v-else-if="!video.url" class="media-placeholder">
+              <el-icon><VideoPlay /></el-icon>
+              <span>视频预览</span>
+            </div>
             <div class="media-overlay">
-              <el-button type="primary" link @click="viewImage(image.url || '')"> 查看 </el-button>
-              <el-button type="primary" link @click="deleteImage(index)"> 删除 </el-button>
+              <el-button type="primary" link @click="viewVideo(video.url || '')"> 查看 </el-button>
+              <el-button type="primary" link @click="deleteVideo(index)"> 删除 </el-button>
             </div>
-            <el-tag v-if="isFirstImage(index)" type="danger" class="cover-tag"> 封面 </el-tag>
           </div>
         </div>
       </div>
 
-      <!-- 视频 Tab:视频上传和展示区域 -->
-      <div v-if="activeTab === 'video'" class="media-grid">
-        <!-- 上传按钮 -->
+      <!-- 独立「视频」Tab(若启用头部 Tab) -->
+      <div v-else-if="activeTab === 'video'" class="media-grid">
         <div class="upload-item upload-box">
           <UploadImgs
             v-model:file-list="videoList"
             :limit="10"
-            :file-size="500"
+            :file-size="50"
             :file-type="['video/mp4', 'video/webm', 'video/ogg', 'video/quicktime'] as any"
             :width="'100%'"
             :height="'200px'"
@@ -111,11 +130,19 @@
           </UploadImgs>
         </div>
 
-        <!-- 已上传的视频 -->
         <div v-for="(video, index) in displayVideos" :key="video.uid || index" class="media-item">
           <div class="media-wrapper">
-            <video v-if="video.url" :src="video.url" class="media-preview" muted preload="metadata" playsinline />
-            <div v-else class="media-placeholder">
+            <video
+              v-if="video.url"
+              :src="video.url"
+              :poster="video.coverUrl || undefined"
+              class="media-preview"
+              muted
+              preload="metadata"
+              playsinline
+            />
+            <img v-if="video.coverUrl" :src="video.coverUrl" class="media-cover-thumb" alt="视频封面" />
+            <div v-else-if="!video.url" class="media-placeholder">
               <el-icon><VideoPlay /></el-icon>
               <span>视频预览</span>
             </div>
@@ -159,11 +186,11 @@
       </template>
     </el-dialog>
 
-    <!-- 图片/视频预览对话框 -->
+    <!-- 视频预览对话框(相册/视频 Tab) -->
     <el-dialog v-model="previewVisible" width="600px" align-center class="preview-dialog">
       <div class="preview-content">
-        <img v-if="isImageTab" :src="previewImageUrl" alt="预览图片" class="preview-image" />
-        <video v-else-if="activeTab === 'video'" :src="previewVideoUrl" controls class="preview-video" />
+        <video v-if="previewVideoUrl" :src="previewVideoUrl" controls class="preview-video" />
+        <img v-else-if="previewImageUrl" :src="previewImageUrl" alt="预览" class="preview-image" />
       </div>
     </el-dialog>
   </div>
@@ -183,9 +210,11 @@ import {
   deleteOfficialAlbum,
   saveOfficialImg,
   getOfficialImgList,
+  getOfficialVideoByStoreId,
   deleteOfficialImg
 } from "@/api/modules/storeDecoration";
 import { uploadFilesToOss } from "@/api/upload.js";
+import { uploadFileViaDevSimpleEndpoint } from "@/api/modules/aiImageUpload";
 
 /** 普通官方相册图 */
 const OFFICIAL_IMG_TYPE_ALBUM = 2;
@@ -208,23 +237,41 @@ interface AlbumImage extends UploadUserFile {
 
 // 扩展视频类型
 interface AlbumVideo extends UploadUserFile {
-  businessId?: number; // 相册ID
+  businessId?: number; // 相册ID(接口 businessId)
   imgSort?: number; // 排序
   imgType?: number; // 视频类型,可能是3或其他值
   imgId?: number; // 视频ID(从服务器返回)
   videoDuration?: number; // 视频时长(秒)
+  coverUrl?: string; // 封面:接口 imgUrl 为 JSON 字符串时的 cover 字段
+  imgDescription?: string; // 接口说明文案
 }
 
-// 相册接口
+// 相册接口(getOfficialAlbumList:isFixed 为「视频」时该 Tab 走视频上传与 alienStore/video 列表)
 interface Album {
   id: string | number;
   name: string;
   albumName?: string; // API返回的字段名
+  isFixed?: string | number | boolean;
   images: AlbumImage[];
   videos: AlbumVideo[];
   coverImageId?: string | number;
 }
 
+/** 是否视频相册:与后端 isFixed 约定,支持文案「视频」或数字 1 */
+const albumIsVideoAlbum = (a: Album | null | undefined): boolean => {
+  if (!a) return false;
+  const v = a.isFixed;
+  if (v === undefined || v === null) return false;
+  if (typeof v === "string") {
+    const t = v.trim();
+    return t === "视频" || /^video$/i.test(t);
+  }
+  if (typeof v === "number") {
+    return v === 1;
+  }
+  return v === true;
+};
+
 const createDialogVisible = ref(false);
 const createFormRef = ref<FormInstance>();
 // 环境 / 视频 / 相册 id(字符串);初始为空,相册列表加载后默认选中第一项
@@ -244,13 +291,8 @@ const isLoadingAlbumImages = ref(false);
 // 相册列表
 const albumList = ref<Album[]>([]);
 
-// 判断当前是否为图片tab(环境或相册)
-const isImageTab = computed(() => {
-  return (
-    activeTab.value === "environment" ||
-    (activeTab.value !== "video" && albumList.value.some(album => String(album.id) === activeTab.value))
-  );
-});
+// 当前是否为相册 Tab(动态相册列表中的某一项)
+const isAlbumTab = computed(() => albumList.value.some(album => String(album.id) === activeTab.value));
 
 // 当前选中的相册或环境数据
 const currentAlbum = computed(() => {
@@ -265,7 +307,6 @@ const currentAlbum = computed(() => {
   // 否则是相册tab,查找对应的相册
   const album = albumList.value.find(album => String(album.id) === activeTab.value);
   if (album) {
-    // 确保 videos 数组存在(相册只有图片,没有视频)
     if (!album.videos) {
       album.videos = [];
     }
@@ -287,30 +328,19 @@ const currentAlbumRef = computed(() => {
   }
   const album = albumList.value.find(album => String(album.id) === activeTab.value);
   if (album) {
-    // 确保 images 数组存在且是响应式的
     if (!album.images) {
       album.images = reactive<AlbumImage[]>([]);
     }
+    if (!album.videos) {
+      album.videos = reactive<AlbumVideo[]>([]);
+    }
     return album;
   }
   return null;
 });
 
-// 当前相册的图片列表(用于计算显示)
-const currentAlbumImages = computed(() => {
-  if (activeTab.value === "environment") {
-    return environmentImages.value;
-  }
-  return currentAlbumRef.value?.images || [];
-});
-
-// 当前媒体列表(根据tab类型返回图片或视频)
-const currentMediaList = computed(() => {
-  if (activeTab.value === "video") {
-    return videoList.value;
-  }
-  return currentAlbumImages.value || [];
-});
+// 当前选中的相册是否为「视频」类型(由 getOfficialAlbumList.isFixed 决定)
+const currentAlbumIsVideo = computed(() => albumIsVideoAlbum(currentAlbumRef.value));
 
 // 分页数据
 const pageable = reactive({
@@ -319,38 +349,31 @@ const pageable = reactive({
   total: 0
 });
 
-// 显示图片列表(分页后)
+// 图片相册:分页后的图片列表
 const displayImages = computed(() => {
-  // 如果不在图片tab,返回空数组
-  if (!isImageTab.value) {
+  if (!isAlbumTab.value || currentAlbumIsVideo.value) {
     return [];
   }
-
-  let images: AlbumImage[] = [];
-  if (activeTab.value === "environment") {
-    images = environmentImages.value || [];
-  } else if (activeTab.value !== "video") {
-    images = currentAlbumRef.value?.images || [];
-  }
-
-  // 如果没有图片,直接返回空数组
+  const images = currentAlbumRef.value?.images || [];
   if (images.length === 0) {
     return [];
   }
-
   const start = (pageable.pageNum - 1) * pageable.pageSize;
   const end = start + pageable.pageSize;
   return images.slice(start, end);
 });
 
-// 显示视频列表(分页后)
+// 显示视频列表(分页后):独立「视频」Tab 或 isFixed 为视频的相册 Tab
 const displayVideos = computed(() => {
-  if (activeTab.value !== "video") {
+  let videos: AlbumVideo[] = [];
+  if (activeTab.value === "video") {
+    videos = videoList.value || [];
+  } else if (isAlbumTab.value && currentAlbumIsVideo.value) {
+    videos = currentAlbumRef.value?.videos || [];
+  } else {
     return [];
   }
-  const videos = videoList.value || [];
 
-  // 如果没有视频,直接返回空数组
   if (videos.length === 0) {
     return [];
   }
@@ -360,6 +383,11 @@ const displayVideos = computed(() => {
   return videos.slice(start, end);
 });
 
+const isFirstImage = (displayIndex: number): boolean => {
+  const actualIndex = (pageable.pageNum - 1) * pageable.pageSize + displayIndex;
+  return actualIndex === 0;
+};
+
 // 新建相册表单
 const createForm = reactive({
   name: ""
@@ -462,8 +490,13 @@ const getVideoDuration = (file: File): Promise<number> => {
 
 // 视频上传成功回调,用于保存视频时长
 const handleVideoUploadSuccess = async (url: string) => {
-  // 找到对应的视频文件
-  const video = videoList.value.find((v: AlbumVideo) => v.url === url && !v.videoDuration);
+  const list: AlbumVideo[] =
+    activeTab.value === "video"
+      ? videoList.value
+      : currentAlbumRef.value && albumIsVideoAlbum(currentAlbumRef.value)
+        ? currentAlbumRef.value.videos || []
+        : [];
+  const video = list.find((v: AlbumVideo) => v.url === url && !v.videoDuration);
   if (!video || !video.raw) {
     return;
   }
@@ -488,20 +521,20 @@ const customVideoUploadApi = async (formData: FormData): Promise<any> => {
   }
 
   const fileSizeMB = file.size / 1024 / 1024;
-  if (fileSizeMB > 500) {
-    ElMessage.warning(`视频大小不能超过 500MB,当前为 ${fileSizeMB.toFixed(2)}MB`);
+  if (fileSizeMB > 50) {
+    ElMessage.warning(`视频大小不能超过 50MB,当前为 ${fileSizeMB.toFixed(2)}MB`);
     throw new Error("视频大小超过限制");
   }
 
-  const urls = await uploadFilesToOss(file, "video");
-  const fileUrl = urls[0];
+  const { fileUrl, coverUrl } = await uploadFileViaDevSimpleEndpoint(file);
   if (!fileUrl) {
     throw new Error("上传失败,未返回地址");
   }
 
   const response = {
-    data: { fileUrl },
-    fileUrl
+    data: { fileUrl, ...(coverUrl ? { coverUrl } : {}) },
+    fileUrl,
+    ...(coverUrl ? { coverUrl } : {})
   };
   await nextTick();
   return response;
@@ -530,8 +563,8 @@ const saveImageToServer = async (imgUrl: string) => {
       businessId = Number(storeId);
       currentImages = environmentImages.value || [];
     } else {
-      // 相册图片:使用相册ID作为 businessId
-      if (!currentAlbumRef.value) {
+      // 相册图片:仅非视频相册(isFixed 非视频)
+      if (!currentAlbumRef.value || albumIsVideoAlbum(currentAlbumRef.value)) {
         return;
       }
       businessId = Number(currentAlbumRef.value.id);
@@ -593,11 +626,22 @@ const saveVideoToServer = async (videoUrl: string) => {
   try {
     const userInfo: any = localGet("geeker-user")?.userInfo || {};
     const storeId = userInfo.storeId;
-    if (!storeId || activeTab.value !== "video") {
+    if (!storeId) {
       return;
     }
 
-    const currentVideos = videoList.value || [];
+    let currentVideos: AlbumVideo[];
+    let businessId: number;
+
+    if (activeTab.value === "video") {
+      currentVideos = videoList.value || [];
+      businessId = Number(storeId);
+    } else if (currentAlbumRef.value && albumIsVideoAlbum(currentAlbumRef.value)) {
+      currentVideos = currentAlbumRef.value.videos || [];
+      businessId = Number(currentAlbumRef.value.id);
+    } else {
+      return;
+    }
     // 找到对应的视频(通过URL匹配)
     const targetVideo = currentVideos.find((video: AlbumVideo) => video.url === videoUrl && !video.imgId);
     if (!targetVideo) {
@@ -609,10 +653,9 @@ const saveVideoToServer = async (videoUrl: string) => {
 
     const imgSort = currentVideos.filter((video: AlbumVideo) => video.imgId).length + 1; // 排序为已保存视频数量+1
 
-    // 视频:使用 storeId 作为 businessId(需要根据实际API调整)
     const params = [
       {
-        businessId: Number(storeId), // 视频使用 storeId 作为 businessId
+        businessId,
         imgSort: imgSort,
         imgType: 3, // 视频类型为3
         imgUrl: videoUrl,
@@ -751,7 +794,7 @@ const handleDeleteAlbum = async () => {
   }
 
   try {
-    await ElMessageBox.confirm("确认删除该相册吗?删除后相册内的所有图片也将被删除。", "提示", {
+    await ElMessageBox.confirm("确认删除该相册吗?删除后相册内的图片或视频也将一并删除。", "提示", {
       confirmButtonText: "确定",
       cancelButtonText: "取消",
       type: "warning"
@@ -842,7 +885,13 @@ const deleteImage = async (index: number) => {
 // 删除视频
 const deleteVideo = async (index: number) => {
   const actualIndex = (pageable.pageNum - 1) * pageable.pageSize + index;
-  const video = videoList.value[actualIndex] as AlbumVideo;
+  const list: AlbumVideo[] =
+    activeTab.value === "video"
+      ? videoList.value
+      : currentAlbumRef.value && albumIsVideoAlbum(currentAlbumRef.value)
+        ? currentAlbumRef.value.videos || []
+        : [];
+  const video = list[actualIndex] as AlbumVideo;
   if (!video) {
     return;
   }
@@ -855,7 +904,7 @@ const deleteVideo = async (index: number) => {
 
       const res: any = await deleteOfficialImg(params);
       if (res && (res.code === 200 || res.code === "200")) {
-        videoList.value.splice(actualIndex, 1);
+        list.splice(actualIndex, 1);
         updatePagination();
         ElMessage.success("删除成功");
       } else {
@@ -863,7 +912,7 @@ const deleteVideo = async (index: number) => {
       }
     } else {
       // 如果视频还没有保存到服务器,直接删除本地数据
-      videoList.value.splice(actualIndex, 1);
+      list.splice(actualIndex, 1);
       updatePagination();
       ElMessage.success("删除成功");
     }
@@ -873,23 +922,18 @@ const deleteVideo = async (index: number) => {
   }
 };
 
-// 判断是否是第一张图片(封面)
-const isFirstImage = (displayIndex: number): boolean => {
-  // 计算在整个图片列表中的实际索引
-  const actualIndex = (pageable.pageNum - 1) * pageable.pageSize + displayIndex;
-  // 第一张图片(索引为0)是封面
-  return actualIndex === 0;
-};
-
 // 更新分页总数
 const updatePagination = () => {
   if (activeTab.value === "video") {
     pageable.total = videoList.value?.length || 0;
   } else if (activeTab.value === "environment") {
     pageable.total = environmentImages.value?.length || 0;
+  } else if (isAlbumTab.value) {
+    pageable.total = currentAlbumIsVideo.value
+      ? currentAlbumRef.value?.videos?.length || 0
+      : currentAlbumRef.value?.images?.length || 0;
   } else {
-    const album = albumList.value.find(album => String(album.id) === activeTab.value);
-    pageable.total = album?.images?.length || 0;
+    pageable.total = 0;
   }
 };
 
@@ -904,21 +948,20 @@ const handleCurrentChange = (page: number) => {
   pageable.pageNum = page;
 };
 
-// 监听图片列表变化,更新分页并保存新上传的图片
+// 监听环境 Tab 或「图片相册」Tab 的图片列表
 watch(
   () => {
-    // 根据当前tab返回对应的图片列表
     if (activeTab.value === "environment") {
       return environmentImages.value || [];
-    } else if (activeTab.value !== "video") {
-      // 相册tab
-      return currentAlbumRef.value?.images || [];
+    }
+    if (isAlbumTab.value && currentAlbumRef.value && !albumIsVideoAlbum(currentAlbumRef.value)) {
+      return currentAlbumRef.value.images || [];
     }
     return [];
   },
   async (newImages, oldImages) => {
-    // 如果不在图片 tab,不处理
-    if (!isImageTab.value) {
+    const isImageAlbumTab = isAlbumTab.value && currentAlbumRef.value && !albumIsVideoAlbum(currentAlbumRef.value);
+    if (activeTab.value !== "environment" && !isImageAlbumTab) {
       return;
     }
 
@@ -970,12 +1013,20 @@ watch(
   { deep: true }
 );
 
-// 监听视频列表变化,更新分页并保存新上传的视频
+// 监听视频列表:独立「视频」Tab 或 isFixed 为视频的相册 Tab
 watch(
-  () => videoList.value || [],
+  () => {
+    if (activeTab.value === "video") {
+      return videoList.value || [];
+    }
+    if (isAlbumTab.value && currentAlbumRef.value && albumIsVideoAlbum(currentAlbumRef.value)) {
+      return currentAlbumRef.value.videos || [];
+    }
+    return [];
+  },
   async (newVideos, oldVideos) => {
-    // 如果不在视频 tab,不处理
-    if (activeTab.value !== "video") {
+    const isVideoAlbumTab = isAlbumTab.value && currentAlbumRef.value && albumIsVideoAlbum(currentAlbumRef.value);
+    if (activeTab.value !== "video" && !isVideoAlbumTab) {
       return;
     }
 
@@ -1082,6 +1133,7 @@ const getAlbumList = async () => {
         id: album.id,
         name: album.albumName || album.name,
         albumName: album.albumName,
+        isFixed: album.isFixed,
         images: reactive<AlbumImage[]>([]), // 使用 reactive 确保响应式
         videos: reactive<AlbumVideo[]>([]),
         coverImageId: album.coverImageId
@@ -1152,7 +1204,112 @@ const loadEnvironmentImages = async () => {
   }
 };
 
-// 加载视频列表
+const parseOfficialVideoListPayload = (payload: any): any[] => {
+  if (Array.isArray(payload)) return payload;
+  if (payload && Array.isArray(payload.list)) return payload.list;
+  if (payload && Array.isArray(payload.records)) return payload.records;
+  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 || "";
+  const url = (parsed.videoUrl || fallback).trim();
+  let coverUrl = (parsed.coverUrl || "").trim();
+  if (!coverUrl) {
+    coverUrl = String(
+      video.cover || video.coverUrl || video.poster || video.thumbnailUrl || video.thumbnail || video.videoCover || ""
+    ).trim();
+  }
+  const id = video.id;
+  const desc = video.imgDescription != null ? String(video.imgDescription).trim() : "";
+  const name = desc || (url ? url.split("/").pop()?.split("?")[0] || "video" : "video");
+  return {
+    uid: id || Date.now() + Math.random(),
+    name,
+    url,
+    status: "success",
+    imgId: id,
+    businessId: video.businessId,
+    imgSort: video.imgSort ?? video.sort,
+    imgType: video.imgType ?? 3,
+    videoDuration: video.videoDuration ?? video.duration ?? 0,
+    coverUrl: coverUrl || undefined,
+    imgDescription: desc || undefined
+  };
+};
+
+/** GET /alienStore/video/getByStoreId */
+const fetchStoreOfficialVideosRaw = async (storeId: number | string) => {
+  const videoRes: any = await getOfficialVideoByStoreId(storeId);
+  if (videoRes && (videoRes.code === 200 || videoRes.code === "200") && videoRes.data != null) {
+    return parseOfficialVideoListPayload(videoRes.data);
+  }
+  return [];
+};
+
+// 加载视频列表(独立「视频」Tab)
 const loadVideoList = async () => {
   try {
     isLoadingAlbumImages.value = true;
@@ -1162,32 +1319,12 @@ const loadVideoList = async () => {
       return;
     }
 
-    // 视频:使用 storeId 作为 businessId(需要根据实际API调整)
-    // 或者如果视频不需要 businessId,可能需要使用其他API
-    // 这里先使用 storeId 作为 businessId
-    const videoRes: any = await getOfficialImgList(Number(storeId), Number(storeId), 3);
-
-    if (videoRes && (videoRes.code === 200 || videoRes.code === "200") && videoRes.data) {
-      const videoListData = Array.isArray(videoRes.data) ? videoRes.data : [];
-      const videos: AlbumVideo[] = videoListData
-        .filter((video: any) => video.deleteFlag !== 1)
-        .map((video: any) => ({
-          uid: video.id || Date.now() + Math.random(),
-          name: video.imgUrl?.split("/").pop() || "video",
-          url: video.imgUrl,
-          status: "success",
-          imgId: video.id,
-          businessId: video.businessId,
-          imgSort: video.imgSort,
-          imgType: video.imgType,
-          videoDuration: video.videoDuration || 0
-        }))
-        .sort((a: AlbumVideo, b: AlbumVideo) => (a.imgSort || 0) - (b.imgSort || 0));
-      // 使用数组替换确保响应式更新
-      videoList.value.splice(0, videoList.value.length, ...videos);
-    } else {
-      videoList.value.splice(0, videoList.value.length);
-    }
+    const rawList = await fetchStoreOfficialVideosRaw(storeId);
+    const videos: AlbumVideo[] = rawList
+      .filter((video: any) => video.deleteFlag !== 1)
+      .map(mapOfficialVideoRowToAlbumVideo)
+      .sort((a: AlbumVideo, b: AlbumVideo) => (a.imgSort || 0) - (b.imgSort || 0));
+    videoList.value.splice(0, videoList.value.length, ...videos);
 
     processedImages.value.clear();
   } catch (error) {
@@ -1200,72 +1337,131 @@ const loadVideoList = async () => {
   }
 };
 
-// 加载相册图片列表(相册只有图片,没有视频)
-const loadAlbumImages = async (albumId: string) => {
-  try {
-    isLoadingAlbumImages.value = true;
+// 图片相册:getOfficialImgList(与改造前一致)
+const loadAlbumPicturesForAlbum = async (albumId: string) => {
+  const userInfo: any = localGet("geeker-user")?.userInfo || {};
+  const storeId = userInfo.storeId;
+  if (!storeId || !albumId) {
+    return;
+  }
 
-    const userInfo: any = localGet("geeker-user")?.userInfo || {};
-    const storeId = userInfo.storeId;
-    if (!storeId || !albumId) {
-      return;
+  const current = albumList.value.find(album => String(album.id) === albumId);
+  if (!current) {
+    return;
+  }
+
+  const listImgType = isAlbumTabImgType4(albumId) ? OFFICIAL_IMG_TYPE_ENV_OR_SPECIAL_ALBUM : OFFICIAL_IMG_TYPE_ALBUM;
+  const imageRes: any = await getOfficialImgList(Number(albumId), Number(storeId), listImgType);
+
+  if (imageRes && (imageRes.code === 200 || imageRes.code === "200") && imageRes.data) {
+    const imageList = Array.isArray(imageRes.data) ? imageRes.data : [];
+    const images: AlbumImage[] = imageList
+      .filter((img: any) => img.deleteFlag !== 1)
+      .map((img: any) => ({
+        uid: img.id || Date.now() + Math.random(),
+        name: img.imgUrl?.split("/").pop() || "image",
+        url: img.imgUrl,
+        status: "success",
+        imgId: img.id,
+        businessId: img.businessId,
+        imgSort: img.imgSort,
+        imgType: img.imgType,
+        isCover: false
+      }))
+      .sort((a: AlbumImage, b: AlbumImage) => (a.imgSort || 0) - (b.imgSort || 0));
+    if (!current.images) {
+      current.images = reactive<AlbumImage[]>([]);
     }
+    current.images.splice(0, current.images.length, ...images);
+  } else {
+    if (!current.images) {
+      current.images = reactive<AlbumImage[]>([]);
+    } else {
+      current.images.splice(0, current.images.length);
+    }
+  }
+
+  if (!current.videos) {
+    current.videos = reactive<AlbumVideo[]>([]);
+  } else {
+    current.videos.splice(0, current.videos.length);
+  }
+};
+
+// 视频相册:getOfficialVideoByStoreId,按 businessId 与相册 id 匹配
+const loadAlbumVideosForAlbum = async (albumId: string) => {
+  const userInfo: any = localGet("geeker-user")?.userInfo || {};
+  const storeId = userInfo.storeId;
+  if (!storeId || !albumId) {
+    return;
+  }
+
+  const current = albumList.value.find(album => String(album.id) === albumId);
+  if (!current) {
+    return;
+  }
+
+  const rawList = await fetchStoreOfficialVideosRaw(storeId);
+  const albumKey = String(albumId);
+  const forAlbum = rawList.filter((v: any) => {
+    if (v.deleteFlag === 1) return false;
+    const bid = v.businessId ?? v.albumId ?? v.officialAlbumId;
+    return bid != null && String(bid) === albumKey;
+  });
+  const videos: AlbumVideo[] = forAlbum
+    .map(mapOfficialVideoRowToAlbumVideo)
+    .sort((a: AlbumVideo, b: AlbumVideo) => (a.imgSort || 0) - (b.imgSort || 0));
 
-    const listImgType = isAlbumTabImgType4(albumId) ? OFFICIAL_IMG_TYPE_ENV_OR_SPECIAL_ALBUM : OFFICIAL_IMG_TYPE_ALBUM;
-    const imageRes: any = await getOfficialImgList(Number(albumId), Number(storeId), listImgType);
+  if (!current.videos) {
+    current.videos = reactive<AlbumVideo[]>([]);
+  }
+  current.videos.splice(0, current.videos.length, ...videos);
+
+  if (!current.images) {
+    current.images = reactive<AlbumImage[]>([]);
+  } else {
+    current.images.splice(0, current.images.length);
+  }
+};
+
+const loadAlbumImages = async (albumId: string) => {
+  try {
+    isLoadingAlbumImages.value = true;
 
     const current = albumList.value.find(album => String(album.id) === albumId);
     if (!current) {
       return;
     }
 
-    // 处理图片列表
-    if (imageRes && (imageRes.code === 200 || imageRes.code === "200") && imageRes.data) {
-      const imageList = Array.isArray(imageRes.data) ? imageRes.data : [];
-      const images: AlbumImage[] = imageList
-        .filter((img: any) => img.deleteFlag !== 1)
-        .map((img: any) => ({
-          uid: img.id || Date.now() + Math.random(),
-          name: img.imgUrl?.split("/").pop() || "image",
-          url: img.imgUrl,
-          status: "success",
-          imgId: img.id,
-          businessId: img.businessId,
-          imgSort: img.imgSort,
-          imgType: img.imgType,
-          isCover: false
-        }))
-        .sort((a: AlbumImage, b: AlbumImage) => (a.imgSort || 0) - (b.imgSort || 0));
-      // 使用 splice 方法替换数组内容,确保响应式更新
-      if (!current.images) {
-        current.images = reactive<AlbumImage[]>([]);
-      }
-      current.images.splice(0, current.images.length, ...images);
+    if (albumIsVideoAlbum(current)) {
+      await loadAlbumVideosForAlbum(albumId);
     } else {
-      if (!current.images) {
-        current.images = reactive<AlbumImage[]>([]);
-      } else {
-        current.images.splice(0, current.images.length);
-      }
+      await loadAlbumPicturesForAlbum(albumId);
     }
 
-    // 清空已处理图片集合,因为加载的是服务器数据,不需要再保存
     processedImages.value.clear();
   } catch (error) {
-    console.error("获取相册图片列表失败:", error);
+    console.error("获取相册媒体列表失败:", error);
     const current = albumList.value.find(album => String(album.id) === albumId);
     if (current) {
       processedImages.value.clear();
-      if (!current.images) {
-        current.images = reactive<AlbumImage[]>([]);
+      if (albumIsVideoAlbum(current)) {
+        if (!current.videos) {
+          current.videos = reactive<AlbumVideo[]>([]);
+        } else {
+          current.videos.splice(0, current.videos.length);
+        }
       } else {
-        current.images.splice(0, current.images.length);
+        if (!current.images) {
+          current.images = reactive<AlbumImage[]>([]);
+        } else {
+          current.images.splice(0, current.images.length);
+        }
       }
     }
   } finally {
     isLoadingAlbumImages.value = false;
     await nextTick();
-    // 更新分页信息,确保视图刷新
     updatePagination();
   }
 };
@@ -1402,6 +1598,16 @@ onMounted(async () => {
             height: 100%;
             object-fit: cover;
           }
+          .media-cover-thumb {
+            position: absolute;
+            inset: 0;
+            z-index: 1;
+            width: 100%;
+            height: 100%;
+            pointer-events: none;
+            object-fit: cover;
+            border-radius: 8px;
+          }
           .media-placeholder {
             display: flex;
             flex-direction: column;
@@ -1418,6 +1624,7 @@ onMounted(async () => {
           .media-overlay {
             position: absolute;
             inset: 0;
+            z-index: 2;
             display: flex;
             gap: 12px;
             align-items: center;