Просмотр исходного кода

fix: 封面逻辑调整,可以上传视频或者5个图片

sgc 2 дней назад
Родитель
Сommit
45b0bfa44d
1 измененных файлов с 288 добавлено и 105 удалено
  1. 288 105
      src/views/storeDecoration/storeCoverMap/index.vue

+ 288 - 105
src/views/storeDecoration/storeCoverMap/index.vue

@@ -34,65 +34,78 @@
       <section class="upload-section">
         <h2 class="block-heading">上传封面图</h2>
         <div class="upload-layout">
-          <div
-            class="upload-slot"
-            :class="{ filled: !!draftCover, uploading: uploading }"
-            :role="draftCover ? undefined : 'button'"
-            :tabindex="draftCover ? -1 : 0"
-            @click="onUploadSlotClick"
-            @keydown.enter.prevent="onUploadSlotKeydown"
-          >
+          <div class="upload-slots-column">
             <input
               ref="fileInputRef"
               type="file"
               class="upload-input-hidden"
               :accept="fileAcceptAttr"
+              :multiple="allowMultipleOnInput"
               @change="onFileInputChange"
             />
-            <template v-if="!draftCover">
-              <el-icon class="upload-slot__plus">
-                <Plus />
-              </el-icon>
-              <div class="upload-slot__primary">上传图片/视频 {{ slotCountTip }}</div>
-              <div class="upload-slot__hint">建议尺寸 1242*640px;图片不超过 20MB,视频不超过 500MB</div>
-            </template>
-            <template v-else-if="draftCover.isVideo">
-              <div class="upload-slot__preview upload-slot__preview--video">
-                <video
-                  :src="draftCover.url"
-                  class="upload-slot__thumb-v"
-                  muted
-                  playsinline
-                  preload="metadata"
-                  :poster="draftCover.posterUrl || undefined"
-                />
-                <div class="upload-slot__hover-overlay">
-                  <button type="button" class="upload-slot__action" @click.stop="openCoverPreview">
-                    <el-icon><View /></el-icon>
-                    <span>查看</span>
-                  </button>
-                  <button type="button" class="upload-slot__action" @click.stop="clearDraft">
-                    <el-icon><Delete /></el-icon>
-                    <span>删除</span>
-                  </button>
-                </div>
+            <div class="upload-slots-row">
+              <div
+                v-for="(item, index) in draftCovers"
+                :key="'cover-' + index + '-' + (item.url || '')"
+                class="upload-slot upload-slot--grid filled"
+                :class="{ uploading: uploading }"
+              >
+                <template v-if="item.isVideo">
+                  <div class="upload-slot__preview upload-slot__preview--video">
+                    <video
+                      :src="item.url"
+                      class="upload-slot__thumb-v"
+                      muted
+                      playsinline
+                      preload="metadata"
+                      :poster="item.posterUrl || undefined"
+                    />
+                    <div class="upload-slot__hover-overlay">
+                      <button type="button" class="upload-slot__action" @click.stop="openCoverPreview(index)">
+                        <el-icon><View /></el-icon>
+                        <span>查看</span>
+                      </button>
+                      <button type="button" class="upload-slot__action" @click.stop="removeDraftAt(index)">
+                        <el-icon><Delete /></el-icon>
+                        <span>删除</span>
+                      </button>
+                    </div>
+                  </div>
+                </template>
+                <template v-else>
+                  <div class="upload-slot__preview">
+                    <img :src="item.url" alt="已选封面图" class="upload-slot__thumb" />
+                    <div class="upload-slot__hover-overlay">
+                      <button type="button" class="upload-slot__action" @click.stop="openCoverPreview(index)">
+                        <el-icon><View /></el-icon>
+                        <span>查看</span>
+                      </button>
+                      <button type="button" class="upload-slot__action" @click.stop="removeDraftAt(index)">
+                        <el-icon><Delete /></el-icon>
+                        <span>删除</span>
+                      </button>
+                    </div>
+                  </div>
+                </template>
               </div>
