|
|
@@ -0,0 +1,833 @@
|
|
|
+<template>
|
|
|
+ <div class="store-cover-page" v-loading="loading">
|
|
|
+ <div class="page-inner">
|
|
|
+ <section class="preview-section">
|
|
|
+ <h2 class="block-heading">效果预览</h2>
|
|
|
+ <div class="preview-area">
|
|
|
+ <div class="preview-card preview-card--blue">
|
|
|
+ <div class="preview-media">
|
|
|
+ <img :src="previewDemoImg" alt="效果预览示例" class="preview-img preview-img--single" />
|
|
|
+ <div class="preview-album-pill" aria-hidden="true">
|
|
|
+ <span>相册</span>
|
|
|
+ <el-icon>
|
|
|
+ <ArrowRight />
|
|
|
+ </el-icon>
|
|
|
+ </div>
|
|
|
+ </div>
|
|
|
+ <div class="preview-meta">
|
|
|
+ <div>
|
|
|
+ <div class="store-title">
|
|
|
+ {{ storeNameDisplay }}
|
|
|
+ </div>
|
|
|
+ <el-rate class="preview-rate" :model-value="5" disabled show-score score-template="5.0" text-color="#151515" />
|
|
|
+ </div>
|
|
|
+ <div class="reviews-right">
|
|
|
+ {{ previewReviews }} 条评价
|
|
|
+ <el-icon class="reviews-arrow">
|
|
|
+ <ArrowRight />
|
|
|
+ </el-icon>
|
|
|
+ </div>
|
|
|
+ </div>
|
|
|
+ </div>
|
|
|
+ </div>
|
|
|
+ </section>
|
|
|
+
|
|
|
+ <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"
|
|
|
+ >
|
|
|
+ <input
|
|
|
+ ref="fileInputRef"
|
|
|
+ type="file"
|
|
|
+ class="upload-input-hidden"
|
|
|
+ :accept="fileAcceptAttr"
|
|
|
+ @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,大小不超过 {{ maxMbDisplay }}MB</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>
|
|
|
+ </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>
|
|
|
+ </div>
|
|
|
+ </template>
|
|
|
+ </div>
|
|
|
+
|
|
|
+ <ul class="instruction-list">
|
|
|
+ <li v-for="(line, idx) in instructionLines" :key="idx" class="instruction-list__item">
|
|
|
+ <span class="instruction-list__icon" aria-hidden="true">
|
|
|
+ <el-icon><InfoFilled /></el-icon>
|
|
|
+ </span>
|
|
|
+ <span class="instruction-list__text">{{ line }}</span>
|
|
|
+ </li>
|
|
|
+ </ul>
|
|
|
+ </div>
|
|
|
+ </section>
|
|
|
+
|
|
|
+ <div class="save-footer">
|
|
|
+ <el-button type="primary" class="btn-save" :loading="saving" :disabled="!canSubmit" @click="onSave"> 保存 </el-button>
|
|
|
+ </div>
|
|
|
+ </div>
|
|
|
+
|
|
|
+ <!-- 图片预览:与 storeHeadMap 一致(根级挂载,同级于内容区) -->
|
|
|
+ <el-image-viewer
|
|
|
+ v-if="coverImagePreviewVisible"
|
|
|
+ :url-list="coverImagePreviewUrls"
|
|
|
+ :initial-index="coverImagePreviewInitialIndex"
|
|
|
+ @close="coverImagePreviewVisible = false"
|
|
|
+ />
|
|
|
+
|
|
|
+ <!-- 视频无法用 image-viewer,保留弹窗 -->
|
|
|
+ <el-dialog
|
|
|
+ v-model="coverVideoPreviewVisible"
|
|
|
+ title="查看"
|
|
|
+ width="min(92vw, 720px)"
|
|
|
+ class="cover-preview-dialog"
|
|
|
+ destroy-on-close
|
|
|
+ append-to-body
|
|
|
+ @closed="onCoverVideoPreviewClosed"
|
|
|
+ >
|
|
|
+ <video
|
|
|
+ v-if="previewTarget?.url"
|
|
|
+ :src="previewTarget.url"
|
|
|
+ class="cover-preview-dialog__media cover-preview-dialog__video"
|
|
|
+ controls
|
|
|
+ playsinline
|
|
|
+ />
|
|
|
+ </el-dialog>
|
|
|
+ </div>
|
|
|
+</template>
|
|
|
+
|
|
|
+<script setup lang="ts">
|
|
|
+import { ref, computed, onMounted } from "vue";
|
|
|
+import { ArrowRight, Plus, InfoFilled, View, Delete } from "@element-plus/icons-vue";
|
|
|
+import { ElLoading, ElMessage } from "element-plus";
|
|
|
+import { getStoreHeadImg, saveStoreHeadImg } from "@/api/modules/storeDecoration";
|
|
|
+import { getDetail } from "@/api/modules/homeEntry";
|
|
|
+import { localGet } from "@/utils";
|
|
|
+import { uploadFilesToOss, isUploadUserCancelledError, isUploadApiErrorAlreadyMessaged } from "@/api/upload.js";
|
|
|
+import { getCoverAuditData } from "@/api/modules/coverAudit";
|
|
|
+import previewDemoImg from "@/assets/images/uDianShiImg4.svg";
|
|
|
+
|
|
|
+/** 与商户端封面 imgType 一致 */
|
|
|
+const IMG_TYPE_COVER = 38;
|
|
|
+
|
|
|
+const IMAGE_MAX_MB = 10;
|
|
|
+const VIDEO_MAX_MB = 80;
|
|
|
+/** 与设计稿文案「不超 10M」一致展示 */
|
|
|
+const maxMbDisplay = IMAGE_MAX_MB;
|
|
|
+
|
|
|
+/** 对齐官方相册 OSS 快照规则(节选) */
|
|
|
+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`;
|
|
|
+}
|
|
|
+
|
|
|
+function normalizeCoverMediaUrl(raw: unknown): string {
|
|
|
+ const s = typeof raw === "string" ? raw.trim() : "";
|
|
|
+ if (!s) return "";
|
|
|
+ if (s.startsWith("{")) {
|
|
|
+ try {
|
|
|
+ const o = JSON.parse(s) as Record<string, unknown>;
|
|
|
+ const v = typeof o.video === "string" ? o.video : typeof o.videoUrl === "string" ? o.videoUrl : "";
|
|
|
+ const im = typeof o.cover === "string" ? o.cover : "";
|
|
|
+ if (/\.(mp4|mov|m4v|webm|3gp)(\?|#|$)/i.test(v)) return v;
|
|
|
+ return im || s;
|
|
|
+ } catch {
|
|
|
+ return s;
|
|
|
+ }
|
|
|
+ }
|
|
|
+ return s;
|
|
|
+}
|
|
|
+
|
|
|
+interface DraftCover {
|
|
|
+ id?: string | number;
|
|
|
+ url: string;
|
|
|
+ isVideo: boolean;
|
|
|
+ posterUrl?: string;
|
|
|
+}
|
|
|
+
|
|
|
+type StoreImgRow = Record<string, unknown>;
|
|
|
+
|
|
|
+const loading = ref(false);
|
|
|
+const uploading = ref(false);
|
|
|
+const saving = ref(false);
|
|
|
+
|
|
|
+const draftCover = ref<DraftCover | null>(null);
|
|
|
+
|
|
|
+const fileInputRef = ref<HTMLInputElement | null>(null);
|
|
|
+/** 图片预览(与 storeHeadMap 一致);视频用弹窗 + 快照避免删除后空引用 */
|
|
|
+const coverImagePreviewVisible = ref(false);
|
|
|
+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 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格式视频",
|
|
|
+ "图片建议包含门店招牌、环境或特色产品,清晰美观无水印"
|
|
|
+];
|
|
|
+
|
|
|
+const canSubmit = computed(() => {
|
|
|
+ const u = draftCover.value?.url?.trim();
|
|
|
+ return Boolean(u && !uploading.value);
|
|
|
+});
|
|
|
+
|
|
|
+const fetchStoreDetail = async () => {
|
|
|
+ try {
|
|
|
+ const param = { id: localGet("geeker-user")?.userInfo?.storeId };
|
|
|
+ if (!param.id) return;
|
|
|
+ const res: any = await getDetail(param as any);
|
|
|
+ if (res?.code === 200 && res.data?.storeName) {
|
|
|
+ storeNameDisplay.value = String(res.data.storeName);
|
|
|
+ }
|
|
|
+ } catch {
|
|
|
+ /* ignore */
|
|
|
+ }
|
|
|
+};
|
|
|
+
|
|
|
+function pickRowId(raw: unknown): string | number | undefined {
|
|
|
+ if (raw === null || raw === undefined || raw === "") return undefined;
|
|
|
+ if (typeof raw === "number") return Number.isFinite(raw) ? raw : undefined;
|
|
|
+ if (typeof raw === "string") return raw;
|
|
|
+ return undefined;
|
|
|
+}
|
|
|
+
|
|
|
+function rowToDraft(it: StoreImgRow): DraftCover | null {
|
|
|
+ let u = normalizeCoverMediaUrl(it.imgUrl);
|
|
|
+ if (!u) return null;
|
|
|
+ const isVideo = !!(it.isVideo === true || it.isVideo === 1) || /\.(mp4|mov|m4v|webm|3gp)(\?|#|$)/i.test(u);
|
|
|
+
|
|
|
+ let poster = "";
|
|
|
+ const thumbRaw = it.thumbnail_url ?? it.thumbnailUrl ?? it.cover ?? it.coverUrl;
|
|
|
+ poster = typeof thumbRaw === "string" ? thumbRaw.trim() : "";
|
|
|
+ if (isVideo && !poster && u) {
|
|
|
+ poster = tryOssVideoSnapshotCoverUrl(u);
|
|
|
+ }
|
|
|
+
|
|
|
+ const id = pickRowId(it.id);
|
|
|
+
|
|
|
+ return {
|
|
|
+ id,
|
|
|
+ url: u,
|
|
|
+ isVideo,
|
|
|
+ ...(poster ? { posterUrl: poster } : {})
|
|
|
+ };
|
|
|
+}
|
|
|
+
|
|
|
+const fetchCoverDraft = async () => {
|
|
|
+ const userInfo: any = localGet("geeker-user")?.userInfo || {};
|
|
|
+ const storeId = userInfo.storeId;
|
|
|
+ if (!storeId) {
|
|
|
+ draftCover.value = null;
|
|
|
+ return;
|
|
|
+ }
|
|
|
+ loading.value = true;
|
|
|
+ try {
|
|
|
+ const res: any = await getStoreHeadImg(storeId, IMG_TYPE_COVER);
|
|
|
+ if (res && (res.code === 200 || res.code === "200") && res.data) {
|
|
|
+ const dataAny = res.data as Record<string, unknown>;
|
|
|
+ const imgList = Array.isArray(dataAny) ? dataAny : (dataAny.storeImgList as StoreImgRow[]) || [];
|
|
|
+ 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;
|
|
|
+ } else {
|
|
|
+ draftCover.value = null;
|
|
|
+ }
|
|
|
+ } catch {
|
|
|
+ draftCover.value = null;
|
|
|
+ } finally {
|
|
|
+ loading.value = false;
|
|
|
+ }
|
|
|
+};
|
|
|
+
|
|
|
+function openFilePicker() {
|
|
|
+ if (uploading.value || saving.value || draftCover.value) return;
|
|
|
+ fileInputRef.value?.click();
|
|
|
+}
|
|
|
+
|
|
|
+function onUploadSlotClick() {
|
|
|
+ openFilePicker();
|
|
|
+}
|
|
|
+
|
|
|
+function onUploadSlotKeydown() {
|
|
|
+ openFilePicker();
|
|
|
+}
|
|
|
+
|
|
|
+function openCoverPreview() {
|
|
|
+ const d = draftCover.value;
|
|
|
+ if (!d) return;
|
|
|
+ if (d.isVideo) {
|
|
|
+ previewTarget.value = { ...d };
|
|
|
+ coverVideoPreviewVisible.value = true;
|
|
|
+ } else {
|
|
|
+ coverImagePreviewUrls.value = [d.url];
|
|
|
+ coverImagePreviewInitialIndex.value = 0;
|
|
|
+ coverImagePreviewVisible.value = true;
|
|
|
+ }
|
|
|
+}
|
|
|
+
|
|
|
+function onCoverVideoPreviewClosed() {
|
|
|
+ coverVideoPreviewVisible.value = false;
|
|
|
+ previewTarget.value = null;
|
|
|
+}
|
|
|
+
|
|
|
+/**
|
|
|
+ * 与 App `checkImageCompliance` 中封面分支一致:`getCoverAuditData({ url })`,
|
|
|
+ * code 成功且非 `overall_match === false` 视为通过。仅在页面层弹一次 toast,此处不调用 ElMessage。
|
|
|
+ */
|
|
|
+async function runCoverComplianceAudit(url: string): Promise<{ ok: true } | { ok: false; message: string }> {
|
|
|
+ try {
|
|
|
+ const res = (await getCoverAuditData({ url })) as {
|
|
|
+ code?: number | string;
|
|
|
+ msg?: string;
|
|
|
+ message?: string;
|
|
|
+ data?: { overall_match?: boolean; match_reason?: string };
|
|
|
+ };
|
|
|
+ const c = res.code;
|
|
|
+ const okCode = c === 200 || c === 0 || c === "200" || c === "0";
|
|
|
+ if (!okCode) {
|
|
|
+ return { ok: false, message: String(res.msg || res.message || "素材合规性检查失败") };
|
|
|
+ }
|
|
|
+ const d = res.data;
|
|
|
+ if (d && d.overall_match === false) {
|
|
|
+ return { ok: false, message: String(d.match_reason || "素材不符合规范,请重新上传") };
|
|
|
+ }
|
|
|
+ return { ok: true };
|
|
|
+ } catch (e: unknown) {
|
|
|
+ const msg =
|
|
|
+ typeof e === "object" && e && "message" in e && typeof (e as { message?: unknown }).message === "string"
|
|
|
+ ? String((e as Error).message)
|
|
|
+ : "素材合规性检查失败,请重试";
|
|
|
+ return { ok: false, message: msg };
|
|
|
+ }
|
|
|
+}
|
|
|
+
|
|
|
+async function ingestPickedFile(file: File): Promise<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);
|
|
|
+
|
|
|
+ if (!imgOk && !isVideo) {
|
|
|
+ ElMessage.warning("仅支持 JPG、PNG、WebP、MP4、MOV");
|
|
|
+ return false;
|
|
|
+ }
|
|
|
+
|
|
|
+ const maxMb = isVideo ? VIDEO_MAX_MB : IMAGE_MAX_MB;
|
|
|
+ const sizeMb = file.size / (1024 * 1024);
|
|
|
+ if (sizeMb > maxMb) {
|
|
|
+ ElMessage.warning(`文件不能超过 ${maxMb}MB`);
|
|
|
+ return false;
|
|
|
+ }
|
|
|
+
|
|
|
+ uploading.value = true;
|
|
|
+ let auditLoading;
|
|
|
+ try {
|
|
|
+ const urls = await uploadFilesToOss([file], isVideo ? "video" : "image", {
|
|
|
+ /** 保留全局 PopupLoading 进度;上传完成不 toast,审核通过后再提示 */
|
|
|
+ uploadSuccessMessage: null
|
|
|
+ });
|
|
|
+ const url = urls[0];
|
|
|
+ if (!url) throw new Error("上传成功但未返回地址");
|
|
|
+
|
|
|
+ auditLoading = ElLoading.service({
|
|
|
+ lock: true,
|
|
|
+ text: "素材合规校验中…",
|
|
|
+ background: "rgba(0, 0, 0, 0.35)"
|
|
|
+ });
|
|
|
+
|
|
|
+ const audit = await runCoverComplianceAudit(url);
|
|
|
+ if (!audit.ok) {
|
|
|
+ ElMessage.error(audit.message);
|
|
|
+ return false;
|
|
|
+ }
|
|
|
+
|
|
|
+ let posterUrl = "";
|
|
|
+ if (isVideo) {
|
|
|
+ posterUrl = tryOssVideoSnapshotCoverUrl(url);
|
|
|
+ }
|
|
|
+
|
|
|
+ draftCover.value = {
|
|
|
+ id: undefined,
|
|
|
+ url,
|
|
|
+ isVideo,
|
|
|
+ ...(posterUrl ? { posterUrl } : {})
|
|
|
+ };
|
|
|
+ ElMessage.success("已上传并通过合规校验,请点击保存写入门店");
|
|
|
+ return true;
|
|
|
+ } catch (e: unknown) {
|
|
|
+ if (isUploadUserCancelledError(e)) {
|
|
|
+ return false;
|
|
|
+ }
|
|
|
+ if (isUploadApiErrorAlreadyMessaged(e)) {
|
|
|
+ return false;
|
|
|
+ }
|
|
|
+ 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;
|
|
|
+ } finally {
|
|
|
+ auditLoading?.close();
|
|
|
+ uploading.value = false;
|
|
|
+ }
|
|
|
+}
|
|
|
+
|
|
|
+async function onFileInputChange(ev: Event): Promise<void> {
|
|
|
+ const input = ev.target as HTMLInputElement;
|
|
|
+ const file = input.files?.[0];
|
|
|
+ input.value = "";
|
|
|
+ if (!file) return;
|
|
|
+ await ingestPickedFile(file);
|
|
|
+}
|
|
|
+
|
|
|
+function clearDraft() {
|
|
|
+ draftCover.value = null;
|
|
|
+}
|
|
|
+
|
|
|
+async function onSave() {
|
|
|
+ if (saving.value) return;
|
|
|
+ if (!canSubmit.value) {
|
|
|
+ ElMessage.warning("请先上传一张图片或一段视频(仅 1 条)");
|
|
|
+ return;
|
|
|
+ }
|
|
|
+ const sidRaw = localGet("geeker-user")?.userInfo?.storeId;
|
|
|
+ const numericStoreId = sidRaw != null ? Number(sidRaw) : NaN;
|
|
|
+ if (!Number.isFinite(numericStoreId) || numericStoreId <= 0) {
|
|
|
+ ElMessage.error("门店信息缺失,无法保存");
|
|
|
+ return;
|
|
|
+ }
|
|
|
+
|
|
|
+ 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 res: any = await saveStoreHeadImg({
|
|
|
+ storeImgList,
|
|
|
+ storeId: numericStoreId,
|
|
|
+ imgType: IMG_TYPE_COVER,
|
|
|
+ imgMode: 0
|
|
|
+ });
|
|
|
+
|
|
|
+ if (res?.code !== 200 && res?.code !== "200") {
|
|
|
+ ElMessage.error(String(res?.msg || res?.message || "保存失败"));
|
|
|
+ return;
|
|
|
+ }
|
|
|
+
|
|
|
+ ElMessage.success(String(res?.msg || "保存成功"));
|
|
|
+ await fetchCoverDraft();
|
|
|
+ } catch {
|
|
|
+ ElMessage.error("保存失败");
|
|
|
+ } finally {
|
|
|
+ saving.value = false;
|
|
|
+ }
|
|
|
+}
|
|
|
+
|
|
|
+onMounted(async () => {
|
|
|
+ await fetchStoreDetail();
|
|
|
+ await fetchCoverDraft();
|
|
|
+});
|
|
|
+</script>
|
|
|
+
|
|
|
+<style scoped lang="scss">
|
|
|
+.store-cover-page {
|
|
|
+ min-height: 100%;
|
|
|
+ padding: 24px;
|
|
|
+ background: #ffffff;
|
|
|
+}
|
|
|
+.page-inner {
|
|
|
+ max-width: 960px;
|
|
|
+ margin: 0;
|
|
|
+}
|
|
|
+.preview-section {
|
|
|
+ margin-bottom: 28px;
|
|
|
+}
|
|
|
+.block-heading {
|
|
|
+ margin: 0 0 12px;
|
|
|
+ font-size: 18px;
|
|
|
+ font-weight: 700;
|
|
|
+ color: #151515;
|
|
|
+ text-align: left;
|
|
|
+}
|
|
|
+.preview-area {
|
|
|
+ max-width: 360px;
|
|
|
+}
|
|
|
+.preview-card {
|
|
|
+ padding: 12px;
|
|
|
+ background: #eff6ff;
|
|
|
+ border-radius: 12px;
|
|
|
+ box-shadow: 0 2px 12px rgb(0 0 0 / 8%);
|
|
|
+}
|
|
|
+.preview-card--blue {
|
|
|
+ border: 2px solid #60a5fa;
|
|
|
+}
|
|
|
+.preview-media {
|
|
|
+ position: relative;
|
|
|
+ overflow: hidden;
|
|
|
+ background: #f0f2f5;
|
|
|
+ border-radius: 8px 8px 0 0;
|
|
|
+}
|
|
|
+.preview-album-pill {
|
|
|
+ position: absolute;
|
|
|
+ right: 10px;
|
|
|
+ bottom: 10px;
|
|
|
+ z-index: 2;
|
|
|
+ display: inline-flex;
|
|
|
+ gap: 4px;
|
|
|
+ align-items: center;
|
|
|
+ padding: 6px 12px;
|
|
|
+ font-size: 12px;
|
|
|
+ line-height: 1;
|
|
|
+ color: #ffffff;
|
|
|
+ pointer-events: none;
|
|
|
+ background-color: rgb(0 0 0 / 60%);
|
|
|
+ border-radius: 20px;
|
|
|
+ .el-icon {
|
|
|
+ font-size: 12px;
|
|
|
+ }
|
|
|
+}
|
|
|
+.preview-img {
|
|
|
+ width: 100%;
|
|
|
+ height: 100%;
|
|
|
+ object-fit: cover;
|
|
|
+}
|
|
|
+.preview-img--single {
|
|
|
+ display: block;
|
|
|
+ width: 100%;
|
|
|
+ max-height: 198px;
|
|
|
+ object-fit: cover;
|
|
|
+}
|
|
|
+.preview-meta {
|
|
|
+ display: flex;
|
|
|
+ gap: 8px;
|
|
|
+ align-items: center;
|
|
|
+ justify-content: space-between;
|
|
|
+ padding: 6px 10px 10px;
|
|
|
+ background: #ffffff;
|
|
|
+ border-radius: 0 0 8px 8px;
|
|
|
+ .store-title {
|
|
|
+ margin-bottom: 6px;
|
|
|
+ font-size: 15px;
|
|
|
+ font-weight: 600;
|
|
|
+ color: #151515;
|
|
|
+ }
|
|
|
+ .preview-rate {
|
|
|
+ display: inline-flex;
|
|
|
+ align-items: center;
|
|
|
+ height: auto;
|
|
|
+ line-height: 1;
|
|
|
+ :deep(.el-rate__icon) {
|
|
|
+ margin-right: 0;
|
|
|
+ font-size: 18px;
|
|
|
+ }
|
|
|
+ :deep(.el-rate__item:not(:last-of-type) .el-rate__icon) {
|
|
|
+ margin-right: 3px;
|
|
|
+ }
|
|
|
+ :deep(.el-rate__text) {
|
|
|
+ padding: 0;
|
|
|
+ margin-left: 4px;
|
|
|
+ font-size: 14px;
|
|
|
+ }
|
|
|
+ }
|
|
|
+ .reviews-right {
|
|
|
+ display: inline-flex;
|
|
|
+ flex-shrink: 0;
|
|
|
+ gap: 2px;
|
|
|
+ align-items: center;
|
|
|
+ font-size: 14px;
|
|
|
+ color: #666666;
|
|
|
+ white-space: nowrap;
|
|
|
+ .reviews-arrow {
|
|
|
+ font-size: 14px;
|
|
|
+ color: #909399;
|
|
|
+ }
|
|
|
+ }
|
|
|
+}
|
|
|
+.upload-section {
|
|
|
+ margin-bottom: 32px;
|
|
|
+}
|
|
|
+.upload-layout {
|
|
|
+ display: flex;
|
|
|
+ flex-wrap: wrap;
|
|
|
+ gap: 32px 40px;
|
|
|
+ align-items: flex-start;
|
|
|
+}
|
|
|
+.upload-slot {
|
|
|
+ box-sizing: border-box;
|
|
|
+ display: flex;
|
|
|
+ flex-direction: column;
|
|
|
+ gap: 8px;
|
|
|
+ align-items: center;
|
|
|
+ justify-content: center;
|
|
|
+ width: 310px;
|
|
|
+ min-height: 200px;
|
|
|
+ padding: 20px;
|
|
|
+ cursor: pointer;
|
|
|
+ border: 2px dashed #cfd4dc;
|
|
|
+ border-radius: 12px;
|
|
|
+ transition:
|
|
|
+ border-color 0.2s,
|
|
|
+ background 0.2s;
|
|
|
+ &:hover:not(.uploading, .filled) {
|
|
|
+ background: rgb(246 248 250);
|
|
|
+ border-color: #909399;
|
|
|
+ }
|
|
|
+ &.uploading {
|
|
|
+ pointer-events: none;
|
|
|
+ cursor: wait;
|
|
|
+ opacity: 0.75;
|
|
|
+ }
|
|
|
+ &.filled {
|
|
|
+ padding: 0;
|
|
|
+ cursor: default;
|
|
|
+ background: #000000;
|
|
|
+ border-color: #e4e7ed;
|
|
|
+ border-style: solid;
|
|
|
+ border-radius: 10px;
|
|
|
+ }
|
|
|
+ &__plus {
|
|
|
+ font-size: 30px;
|
|
|
+ font-weight: 600;
|
|
|
+ color: #909399;
|
|
|
+ }
|
|
|
+ &__primary {
|
|
|
+ font-size: 15px;
|
|
|
+ color: #909399;
|
|
|
+ }
|
|
|
+ &__hint {
|
|
|
+ font-size: 12px;
|
|
|
+ line-height: 1.55;
|
|
|
+ color: #909399;
|
|
|
+ text-align: center;
|
|
|
+ }
|
|
|
+ &__preview {
|
|
|
+ position: relative;
|
|
|
+ width: 100%;
|
|
|
+ min-height: 180px;
|
|
|
+ max-height: 280px;
|
|
|
+ overflow: hidden;
|
|
|
+ border-radius: 10px;
|
|
|
+ }
|
|
|
+ &__preview--video {
|
|
|
+ background: #000000;
|
|
|
+ }
|
|
|
+ &__thumb {
|
|
|
+ display: block;
|
|
|
+ width: 100%;
|
|
|
+ max-height: 280px;
|
|
|
+ object-fit: contain;
|
|
|
+ }
|
|
|
+ &__thumb-v {
|
|
|
+ display: block;
|
|
|
+ width: 100%;
|
|
|
+ max-height: 280px;
|
|
|
+ object-fit: contain;
|
|
|
+ }
|
|
|
+ &__hover-overlay {
|
|
|
+ position: absolute;
|
|
|
+ inset: 0;
|
|
|
+ z-index: 2;
|
|
|
+ display: flex;
|
|
|
+ flex-direction: column;
|
|
|
+ gap: 18px;
|
|
|
+ align-items: center;
|
|
|
+ justify-content: center;
|
|
|
+ pointer-events: none;
|
|
|
+ background: rgb(80 80 80 / 92%);
|
|
|
+ border-radius: 10px;
|
|
|
+ opacity: 0;
|
|
|
+ transition: opacity 0.2s ease;
|
|
|
+ }
|
|
|
+ &__preview:hover &__hover-overlay {
|
|
|
+ pointer-events: auto;
|
|
|
+ opacity: 1;
|
|
|
+ }
|
|
|
+ &__action {
|
|
|
+ display: inline-flex;
|
|
|
+ gap: 8px;
|
|
|
+ align-items: center;
|
|
|
+ padding: 0;
|
|
|
+ font-size: 15px;
|
|
|
+ font-weight: 500;
|
|
|
+ line-height: 1;
|
|
|
+ color: #ffffff;
|
|
|
+ cursor: pointer;
|
|
|
+ background: none;
|
|
|
+ border: none;
|
|
|
+ border-radius: 0;
|
|
|
+ .el-icon {
|
|
|
+ font-size: 18px;
|
|
|
+ }
|
|
|
+ &:hover {
|
|
|
+ opacity: 0.9;
|
|
|
+ }
|
|
|
+ }
|
|
|
+}
|
|
|
+.upload-input-hidden {
|
|
|
+ position: absolute;
|
|
|
+ width: 0;
|
|
|
+ height: 0;
|
|
|
+ pointer-events: none;
|
|
|
+ opacity: 0;
|
|
|
+}
|
|
|
+.instruction-list {
|
|
|
+ flex: 1;
|
|
|
+ min-width: 260px;
|
|
|
+ padding: 0;
|
|
|
+ margin: 0;
|
|
|
+ list-style: none;
|
|
|
+ &__item {
|
|
|
+ display: flex;
|
|
|
+ gap: 10px;
|
|
|
+ align-items: flex-start;
|
|
|
+ margin-bottom: 14px;
|
|
|
+ font-size: 13px;
|
|
|
+ line-height: 1.65;
|
|
|
+ color: #575c66;
|
|
|
+ &:last-child {
|
|
|
+ margin-bottom: 0;
|
|
|
+ }
|
|
|
+ }
|
|
|
+ &__icon {
|
|
|
+ flex-shrink: 0;
|
|
|
+ margin-top: 2px;
|
|
|
+ font-size: 16px;
|
|
|
+ line-height: 1;
|
|
|
+ color: #409eff;
|
|
|
+ .el-icon {
|
|
|
+ display: block;
|
|
|
+ }
|
|
|
+ }
|
|
|
+}
|
|
|
+.save-footer {
|
|
|
+ display: flex;
|
|
|
+ justify-content: center;
|
|
|
+ margin-top: 8px;
|
|
|
+}
|
|
|
+.btn-save {
|
|
|
+ width: 100%;
|
|
|
+ max-width: 180px;
|
|
|
+ height: 44px;
|
|
|
+ margin: 0;
|
|
|
+ font-size: 16px;
|
|
|
+ font-weight: 600;
|
|
|
+ border-radius: 10px;
|
|
|
+}
|
|
|
+:deep(.btn-save.el-button--primary:not(.is-disabled)) {
|
|
|
+ --el-button-bg-color: #3b82f6;
|
|
|
+ --el-button-border-color: #3b82f6;
|
|
|
+ --el-button-hover-bg-color: #2563eb;
|
|
|
+ --el-button-hover-border-color: #2563eb;
|
|
|
+ --el-button-active-bg-color: #1d4ed8;
|
|
|
+ --el-button-active-border-color: #1d4ed8;
|
|
|
+}
|
|
|
+
|
|
|
+/* 禁用:无素材 / 上传中 → 置灰不可点;保存 loading 时仍为蓝色 */
|
|
|
+:deep(.btn-save.el-button--primary.is-disabled:not(.is-loading)),
|
|
|
+:deep(.btn-save.el-button--primary.is-disabled:not(.is-loading):hover) {
|
|
|
+ --el-button-bg-color: #ebedf0 !important;
|
|
|
+ --el-button-border-color: #ebedf0 !important;
|
|
|
+ --el-button-hover-bg-color: #ebedf0 !important;
|
|
|
+ --el-button-hover-border-color: #ebedf0 !important;
|
|
|
+ --el-button-disabled-bg-color: #ebedf0 !important;
|
|
|
+ --el-button-disabled-border-color: #ebedf0 !important;
|
|
|
+
|
|
|
+ color: #b1b8c4 !important;
|
|
|
+ cursor: not-allowed !important;
|
|
|
+ opacity: 1 !important;
|
|
|
+}
|
|
|
+.cover-preview-dialog__media {
|
|
|
+ display: block;
|
|
|
+ width: 100%;
|
|
|
+ max-height: 70vh;
|
|
|
+ object-fit: contain;
|
|
|
+}
|
|
|
+.cover-preview-dialog__video {
|
|
|
+ background: #000000;
|
|
|
+}
|
|
|
+
|
|
|
+@media screen and (width <= 640px) {
|
|
|
+ .upload-layout {
|
|
|
+ flex-direction: column;
|
|
|
+ }
|
|
|
+ .upload-slot {
|
|
|
+ width: 100%;
|
|
|
+ max-width: 360px;
|
|
|
+ }
|
|
|
+}
|
|
|
+</style>
|