-            </template>
-            <template v-else>
-              <div class="upload-slot__preview">
-                <img :src="draftCover.url" alt="已选封面图" class="upload-slot__thumb" />
-                <div class="upload-slot__hover-overlay">
-                  <button type="button" class="upload-slot__action" @click.stop="openCoverPreview">
-                    <el-icon><View /></el-icon>
-                    <span>查看</span>
-                  </button>
-                  <button type="button" class="upload-slot__action" @click.stop="clearDraft">
-                    <el-icon><Delete /></el-icon>
-                    <span>删除</span>
-                  </button>
+
+              <div
+                v-if="showAddCoverSlot"
+                class="upload-slot upload-slot--grid"
+                :class="{ uploading: uploading }"
+                role="button"
+                tabindex="0"
+                @click="onAddCoverSlotClick"
+                @keydown.enter.prevent="onAddCoverSlotKeydown"
+              >
+                <el-icon class="upload-slot__plus">
+                  <Plus />
+                </el-icon>
+                <div class="upload-slot__primary">
+                  {{ addSlotPrimaryText }}
                 </div>
               </div>
-            </template>
+            </div>
           </div>
 
           <ul class="instruction-list">
@@ -139,6 +152,8 @@ import PcVideoPreviewDialog from "@/components/pcMediaPreview/PcVideoPreviewDial
 
 /** 与商户端封面 imgType 一致 */
 const IMG_TYPE_COVER = 38;
+/** 与商户端一致:纯图模式最多 5 张,与 1 个视频二选一 */
+const COVER_MAX_IMAGES = 5;
 
 const IMAGE_MAX_MB = 20;
 const VIDEO_MAX_MB = 500;
@@ -188,7 +203,7 @@ const loading = ref(false);
 const uploading = ref(false);
 const saving = ref(false);
 
-const draftCover = ref<DraftCover | null>(null);
+const draftCovers = ref<DraftCover[]>([]);
 
 const fileInputRef = ref<HTMLInputElement | null>(null);
 /** 图片预览(与 storeHeadMap 一致);视频用弹窗 + 快照避免删除后空引用 */
@@ -197,22 +212,43 @@ const coverImagePreviewUrls = ref<string[]>([]);
 const coverImagePreviewInitialIndex = ref(0);
 const coverVideoPreviewVisible = ref(false);
 const previewTarget = ref<DraftCover | null>(null);
-const slotCountTip = computed(() => (draftCover.value ? `(1/1)` : `(0/1)`));
+
+const draftHasVideo = computed(() => draftCovers.value.some(d => d.isVideo));
+
+const showAddCoverSlot = computed(() => !draftHasVideo.value && draftCovers.value.length < COVER_MAX_IMAGES);
+
+/** 仅已有图片时显示张数上限;空态不显示 (0/5) */
+const addSlotPrimaryText = computed(() => {
+  const n = draftCovers.value.length;
+  if (n > 0) {
+    return `上传图片 (${n}/${COVER_MAX_IMAGES})`;
+  }
+  return "上传图片或视频";
+});
+
+const fileAcceptAttr = computed(() => {
+  if (draftHasVideo.value) return "";
+  if (draftCovers.value.length > 0) {
+    return "image/jpeg,image/png,image/webp,.jpg,.jpeg,.png,.webp";
+  }
+  return "image/jpeg,image/png,image/webp,video/mp4,video/quicktime,video/webm,.mp4,.MOV,.mov,.webm,.m4v";
+});
+
+/** 已有图且未满时允许多选图片一次加入;空列表也可多选图片;视频与图二选一时选视频为单文件 */
+const allowMultipleOnInput = computed(() => !draftHasVideo.value && draftCovers.value.length < COVER_MAX_IMAGES);
 
 const previewReviews = ref(1853);
 const storeNameDisplay = ref("示例门店");
 
-const fileAcceptAttr = "image/jpeg,image/png,image/webp,video/mp4,video/quicktime,video/webm,.mp4,.MOV,.mov,.webm,.m4v";
-
 const instructionLines = [
-  "封面图将展示在门店列表、搜索结果等位置,是用户对门店的第一印象",
-  "支持JPG、PNG、WebP格式图片,或MP4、MOV格式视频",
-  "图片建议包含门店招牌、环境或特色产品,清晰美观无水印"
+  "封面请二选一:上传 1 个视频,或上传最多 5 张图片;保存后仍可重新上传替换。",
+  "视频建议不超过 500MB,避免视频出现显示不全问题。",
+  "建议上传4:3尺寸的图片,图片建议不超过 20MB,避免图片出现显示不全问题。"
 ];
 
 const canSubmit = computed(() => {
-  const u = draftCover.value?.url?.trim();
-  return Boolean(u && !uploading.value);
+  if (uploading.value) return false;
+  return draftCovers.value.some(d => Boolean(d.url?.trim()));
 });
 
 const fetchStoreDetail = async () => {
@@ -261,7 +297,7 @@ const fetchCoverDraft = async () => {
   const userInfo: any = localGet("geeker-user")?.userInfo || {};
   const storeId = userInfo.storeId;
   if (!storeId) {
-    draftCover.value = null;
+    draftCovers.value = [];
     return;
   }
   loading.value = true;
@@ -273,40 +309,55 @@ const fetchCoverDraft = async () => {
       const sorted = [...imgList].sort(
         (a: unknown, b: unknown) => (Number((a as StoreImgRow)?.imgSort) || 0) - (Number((b as StoreImgRow)?.imgSort) || 0)
       );
-      const firstRow = sorted[0];
-      draftCover.value = firstRow ? rowToDraft(firstRow as StoreImgRow) : null;
+      if (!sorted.length) {
+        draftCovers.value = [];
+        return;
+      }
+      const firstDraft = rowToDraft(sorted[0] as StoreImgRow);
+      if (firstDraft?.isVideo) {
+        draftCovers.value = [firstDraft];
+        return;
+      }
+      draftCovers.value = sorted
+        .slice(0, COVER_MAX_IMAGES)
+        .map(row => rowToDraft(row as StoreImgRow))
+        .filter((x): x is DraftCover => x != null);
     } else {
-      draftCover.value = null;
+      draftCovers.value = [];
     }
   } catch {
-    draftCover.value = null;
+    draftCovers.value = [];
   } finally {
     loading.value = false;
   }
 };
 
 function openFilePicker() {
-  if (uploading.value || saving.value || draftCover.value) return;
+  if (uploading.value || saving.value) return;
+  if (draftHasVideo.value) return;
+  if (draftCovers.value.length >= COVER_MAX_IMAGES) return;
   fileInputRef.value?.click();
 }
 
-function onUploadSlotClick() {
+function onAddCoverSlotClick() {
   openFilePicker();
 }
 
-function onUploadSlotKeydown() {
+function onAddCoverSlotKeydown() {
   openFilePicker();
 }
 
-function openCoverPreview() {
-  const d = draftCover.value;
+function openCoverPreview(index: number) {
+  const d = draftCovers.value[index];
   if (!d) return;
   if (d.isVideo) {
     previewTarget.value = { ...d };
     coverVideoPreviewVisible.value = true;
   } else {
-    coverImagePreviewUrls.value = [d.url];
-    coverImagePreviewInitialIndex.value = 0;
+    const urls = draftCovers.value.filter(x => !x.isVideo).map(x => x.url);
+    coverImagePreviewUrls.value = urls;
+    const ii = urls.indexOf(d.url);
+    coverImagePreviewInitialIndex.value = ii >= 0 ? ii : 0;
     coverImagePreviewVisible.value = true;
   }
 }
@@ -316,30 +367,28 @@ function onCoverVideoPreviewClosed() {
   previewTarget.value = null;
 }
 
-async function ingestPickedFile(file: File): Promise<boolean> {
+function isFileVideo(file: File): boolean {
   const name = String(file.name || "").toLowerCase();
-  const type = String(file.type || "");
-
-  let isVideo = /^video\//i.test(type) || /\.(mp4|mov|m4v|webm|3gp)$/i.test(name);
-
-  const imgOk = type === "image/jpeg" || type === "image/png" || type === "image/webp" || /\.(jpe?g|png|webp)$/i.test(name);
+  return /^video\//i.test(String(file.type || "")) || /\.(mp4|mov|m4v|webm|3gp)$/i.test(name);
+}
 
-  if (!imgOk && !isVideo) {
-    ElMessage.warning("仅支持 JPG、PNG、WebP、MP4、MOV");
-    return false;
-  }
+function isFileImage(file: File): boolean {
+  const name = String(file.name || "").toLowerCase();
+  const type = String(file.type || "");
+  return type === "image/jpeg" || type === "image/png" || type === "image/webp" || /\.(jpe?g|png|webp)$/i.test(name);
+}
 
+async function uploadOneToDraft(file: File, isVideo: boolean): Promise<DraftCover | null> {
   const maxMb = isVideo ? VIDEO_MAX_MB : IMAGE_MAX_MB;
   const sizeMb = file.size / (1024 * 1024);
   if (sizeMb > maxMb) {
     ElMessage.warning(`文件不能超过 ${maxMb}MB`);
-    return false;
+    return null;
   }
 
   uploading.value = true;
   try {
     const urls = await uploadFilesToOss([file], isVideo ? "video" : "image", {
-      /** 保留全局 PopupLoading 进度;上传完成不 toast,由本页统一提示(上传服务已含合规校验) */
       uploadSuccessMessage: null
     });
     const url = urls[0];
@@ -350,48 +399,122 @@ async function ingestPickedFile(file: File): Promise<boolean> {
       posterUrl = tryOssVideoSnapshotCoverUrl(url);
     }
 
-    draftCover.value = {
+    return {
       id: undefined,
       url,
       isVideo,
       ...(posterUrl ? { posterUrl } : {})
     };
-    ElMessage.success("上传成功");
-    return true;
   } catch (e: unknown) {
     if (isUploadUserCancelledError(e)) {
-      return false;
+      return null;
     }
     if (isUploadApiErrorAlreadyMessaged(e)) {
-      return false;
+      return null;
     }
     const msg =
       typeof e === "object" && e && "message" in e && typeof (e as { message?: unknown }).message === "string"
         ? String((e as Error).message)
         : "上传失败";
     ElMessage.error(msg);
-    return false;
+    return null;
   } finally {
     uploading.value = false;
   }
 }
 
+async function ingestSingleVideoFromFile(file: File): Promise<boolean> {
+  if (!isFileVideo(file)) {
+    ElMessage.warning("仅支持 MP4、MOV 等视频格式");
+    return false;
+  }
+  const draft = await uploadOneToDraft(file, true);
+  if (!draft) return false;
+  draftCovers.value = [draft];
+  ElMessage.success("上传成功");
+  return true;
+}
+
+async function ingestSingleImageAppend(file: File): Promise<boolean> {
+  if (!isFileImage(file)) {
+    ElMessage.warning("仅支持 JPG、PNG、WebP 图片");
+    return false;
+  }
+  if (draftHasVideo.value) {
+    ElMessage.warning("请先删除视频后再上传图片");
+    return false;
+  }
+  if (draftCovers.value.length >= COVER_MAX_IMAGES) {
+    ElMessage.warning(`最多上传 ${COVER_MAX_IMAGES} 张图片`);
+    return false;
+  }
+  const draft = await uploadOneToDraft(file, false);
+  if (!draft) return false;
+  draftCovers.value = [...draftCovers.value, draft];
+  return true;
+}
+
+async function ingestPickedFiles(files: File[]): Promise<void> {
+  const list = files.filter(Boolean);
+  if (!list.length) return;
+
+  const firstVideo = list.find(isFileVideo);
+  if (firstVideo) {
+    if (list.some(f => isFileImage(f)) || list.filter(isFileVideo).length > 1) {
+      ElMessage.info("已选择视频,将仅保留该视频作为封面(与图片二选一)");
+    }
+    await ingestSingleVideoFromFile(firstVideo);
+    return;
+  }
+
+  if (draftHasVideo.value) {
+    ElMessage.warning("请先删除视频后再上传图片");
+    return;
+  }
+
+  const remaining = COVER_MAX_IMAGES - draftCovers.value.length;
+  if (remaining <= 0) {
+    ElMessage.warning(`最多上传 ${COVER_MAX_IMAGES} 张图片`);
+    return;
+  }
+
+  const imageFiles = list.filter(isFileImage).slice(0, remaining);
+  if (!imageFiles.length) {
+    ElMessage.warning("仅支持 JPG、PNG、WebP、MP4、MOV");
+    return;
+  }
+
+  let okCount = 0;
+  for (const file of imageFiles) {
+    if (draftCovers.value.length >= COVER_MAX_IMAGES) break;
+    const ok = await ingestSingleImageAppend(file);
+    if (ok) okCount += 1;
+    else break;
+  }
+  if (okCount > 1) {
+    ElMessage.success(`已上传 ${okCount} 张图片`);
+  } else if (okCount === 1) {
+    ElMessage.success("上传成功");
+  }
+}
+
 async function onFileInputChange(ev: Event): Promise<void> {
   const input = ev.target as HTMLInputElement;
-  const file = input.files?.[0];
+  const files = Array.from(input.files || []);
   input.value = "";
-  if (!file) return;
-  await ingestPickedFile(file);
+  if (!files.length) return;
+  await ingestPickedFiles(files);
 }
 
-function clearDraft() {
-  draftCover.value = null;
+function removeDraftAt(index: number) {
+  const next = draftCovers.value.filter((_, i) => i !== index);
+  draftCovers.value = next;
 }
 
 async function onSave() {
   if (saving.value) return;
   if (!canSubmit.value) {
-    ElMessage.warning("请先上传一张图片或一段视频(仅 1 条)");
+    ElMessage.warning("请先上传封面:1 个视频或最多 5 张图片");
     return;
   }
   const sidRaw = localGet("geeker-user")?.userInfo?.storeId;
@@ -403,17 +526,15 @@ async function onSave() {
 
   saving.value = true;
   try {
-    const dc = draftCover.value!;
-    const storeImgList = [
-      {
-        id: dc.id,
-        imgUrl: dc.url,
-        imgType: IMG_TYPE_COVER,
-        imgSort: 1,
-        storeId: numericStoreId,
-        isExtract: 0
-      }
-    ];
+    const list = draftCovers.value;
+    const storeImgList = list.map((dc, index) => ({
+      id: dc.id,
+      imgUrl: dc.url,
+      imgType: IMG_TYPE_COVER,
+      imgSort: index + 1,
+      storeId: numericStoreId,
+      isExtract: 0
+    }));
 
     const res: any = await saveStoreHeadImg({
       storeImgList,
@@ -574,6 +695,16 @@ onMounted(async () => {
   gap: 32px 40px;
   align-items: flex-start;
 }
+.upload-slots-column {
+  flex: 0 1 auto;
+  min-width: 0;
+}
+.upload-slots-row {
+  display: flex;
+  flex-wrap: wrap;
+  gap: 16px;
+  align-items: stretch;
+}
 .upload-slot {
   box-sizing: border-box;
   display: flex;
@@ -685,6 +816,51 @@ onMounted(async () => {
     }
   }
 }
+.upload-slot.upload-slot--grid {
+  box-sizing: border-box;
+  display: flex;
+  flex-shrink: 0;
+  flex-direction: column;
+  align-items: center;
+  justify-content: center;
+  width: 240px;
+  height: 180px;
+  min-height: 180px;
+  max-height: 180px;
+  padding: 6px 8px;
+  overflow: hidden;
+  border-radius: 10px;
+  .upload-slot__plus {
+    font-size: 22px;
+  }
+  .upload-slot__primary {
+    margin-top: 2px;
+    font-size: 11px;
+    line-height: 1.3;
+    color: #909399;
+    text-align: center;
+  }
+  &.filled {
+    justify-content: stretch;
+    padding: 0;
+  }
+  &.filled .upload-slot__preview {
+    flex: 1 1 auto;
+    width: 100%;
+    height: 100%;
+    min-height: 0;
+    max-height: none;
+    border-radius: 10px;
+  }
+  &.filled .upload-slot__thumb,
+  &.filled .upload-slot__thumb-v {
+    display: block;
+    width: 100%;
+    height: 100%;
+    max-height: none;
+    object-fit: cover;
+  }
+}
 .upload-input-hidden {
   position: absolute;
   width: 0;
@@ -767,5 +943,12 @@ onMounted(async () => {
     width: 100%;
     max-width: 360px;
   }
+  .upload-slot.upload-slot--grid {
+    width: calc((100% - 16px) / 2);
+    max-width: 240px;
+    height: 180px;
+    min-height: 180px;
+    max-height: 180px;
+  }
 }
 </style>