ソースを参照

Merge remote-tracking branch 'origin/development' into uat

# Conflicts:
#	.env.production
LuTong 1 日 前
コミット
62d55ea97b

+ 4 - 2
.env.development

@@ -24,8 +24,10 @@ VITE_API_URL_PLATFORM = /api/alienStorePlatform
 
 # 开发环境跨域代理,支持配置多个
 # VITE_PROXY = [["/api","https://api.ailien.shop"]] #生产环境
-# /ai-upload 供 Tus 上传(见 src/utils/config.ts、src/api/modules/aiImageUpload.ts)
-VITE_PROXY = [["/api","http://120.26.186.130:8000"],["/ai-upload","https://uat.ailien.shop"]] # 邹建宇
+# /ai-upload:Tus/simple 上传
+# /cover-audit:封面合规审核 → http://124.93.18.180:9000(避免浏览器直连跨域)
+VITE_COVER_AUDIT_BASE = /cover-audit
+VITE_PROXY = [["/api","http://120.26.186.130:8000"],["/ai-upload","https://uat.ailien.shop"],["/cover-audit","http://124.93.18.180:9000"]] # 邹建宇
 
 
 # WebSocket 基础地址(分享等能力,与商家端一致)

+ 10 - 0
.env.production

@@ -22,6 +22,9 @@ VITE_DROP_CONSOLE = true
 VITE_PWA = true
 
 # 线上环境接口地址
+# VITE_PROXY = [["/api","http://localhost:8888"]]
+
+# 生产环境接口地址
 VITE_API = /api
 VITE_API_URL_STORE = /api/alienStore
 VITE_API_URL = /api/alienStore
@@ -34,6 +37,13 @@ VITE_PROXY = [["/alienStore","http://120.26.186.130:8000/alienStore"]]
 # AI接口
 VITE_PROXY_AI = [["/ai-api","http://124.93.18.180:9000"]]
 
+# 封面合规审核(预生产端口 9300;正式环境若端口不同请改此处或 nginx 反代为同源路径)
+VITE_COVER_AUDIT_BASE = http://124.93.18.180:9300
+
+# WebSocket:HTTPS 部署下「联系业主」等页面会使用 getWebSocketBase() → wss://当前访问域名/alienStore/socket
+# 可不配置 VITE_WS_BASE,避免写死域名与实际上线域名不一致。仅 HTTP/本地联调时再配 ws/wss。
+# VITE_WS_BASE = wss://test.ailien.shop/alienStore/socket
+
 # 上传请求:不配则走同源 /ai-upload(Nginx 反代示例)
 # location /ai-upload/ {
 #   proxy_pass https://upload.ailien.shop:8443/;

+ 3 - 0
.env.test

@@ -44,6 +44,9 @@ VITE_PROXY = [["/api","http://120.26.186.130:8000"],["/ai-upload","https://uat.a
 # 简单上传与 Tus:与 UAT 上传服务一致(不配则默认同源 /ai-upload,测试部署在非 uat 域名时常 404)
 VITE_AI_UPLOAD_BASE = https://uat.ailien.shop
 
+# 封面合规审核(测试环境端口 9100,需服务端 CORS 或 Nginx 反代)
+VITE_COVER_AUDIT_BASE = http://124.93.18.180:9100
+
 # 接口加密配置
 # 加密功能总开关
 VITE_API_ENCRYPTION_ENABLED = false

+ 46 - 0
src/api/modules/coverAudit.ts

@@ -0,0 +1,46 @@
+import { COVER_AUDIT_BASE_URL } from "@/utils/config";
+import { useUserStore } from "@/stores/modules/user";
+
+/**
+ * 封面素材合规审核(与商家端 `merchantSetInfo.getCoverAuditData`、路径一致)
+ * POST `/ai/auto-review/api/v1/cover_audit/audit`,body `{ url }` 为图片或视频直链。
+ * 基址:`COVER_AUDIT_BASE_URL`(固定 IP + 环境端口或 `VITE_COVER_AUDIT_BASE`)
+ */
+export async function getCoverAuditData(payload: { url: string }): Promise<Record<string, unknown>> {
+  const base = String(COVER_AUDIT_BASE_URL || "").replace(/\/+$/, "");
+  if (!base) {
+    throw new Error("未配置封面审核服务地址");
+  }
+
+  const userStore = useUserStore();
+  const token = userStore.token || "";
+  const reqUrl = `${base}/ai/auto-review/api/v1/cover_audit/audit`;
+
+  const res = await fetch(reqUrl, {
+    method: "POST",
+    headers: {
+      "Content-Type": "application/json",
+      ...(token ? { Authorization: token } : {})
+    },
+    body: JSON.stringify(payload),
+    credentials: "omit"
+  });
+
+  const text = await res.text();
+  let body: Record<string, unknown> = {};
+  try {
+    body = text ? (JSON.parse(text) as Record<string, unknown>) : {};
+  } catch {
+    throw new Error(text?.slice?.(0, 200) || "封面审核响应解析失败");
+  }
+
+  if (!res.ok) {
+    const msg =
+      (typeof body.msg === "string" && body.msg) ||
+      (typeof body.message === "string" && body.message) ||
+      `请求失败(${res.status})`;
+    throw new Error(msg);
+  }
+
+  return body;
+}

+ 190 - 17
src/api/upload.js

@@ -1,11 +1,39 @@
 import { useUserStore } from "@/stores/modules/user";
+import { useSimpleUploadOverlayStore } from "@/stores/modules/simpleUploadOverlay";
 import { ElMessage } from "element-plus";
 import { AI_UPLOAD_FILES_PUBLIC_BASE, BASE_AI_URL } from "@/utils/config";
 import { withSimpleUploadOverlay } from "@/utils/withSimpleUploadOverlay";
 
-/** 非 TUS 简单上传接口路径(与 Apifox「上传文件-非TUS」一致;开发环境经 VITE_PROXY /ai-upload 转发) */
+/** 简单上传的路径(不含 base) */
 const SIMPLE_UPLOAD_PATH = "/upload/simple";
 
+/**
+ * 已由 uploadFilesToOss 弹出过 ElMessage,调用方勿重复 error
+ * @param {unknown} err
+ */
+export function isUploadApiErrorAlreadyMessaged(err) {
+  return Boolean(err && typeof err === "object" && /** @type {{ __uploadMessageShown?: boolean }} */ (err).__uploadMessageShown);
+}
+
+/**
+ * 用户取消上传(关闭弹层、AbortSignal)或请求被顶替:不向用户弹出技术性英文(如 signal is aborted without reason)
+ * @param {unknown} err
+ * @returns {boolean}
+ */
+export function isUploadUserCancelledError(err) {
+  if (err == null || typeof err !== "object") return false;
+  const o = /** @type {{ name?: unknown; message?: unknown }} */ (err);
+  if (String(o.name ?? "") === "AbortError") return true;
+  const msg = String(o.message ?? "").toLowerCase();
+  if (!msg) return false;
+  return (
+    msg.includes("signal is aborted") ||
+    msg.includes("the user aborted") ||
+    msg.includes("aborted a request") ||
+    msg.includes("body stream was aborted")
+  );
+}
+
 /** 从路径或 fileType 解析文件后缀(如 jpg、mp4),用于生成带格式的文件名 */
 export function getFileExtension(filePath, fileType) {
   const pathPart = (filePath || "").split("?")[0];
@@ -147,6 +175,40 @@ function deepFindHttpUrl(val, depth = 0, maxDepth = 8) {
 }
 
 /**
+ * 递归查找上传接口返回的 `download_url` / `downloadUrl`(后台保存相册等常以该字段为 imgUrl)
+ * @param {unknown} val
+ * @param {number} depth
+ * @param {number} maxDepth
+ * @returns {string}
+ */
+function preferDownloadUrlFromBody(val, depth = 0, maxDepth = 10) {
+  if (depth > maxDepth || val == null) return "";
+  if (Array.isArray(val)) {
+    for (const item of val) {
+      const u = preferDownloadUrlFromBody(item, depth + 1, maxDepth);
+      if (u) return u;
+    }
+    return "";
+  }
+  if (typeof val !== "object") return "";
+  const o = /** @type {Record<string, unknown>} */ (val);
+  for (const k of ["download_url", "downloadUrl"]) {
+    const v = o[k];
+    if (typeof v === "string" && v.trim()) {
+      const t = v.trim();
+      const abs = normalizeToFileUrl(t);
+      if (abs) return abs;
+      if (/^https?:\/\//i.test(t)) return t;
+    }
+  }
+  for (const v of Object.values(o)) {
+    const u = preferDownloadUrlFromBody(v, depth + 1, maxDepth);
+    if (u) return u;
+  }
+  return "";
+}
+
+/**
  * 从 JSON 体中解析文件访问地址(兼容多种后端约定)
  * @param {unknown} body
  * @returns {string}
@@ -158,6 +220,17 @@ function pickUrlFromJsonBody(body) {
     return normalizeToFileUrl(body) || (body.trim().startsWith("http") ? body.trim() : "");
   }
 
+  if (typeof body === "object" && !Array.isArray(body)) {
+    const dUrl = preferDownloadUrlFromBody(body);
+    if (dUrl) return dUrl;
+  }
+
+  if (Array.isArray(body)) {
+    const dUrl = preferDownloadUrlFromBody(body);
+    if (dUrl) return dUrl;
+    return deepFindHttpUrl(body);
+  }
+
   if (typeof body !== "object" || Array.isArray(body)) {
     return deepFindHttpUrl(body);
   }
@@ -310,12 +383,12 @@ function assertSimpleUploadBusinessOk(parsed) {
 }
 
 /**
- * POST multipart:字段 file;可选 filename(此处不传则服务端用原名)
+ * POST multipart:字段 file;解析展示用地址与业务保存用的 download_url
  * @param {File} file
  * @param {{ signal?: AbortSignal }} [fetchOptions]
- * @returns {Promise<string>} 文件访问 URL
+ * @returns {Promise<{ url: string; downloadUrl: string; parsed: unknown }>}
  */
-async function postFileToSimpleUpload(file, fetchOptions = {}) {
+async function postFileToSimpleUploadDetailed(file, fetchOptions = {}) {
   const base = String(BASE_AI_URL || "").replace(/\/$/, "");
   if (!base) {
     throw new Error("未配置上传服务地址(VITE_AI_UPLOAD_BASE 或默认 /ai-upload)");
@@ -333,14 +406,24 @@ async function postFileToSimpleUpload(file, fetchOptions = {}) {
 
   const { signal } = fetchOptions;
 
-  const res = await fetch(`${base}${SIMPLE_UPLOAD_PATH}`, {
-    method: "POST",
-    headers,
-    /** 不带跨域 Cookie,减轻上传服务 CORS 要求(鉴权仅用 Authorization) */
-    credentials: "omit",
-    body: formData,
-    signal: signal ?? undefined
-  });
+  const uploadUrl = `${base}${SIMPLE_UPLOAD_PATH}`;
+  let res;
+  try {
+    res = await fetch(uploadUrl, {
+      method: "POST",
+      headers,
+      /** 不带跨域 Cookie,减轻上传服务 CORS 要求(鉴权仅用 Authorization) */
+      credentials: "omit",
+      body: formData,
+      signal: signal ?? undefined
+    });
+  } catch (err) {
+    if (isUploadUserCancelledError(err)) {
+      throw err instanceof Error ? err : new Error(String(err ?? "AbortError"));
+    }
+    console.error("[upload/simple] 上传请求失败:", uploadUrl, err);
+    throw new Error("上传失败");
+  }
 
   const rawText = await res.text();
   let parsed = null;
@@ -378,6 +461,8 @@ async function postFileToSimpleUpload(file, fetchOptions = {}) {
     throw new Error(moderationUserTip);
   }
 
+  const downloadUrl = (preferDownloadUrlFromBody(parsed) || "").trim();
+
   let url = pickUrlFromJsonBody(parsed);
   if (!url && trimmed.startsWith("http")) {
     url = trimmed;
@@ -404,6 +489,17 @@ async function postFileToSimpleUpload(file, fetchOptions = {}) {
     console.error("[upload/simple] 无法解析文件地址,响应片段:", trimmed.slice(0, 800));
     throw new Error("上传失败,未返回文件地址");
   }
+  return { url, downloadUrl, parsed };
+}
+
+/**
+ * POST multipart:字段 file;可选 filename(此处不传则服务端用原名)
+ * @param {File} file
+ * @param {{ signal?: AbortSignal }} [fetchOptions]
+ * @returns {Promise<string>} 文件访问 URL
+ */
+async function postFileToSimpleUpload(file, fetchOptions = {}) {
+  const { url } = await postFileToSimpleUploadDetailed(file, fetchOptions);
   return url;
 }
 
@@ -411,13 +507,15 @@ async function postFileToSimpleUpload(file, fetchOptions = {}) {
  * 上传文件:图片与视频均走同一接口 POST /upload/simple,formData 键 file。
  * @param {File | File[] | FileList} files 浏览器文件对象;支持单个 File、数组或 FileList
  * @param {string} [_fileType] 保留参数,兼容旧调用(当前不参与分支)
- * @param {{ showLoading?: boolean; skipSimpleUploadOverlay?: boolean }} [options]
+ * @param {{ showLoading?: boolean; skipSimpleUploadOverlay?: boolean; uploadSuccessMessage?: string | null; uploadOverlayTitle?: string }} [options]
  *   showLoading:在未使用全局上传弹层时,用 ElMessage 提示上传中
  *   skipSimpleUploadOverlay:为 true 时不展示 PopupLoading(不弹「上传成功」)
+ *   uploadSuccessMessage:传给弹层,`null` 表示上传成功不 toast(默认「上传成功」)
+ *   uploadOverlayTitle:弹层标题
  * @returns {Promise<string[]>} 上传成功后的文件 URL 列表
  */
 export async function uploadFilesToOss(files, _fileType, options = {}) {
-  const { showLoading = false, skipSimpleUploadOverlay = false } = options;
+  const { showLoading = false, skipSimpleUploadOverlay = false, uploadSuccessMessage, uploadOverlayTitle } = options;
   const fileArr = normalizeFiles(files);
   if (fileArr.length === 0) {
     throw new Error("请选择要上传的文件");
@@ -441,9 +539,17 @@ export async function uploadFilesToOss(files, _fileType, options = {}) {
 
     let uploadedUrls;
     if (skipSimpleUploadOverlay) {
-      uploadedUrls = await runUpload(null);
+      const overlaySignal = useSimpleUploadOverlayStore().getActiveAbortSignal?.();
+      uploadedUrls = await runUpload(overlaySignal ?? null);
     } else {
-      uploadedUrls = await withSimpleUploadOverlay(signal => runUpload(signal));
+      const overlayOpts =
+        uploadSuccessMessage !== undefined || uploadOverlayTitle
+          ? {
+              title: uploadOverlayTitle,
+              successMessage: uploadSuccessMessage
+            }
+          : undefined;
+      uploadedUrls = await withSimpleUploadOverlay(signal => runUpload(signal), overlayOpts);
     }
 
     closeLoading();
@@ -451,11 +557,78 @@ export async function uploadFilesToOss(files, _fileType, options = {}) {
   } catch (e) {
     closeLoading();
     console.error("上传失败", e);
-    if (e?.name === "AbortError") {
+    if (isUploadUserCancelledError(e)) {
+      throw e;
+    }
+    const msg = e?.message || "上传失败";
+    ElMessage.error(msg);
+    try {
+      if (e && typeof e === "object") {
+        Object.defineProperty(e, "__uploadMessageShown", { value: true, enumerable: false, configurable: true });
+      }
+    } catch (_) {
+      /* ignore */
+    }
+    throw e;
+  }
+}
+
+/**
+ * 单文件上传:返回列表展示用地址与业务保存用的 `download_url`(响应中无则为空字符串)
+ * @param {File} file
+ * @param {{ showLoading?: boolean; skipSimpleUploadOverlay?: boolean; uploadSuccessMessage?: string | null; uploadOverlayTitle?: string }} [options]
+ * @returns {Promise<{ fileUrl: string; downloadUrl: string }>}
+ */
+export async function uploadFileToOssWithDownloadMeta(file, options = {}) {
+  const { showLoading = false, skipSimpleUploadOverlay = false, uploadSuccessMessage, uploadOverlayTitle } = options;
+  if (!(file instanceof File)) {
+    throw new Error("请选择要上传的文件");
+  }
+
+  let closeLoading = () => {};
+  if (showLoading && skipSimpleUploadOverlay) {
+    const loading = ElMessage({ message: "上传中...", type: "info", duration: 0, showClose: false });
+    closeLoading = () => loading.close();
+  }
+
+  try {
+    const runUpload = async signal => {
+      const { url, downloadUrl } = await postFileToSimpleUploadDetailed(file, signal ? { signal } : {});
+      return { fileUrl: url, downloadUrl };
+    };
+
+    let result;
+    if (skipSimpleUploadOverlay) {
+      const overlaySignal = useSimpleUploadOverlayStore().getActiveAbortSignal?.();
+      result = await runUpload(overlaySignal ?? null);
+    } else {
+      const overlayOpts =
+        uploadSuccessMessage !== undefined || uploadOverlayTitle
+          ? {
+              title: uploadOverlayTitle,
+              successMessage: uploadSuccessMessage
+            }
+          : undefined;
+      result = await withSimpleUploadOverlay(signal => runUpload(signal), overlayOpts);
+    }
+
+    closeLoading();
+    return result;
+  } catch (e) {
+    closeLoading();
+    console.error("上传失败", e);
+    if (isUploadUserCancelledError(e)) {
       throw e;
     }
     const msg = e?.message || "上传失败";
     ElMessage.error(msg);
+    try {
+      if (e && typeof e === "object") {
+        Object.defineProperty(e, "__uploadMessageShown", { value: true, enumerable: false, configurable: true });
+      }
+    } catch (_) {
+      /* ignore */
+    }
     throw e;
   }
 }

ファイルの差分が大きいため隠しています
+ 6 - 0
src/assets/images/store-decoration-effect-preview.svg


ファイルの差分が大きいため隠しています
+ 14 - 0
src/assets/images/uDianShiImg4.svg


+ 14 - 0
src/assets/json/authMenuList.json

@@ -391,6 +391,20 @@
           }
         },
         {
+          "path": "/storeDecoration/storeCoverMap",
+          "name": "storeCoverMap",
+          "component": "/storeDecoration/storeCoverMap/index",
+          "meta": {
+            "icon": "Folder",
+            "title": "封面图",
+            "isLink": "",
+            "isHide": false,
+            "isFull": false,
+            "isAffix": false,
+            "isKeepAlive": false
+          }
+        },
+        {
           "path": "/storeDecoration/storeHeadMap",
           "name": "storeHeadMap",
           "component": "/storeDecoration/storeHeadMap/index",

+ 5 - 5
src/components/Upload/Img.vue

@@ -174,11 +174,11 @@ const uploadSuccess = () => {
  * @description 图片上传错误
  * */
 const uploadError = () => {
-  ElNotification({
-    title: "温馨提示",
-    message: "图片上传失败,请您重新上传!",
-    type: "error"
-  });
+  // ElNotification({
+  //   title: "温馨提示",
+  //   message: "图片上传失败,请您重新上传!",
+  //   type: "error"
+  // });
 };
 </script>
 

+ 7 - 7
src/components/Upload/Imgs.vue

@@ -83,7 +83,7 @@ interface UploadFileProps {
   height?: string; // 组件高度 ==> 非必传(默认为 150px)
   width?: string; // 组件宽度 ==> 非必传(默认为 150px)
   borderRadius?: string; // 组件边框圆角 ==> 非必传(默认为 8px)
-  onSuccess?: (url: string) => void;
+  onSuccess?: (url: string, response?: unknown, file?: UploadFile) => void;
   onVideoPreview?: (url: string) => void; // 点击视频「查看」时的回调,用于父级弹窗放大预览(不传则新标签页打开)
   showSuccessNotification?: boolean; // 是否显示上传成功通知 ==> 非必传(默认为 true)
   hideUploadTrigger?: boolean; // 是否隐藏上传入口(达限或自定义隐藏时使用)==> 非必传(默认为 false)
@@ -276,7 +276,7 @@ const handleHttpUpload = (options: UploadRequestOptions): Promise<unknown> => {
       emit("update:fileList", _fileList.value);
       if (props.onSuccess) {
         try {
-          const result = props.onSuccess(fileUrl);
+          const result = props.onSuccess(fileUrl, response, options.file as UploadFile);
           if (
             result !== undefined &&
             result !== null &&
@@ -404,11 +404,11 @@ const handleImageLoad = (event: Event) => {
  * @description 图片上传错误
  * */
 const uploadError = () => {
-  ElNotification({
-    title: "温馨提示",
-    message: "上传失败,请您重新上传!",
-    type: "error"
-  });
+  // ElNotification({
+  //   title: "温馨提示",
+  //   message: "上传失败,请您重新上传!",
+  //   type: "error"
+  // });
 };
 
 /**

+ 1 - 2
src/components/popupLoading/index.vue

@@ -67,7 +67,7 @@ const props = defineProps({
   }
 });
 
-const emit = defineEmits(["cancel", "update:show"]);
+const emit = defineEmits(["cancel"]);
 
 const displayPercent = computed(() => {
   const n = Number(props.percent);
@@ -95,7 +95,6 @@ const dashOffset = computed(() => {
 
 function onCancel() {
   emit("cancel");
-  emit("update:show", false);
 }
 </script>
 

+ 1 - 0
src/layouts/components/Header/components/NotificationDrawerContent.vue

@@ -469,6 +469,7 @@ function handleMessageItemClick(item: MessageItem) {
   const params = new URLSearchParams({
     receiverId: String(receiverId),
     uName: encodeURIComponent(item.senderName || "用户"),
+    storeName: encodeURIComponent(String(item.storeName ?? "")),
     userImage: item.avatar ? encodeURIComponent(item.avatar) : ""
   });
   router.push(`/storeDecorationManagement/decorationChat?${params.toString()}`);

+ 11 - 3
src/stores/modules/simpleUploadOverlay.ts

@@ -1,5 +1,6 @@
 import { defineStore } from "pinia";
 import { ref } from "vue";
+import { ElMessage } from "element-plus";
 
 let progressTimer: ReturnType<typeof setInterval> | null = null;
 let activeController: AbortController | null = null;
@@ -18,7 +19,7 @@ export const useSimpleUploadOverlayStore = defineStore("simple-upload-overlay",
   const cancelText = ref("取消上传");
 
   function beginUpload(opts?: { title?: string }) {
-    activeController?.abort();
+    activeController?.abort(new DOMException("已开始新的上传", "AbortError"));
     clearProgressTimer();
     activeController = new AbortController();
     title.value = opts?.title ?? "上传中";
@@ -45,8 +46,14 @@ export const useSimpleUploadOverlayStore = defineStore("simple-upload-overlay",
   }
 
   function userCancel() {
-    activeController?.abort();
+    activeController?.abort(new DOMException("用户已取消上传", "AbortError"));
     dismiss();
+    ElMessage.info("取消上传");
+  }
+
+  /** 供 skipSimpleUploadOverlay 的上传与 beginUpload 配套,把 fetch 绑到同一 AbortSignal */
+  function getActiveAbortSignal(): AbortSignal | undefined {
+    return activeController?.signal;
   }
 
   return {
@@ -57,6 +64,7 @@ export const useSimpleUploadOverlayStore = defineStore("simple-upload-overlay",
     beginUpload,
     bumpToComplete,
     dismiss,
-    userCancel
+    userCancel,
+    getActiveAbortSignal
   };
 });

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

@@ -66,6 +66,10 @@ declare interface ViteEnv {
   VITE_WS_BASE?: string;
   /** 上传 API 请求根(可选;不配则默认同源 /ai-upload,依赖 Vite/Nginx 反代) */
   VITE_AI_UPLOAD_BASE?: string;
+  /** 封面审核根:完整 URL 或同源代理路径(如 /cover-audit);不配则用 124.93.18.180 + 环境默认端口 */
+  VITE_COVER_AUDIT_BASE?: string;
+  /** 封面审核端口(仅当未配 VITE_COVER_AUDIT_BASE 时):与固定 IP 拼接 */
+  VITE_COVER_AUDIT_PORT?: string;
   /** 上传完成后对外访问 URL 前缀 */
   VITE_AI_FILES_PUBLIC_BASE?: string;
   /** 可选;官方相册视频 simple 上传服务根,不配则开发走相对路径 /dev-upload-ailien/...、生产默认 upload.ailien.shop:8443 */

+ 26 - 0
src/utils/config.ts

@@ -7,6 +7,32 @@ const trimSlash = (s: string) => s.replace(/\/$/, "");
 
 export const BASE_AI_URL = trimSlash(String(import.meta.env.VITE_AI_UPLOAD_BASE || "").trim() || "/ai-upload");
 
+/** 封面合规审核服务固定主机(与商家端 AI 网关一致) */
+const COVER_AUDIT_FIXED_HOST = "124.93.18.180";
+
+/**
+ * 封面审核:`POST …/ai/auto-review/api/v1/cover_audit/audit`
+ * - 优先 `VITE_COVER_AUDIT_BASE`:完整 URL(如 `http://124.93.18.180:9000`)或开发代理路径(如 `/cover-audit`)
+ * - 否则 `VITE_COVER_AUDIT_PORT`:与固定 IP 拼装
+ * - 再否则按构建模式:`development`→9000,`test`→9100,`production`→9300(预生产等可在 .env 覆盖)
+ */
+function resolveCoverAuditBase(): string {
+  const explicit = String(import.meta.env.VITE_COVER_AUDIT_BASE || "").trim();
+  if (explicit) {
+    return trimSlash(explicit);
+  }
+  let port = String(import.meta.env.VITE_COVER_AUDIT_PORT || "").trim();
+  if (!port) {
+    const mode = import.meta.env.MODE;
+    if (mode === "test") port = "9100";
+    else if (mode === "production") port = "9300";
+    else port = "9000";
+  }
+  return trimSlash(`http://${COVER_AUDIT_FIXED_HOST}:${port}`);
+}
+
+export const COVER_AUDIT_BASE_URL = resolveCoverAuditBase();
+
 /** 上传完成后对外可访问的文件 URL:`${AI_UPLOAD_FILES_PUBLIC_BASE}/${uploadId}` */
 export const AI_UPLOAD_FILES_PUBLIC_BASE = trimSlash(
   String(import.meta.env.VITE_AI_FILES_PUBLIC_BASE || "").trim() || "https://uat.ailien.shop/files"

+ 5 - 2
src/utils/withSimpleUploadOverlay.ts

@@ -8,10 +8,11 @@ function sleep(ms: number) {
 /**
  * 使用全局 PopupLoading 包裹「/upload/simple」类上传;成功后提示「上传成功」。
  * 取消(AbortError)不弹成功提示;失败时关闭弹层,由调用方决定是否 ElMessage.error。
+ * `successMessage === null` 时不弹成功提示(用于上传后还要继续审核等场景)。
  */
 export async function withSimpleUploadOverlay<T>(
   task: (signal: AbortSignal) => Promise<T>,
-  options?: { title?: string; successMessage?: string }
+  options?: { title?: string; successMessage?: string | null }
 ): Promise<T> {
   const overlay = useSimpleUploadOverlayStore();
   const signal = overlay.beginUpload({ title: options?.title });
@@ -20,7 +21,9 @@ export async function withSimpleUploadOverlay<T>(
     overlay.bumpToComplete();
     await sleep(280);
     overlay.dismiss();
-    ElMessage.success(options?.successMessage ?? "上传成功");
+    if (options?.successMessage !== null) {
+      ElMessage.success(options?.successMessage ?? "上传成功");
+    }
     return result;
   } catch (e) {
     overlay.dismiss();

+ 13 - 11
src/utils/wsBase.ts

@@ -1,28 +1,30 @@
 /**
  * WebSocket 基础地址
- * - 优先使用 .env 中的 VITE_WS_BASE(若为 https 页面且配的是 ws://,会自动改为 wss + 当前 host
- * - 若当前页面为 HTTPS,强制使用 wss + 当前 host(走 Nginx 443),避免混合内容被拦截
- * - 若页面是 HTTP 且访问 IP/与 VITE_WS_BASE 中 wss 域名不一致,改用 ws://当前主机:8000(避免在 http://IP 下仍连 wss://其它域导致握手失败)
+ * - HTTPS:wss://当前页面 host/alienStore/socket(不写死域名/IP,UAT/生产/测试域名通用
+ * - HTTP:优先 VITE_WS_BASE;若 env 为异名 wss 则回退 ws://当前 hostname + VITE_WS_DIRECT_PORT(默认 8000)直连 store
+ * - 不配 env 的 HTTP:同上,用当前 hostname,避免写死某台 SIT IP
  */
 export function getWebSocketBase(): string {
   const envBase = (import.meta.env.VITE_WS_BASE as string | undefined)?.trim();
+  const directPort = String(import.meta.env.VITE_WS_DIRECT_PORT || "8000").replace(/^:/, "");
+
+  const httpDirectWs = (hostname: string) => `ws://${hostname}:${directPort}/alienStore/socket`;
+
   if (typeof window !== "undefined" && window.location) {
     const isHttps = window.location.protocol === "https:";
-    const host = window.location.host; // hostname + port(如 120.26.186.130 或 uat.ailien.shop)
-    // 页面是 HTTPS 时,必须用 wss,且用当前 host(不写死端口),由 Nginx 443 代理到后端
+    const host = window.location.host; // hostname + port(如 uat.ailien.shop)
     if (isHttps) {
       return `wss://${host}/alienStore/socket`;
     }
+    const locHost = window.location.hostname;
     if (envBase && envBase.trim()) {
       const e = envBase.replace(/\/$/, "");
-      const locHost = window.location.hostname;
       const isLocal = locHost === "localhost" || locHost === "127.0.0.1";
-      // 例如:浏览器打开 http://120.26.186.130/... 但 .env 写死 wss://test.ailien.shop → 跨域 wss 常失败;同机 store 多监听 8000
-      if (!isHttps && e.startsWith("wss://") && !isLocal) {
+      if (e.startsWith("wss://") && !isLocal) {
         try {
           const envHost = new URL(e.replace(/^wss:/, "https:")).hostname;
           if (envHost !== locHost) {
-            return `ws://${locHost}:8000/alienStore/socket`;
+            return httpDirectWs(locHost);
           }
         } catch (_) {
           /* ignore */
@@ -30,10 +32,10 @@ export function getWebSocketBase(): string {
       }
       return e;
     }
-    return "ws://120.26.186.130:8000/alienStore/socket";
+    return httpDirectWs(locHost);
   }
   if (envBase) {
     return envBase.replace(/\/$/, "");
   }
-  return "ws://120.26.186.130:8000/alienStore/socket";
+  return `ws://127.0.0.1:${directPort}/alienStore/socket`;
 }

+ 5 - 3
src/views/dynamicManagement/publishDynamic.vue

@@ -133,7 +133,7 @@ import type { FormInstance, FormRules, UploadUserFile, UploadFile } from "elemen
 // import { publishDynamic, saveDraft, uploadDynamicImage } from "@/api/modules/dynamicManagement";
 // import { addOrUpdateDynamic } from "@/api/modules/dynamicManagement";
 import { addOrUpdateDynamic } from "@/api/modules/newLoginApi";
-import { uploadFilesToOss } from "@/api/upload.js";
+import { uploadFilesToOss, isUploadUserCancelledError } from "@/api/upload.js";
 import { useSimpleUploadOverlayStore } from "@/stores/modules/simpleUploadOverlay";
 import { useUserStore } from "@/stores/modules/user";
 import { getUserDraftDynamics, getInputPrompt } from "@/api/modules/newLoginApi";
@@ -428,8 +428,10 @@ const uploadSingleFile = async (file: UploadFile) => {
       formData.images.push(fileUrl);
     }
   } catch (error: any) {
-    publishSimpleOverlayHadError = true;
-    console.error("上传失败:", error);
+    if (!isUploadUserCancelledError(error)) {
+      publishSimpleOverlayHadError = true;
+      console.error("上传失败:", error);
+    }
     file.status = "fail";
     if (file.url && file.url.startsWith("blob:")) {
       URL.revokeObjectURL(file.url);

+ 27 - 19
src/views/storeDecoration/decorationChat.vue

@@ -4,7 +4,7 @@
       <el-button text @click="handleBack" class="back-btn">
         <el-icon><ArrowLeft /></el-icon>
       </el-button>
-      <span class="chat-title">{{ ownerName || "联系业主" }}</span>
+      <span class="chat-title">{{ storeName || "联系业主" }}</span>
     </div>
 
     <div ref="chatContainerRef" class="chat-content" @scroll="handleScroll">
@@ -124,10 +124,18 @@ const socketStore = useWebSocketStore();
 
 const isWsReady = () => socketStore.isSocketOpen();
 
+/** 接口与 WebSocket:相关 id 需带 store_ 前缀(已带则不变) */
+const ensureStoreSenderId = (id: string) => {
+  const s = String(id ?? "").trim();
+  if (!s) return s;
+  return /^store_/i.test(s) ? s : `store_${s}`;
+};
+
 // 会话信息
 const sendId = ref("");
 const receiverId = ref("");
-const ownerName = ref("");
+/** 左上角标题:店铺名称(由列表/详情跳转时 query.storeName 传入) */
+const storeName = ref("");
 const ownerAvatar = ref("");
 const requirementId = ref("");
 
@@ -184,8 +192,8 @@ const handleSend = async () => {
   const ok = await socketStore.sendMessage({
     category: "message",
     type: 1,
-    receiverId: receiverId.value,
-    senderId: sendId.value,
+    receiverId: ensureStoreSenderId(receiverId.value),
+    senderId: ensureStoreSenderId(sendId.value),
     text
   });
   sending.value = false;
@@ -196,8 +204,8 @@ const handleSend = async () => {
         id: `temp_${Date.now()}`,
         type: 1,
         content: text,
-        senderId: sendId.value,
-        receiverId: receiverId.value,
+        senderId: ensureStoreSenderId(sendId.value),
+        receiverId: ensureStoreSenderId(receiverId.value),
         createdTime: formatTime(new Date())
       }
     ];
@@ -245,8 +253,8 @@ const handleImageSelect = async (e: Event) => {
     const ok = await socketStore.sendMessage({
       category: "message",
       type: 2,
-      receiverId: receiverId.value,
-      senderId: sendId.value,
+      receiverId: ensureStoreSenderId(receiverId.value),
+      senderId: ensureStoreSenderId(sendId.value),
       text: fileUrl
     });
     if (ok) {
@@ -256,8 +264,8 @@ const handleImageSelect = async (e: Event) => {
           id: `temp_${Date.now()}`,
           type: 2,
           content: fileUrl,
-          senderId: sendId.value,
-          receiverId: receiverId.value,
+          senderId: ensureStoreSenderId(sendId.value),
+          receiverId: ensureStoreSenderId(receiverId.value),
           createdTime: formatTime(new Date())
         }
       ];
@@ -295,8 +303,8 @@ const handleVideoSelect = async (e: Event) => {
     const ok = await socketStore.sendMessage({
       category: "message",
       type: 8,
-      receiverId: receiverId.value,
-      senderId: sendId.value,
+      receiverId: ensureStoreSenderId(receiverId.value),
+      senderId: ensureStoreSenderId(sendId.value),
       text: fileUrl
     });
     if (ok) {
@@ -306,8 +314,8 @@ const handleVideoSelect = async (e: Event) => {
           id: `temp_${Date.now()}`,
           type: 8,
           content: fileUrl,
-          senderId: sendId.value,
-          receiverId: receiverId.value,
+          senderId: ensureStoreSenderId(sendId.value),
+          receiverId: ensureStoreSenderId(receiverId.value),
           createdTime: formatTime(new Date())
         }
       ];
@@ -329,7 +337,7 @@ const readMessage = async () => {
   try {
     await messageRead({
       receiverId: sendId.value,
-      senderId: receiverId.value
+      senderId: ensureStoreSenderId(receiverId.value)
     });
   } catch (e) {
     console.error("消息已读接口调用失败", e);
@@ -346,7 +354,7 @@ const loadChatRecord = async () => {
     await readMessage();
     const res: any = await getChatRecord({
       receiverId: sendId.value,
-      senderId: receiverId.value
+      senderId: ensureStoreSenderId(receiverId.value)
     });
     const list = res?.data?.messageList || res?.messageList || [];
     list.forEach((item: any) => {
@@ -368,7 +376,7 @@ const initWebSocket = async () => {
     ElMessage.warning("未获取到商家信息,无法连接");
     return;
   }
-  sendId.value = `store_${phone}`;
+  sendId.value = ensureStoreSenderId(String(phone));
   // 与打包 mode 无关:HTTPS 走 wss://当前域名/alienStore/socket,避免 .env.production 仍为 ws 导致混合内容拦截
   const WS_BASE = getWebSocketBase();
   const wsUrl =
@@ -464,7 +472,7 @@ watch(
   q => {
     if (q?.receiverId) {
       receiverId.value = String(q.receiverId);
-      ownerName.value = decodeURIComponent(String(q.uName || q.ownerName || "业主"));
+      storeName.value = decodeURIComponent(String(q.storeName || ""));
       ownerAvatar.value = q.userImage ? decodeURIComponent(String(q.userImage)) : "";
       requirementId.value = String(q.id || q.requirementId || "");
       loadChatRecord();
@@ -475,7 +483,7 @@ watch(
 
 onMounted(async () => {
   receiverId.value = String(route.query.receiverId || "");
-  ownerName.value = decodeURIComponent(String(route.query.uName || route.query.ownerName || "业主"));
+  storeName.value = decodeURIComponent(String(route.query.storeName || ""));
   ownerAvatar.value = route.query.userImage ? decodeURIComponent(String(route.query.userImage)) : "";
   requirementId.value = String(route.query.id || route.query.requirementId || "");
 

+ 1 - 0
src/views/storeDecoration/decorationCompany.vue

@@ -100,6 +100,7 @@ const handleContact = (row: any) => {
   const params = new URLSearchParams({
     receiverId: String(receiverId),
     uName: encodeURIComponent(row.contactName || row.userNickname || "业主"),
+    storeName: encodeURIComponent(String(row.storeName ?? "")),
     userImage: encodeURIComponent(row.storeAvatar || ""),
     id: String(row.id || "")
   });

+ 173 - 56
src/views/storeDecoration/decorationCompanyDetail.vue

@@ -65,16 +65,49 @@
         </el-form-item>
 
         <el-form-item label="上传房屋图纸" required>
-          <el-upload
-            v-model:file-list="fileList"
-            list-type="picture-card"
-            :disabled="true"
-            :on-preview="handlePictureCardPreview"
-            :on-remove="handleRemove"
-          >
-            <el-icon><Plus /></el-icon>
-          </el-upload>
-          <div class="upload-tip">({{ fileList.length }}/9)</div>
+          <div class="attachment-wrap">
+            <div class="attachment-grid">
+              <div v-for="(url, index) in formData.attachmentUrls" :key="`${index}-${url}`" class="attachment-card">
+                <template v-if="isAttachmentVideo(url)">
+                  <div
+                    class="attachment-card-inner attachment-card-inner--video"
+                    @click="openVideoPreview(getAttachmentVideoSrc(url))"
+                  >
+                    <video
+                      class="attachment-video-thumb"
+                      :poster="getVideoThumbPoster(url) || undefined"
+                      :src="getAttachmentVideoSrc(url)"
+                      muted
+                      preload="metadata"
+                      playsinline
+                    />
+                    <div class="video-overlay">
+                      <el-icon class="play-icon">
+                        <VideoPlay />
+                      </el-icon>
+                    </div>
+                  </div>
+                </template>
+                <el-image
+                  v-else
+                  :src="url"
+                  fit="cover"
+                  class="attachment-image"
+                  :preview-src-list="imagePreviewList"
+                  :initial-index="getImagePreviewIndex(url)"
+                  preview-teleported
+                />
+              </div>
+              <div
+                v-if="formData.attachmentUrls.length < 9"
+                class="attachment-card attachment-card--placeholder"
+                aria-hidden="true"
+              >
+                <el-icon><Plus /></el-icon>
+              </div>
+            </div>
+            <div class="upload-tip">({{ formData.attachmentUrls.length }}/9)</div>
+          </div>
         </el-form-item>
 
         <el-form-item label="联系人" required>
@@ -100,22 +133,23 @@
       </div>
     </div>
 
-    <!-- 图片预览 -->
-    <el-image-viewer
-      v-if="imageViewerVisible"
-      :url-list="imageViewerUrlList"
-      :initial-index="imageViewerInitialIndex"
-      @close="imageViewerVisible = false"
-    />
+    <el-dialog
+      v-model="videoDialogVisible"
+      title="视频预览"
+      width="min(640px, 92vw)"
+      destroy-on-close
+      @close="previewVideoUrl = ''"
+    >
+      <video v-if="previewVideoUrl" :src="previewVideoUrl" controls autoplay class="dialog-video" />
+    </el-dialog>
   </div>
 </template>
 
 <script setup lang="ts" name="decorationCompanyDetail">
-import { ref, onMounted, watch } from "vue";
+import { ref, computed, onMounted, watch } from "vue";
 import { useRoute, useRouter } from "vue-router";
 import { ElMessage } from "element-plus";
-import { Plus, Close } from "@element-plus/icons-vue";
-import type { UploadFile } from "element-plus";
+import { Plus, Close, VideoPlay } from "@element-plus/icons-vue";
 import { getDecorationDetail } from "@/api/modules/storeDecoration";
 
 const route = useRoute();
@@ -142,10 +176,60 @@ const formData = ref<any>({
   userId: "" // 兼容字段
 });
 
-const fileList = ref<UploadFile[]>([]);
-const imageViewerVisible = ref(false);
-const imageViewerUrlList = ref<string[]>([]);
-const imageViewerInitialIndex = ref(0);
+const videoDialogVisible = ref(false);
+const previewVideoUrl = ref("");
+
+/** 与 caseDetail / 常见上传一致:扩展名或「视频|封面」 */
+const isAttachmentVideo = (raw: string) => {
+  if (!raw || typeof raw !== "string") return false;
+  if (raw.includes("|")) return true;
+  return /\.(mp4|avi|mov|wmv|flv|webm|m4v|3gp)(\?|#|$)/i.test(raw);
+};
+
+const getAttachmentVideoSrc = (raw: string) => {
+  if (!raw || typeof raw !== "string") return "";
+  if (raw.includes("|")) return raw.split("|")[0].trim();
+  return raw;
+};
+
+const getExplicitVideoCover = (raw: string) => {
+  if (!raw || typeof raw !== "string" || !raw.includes("|")) return "";
+  const parts = raw
+    .split("|")
+    .map(s => s.trim())
+    .filter(Boolean);
+  return parts.length >= 2 ? parts[1] : "";
+};
+
+/** OSS 视频首帧快照,与 storeCoverMap 一致,利于卡片缩略图 */
+const getOssVideoSnapshotUrl = (videoUrl: string) => {
+  if (!videoUrl || typeof videoUrl !== "string") return "";
+  const isOss =
+    videoUrl.includes("aliyuncs.com") ||
+    videoUrl.includes("oss-cn-") ||
+    videoUrl.includes("oss.") ||
+    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`;
+};
+
+const getVideoThumbPoster = (raw: string) => {
+  const explicit = getExplicitVideoCover(raw);
+  if (explicit) return explicit;
+  const src = getAttachmentVideoSrc(raw);
+  return getOssVideoSnapshotUrl(src);
+};
+
+const imagePreviewList = computed(() => (formData.value.attachmentUrls || []).filter((u: string) => u && !isAttachmentVideo(u)));
+
+const getImagePreviewIndex = (url: string) => imagePreviewList.value.indexOf(url);
+
+const openVideoPreview = (url: string) => {
+  if (!url) return;
+  previewVideoUrl.value = url;
+  videoDialogVisible.value = true;
+};
 
 // 联系业主(跳转聊天页面)
 const handleContact = () => {
@@ -158,6 +242,7 @@ const handleContact = () => {
   const params = new URLSearchParams({
     receiverId: String(receiverId),
     uName: encodeURIComponent(data.contactName || data.userNickname || "业主"),
+    storeName: encodeURIComponent(String(data.storeName ?? "")),
     userImage: encodeURIComponent(data.storeAvatar || ""),
     id: String(route.query.id || "")
   });
@@ -196,16 +281,6 @@ const initData = async () => {
         createUserId: data.createUserId || data.userId || "",
         userId: data.userId || data.createUserId || ""
       };
-
-      // 处理附件列表
-      if (formData.value.attachmentUrls && formData.value.attachmentUrls.length > 0) {
-        fileList.value = formData.value.attachmentUrls.map((url: string, index: number) => ({
-          uid: index,
-          name: `图片${index + 1}`,
-          url: url,
-          status: "success"
-        }));
-      }
     }
   } catch (error: any) {
     console.error("获取详情失败:", error);
@@ -213,20 +288,6 @@ const initData = async () => {
   }
 };
 
-// 图片预览
-const handlePictureCardPreview = (file: UploadFile) => {
-  if (file.url) {
-    imageViewerUrlList.value = fileList.value.map((item: UploadFile) => item.url || "").filter(Boolean);
-    imageViewerInitialIndex.value = fileList.value.findIndex((item: UploadFile) => item.uid === file.uid);
-    imageViewerVisible.value = true;
-  }
-};
-
-// 删除图片(详情页禁用,这里只是占位)
-const handleRemove = () => {
-  // 详情页不允许删除
-};
-
 // 关闭页面
 const handleClose = () => {
   router.go(-1);
@@ -283,19 +344,75 @@ onMounted(() => {
     font-size: 12px;
     color: #999999;
   }
+  .attachment-wrap {
+    width: 100%;
+  }
+  .attachment-grid {
+    display: flex;
+    flex-wrap: wrap;
+    gap: 8px;
+    align-items: flex-start;
+  }
+  .attachment-card {
+    box-sizing: border-box;
+    width: 100px;
+    height: 100px;
+    overflow: hidden;
+    background: #fafafa;
+    border: 1px solid #e4e7ed;
+    border-radius: 4px;
+  }
+  .attachment-card--placeholder {
+    display: flex;
+    align-items: center;
+    justify-content: center;
+    font-size: 24px;
+    color: #8c939d;
+    pointer-events: none;
+    border-style: dashed;
+  }
+  .attachment-card-inner {
+    position: relative;
+    width: 100%;
+    height: 100%;
+    cursor: pointer;
+  }
+  .attachment-image {
+    display: block;
+    width: 100%;
+    height: 100%;
+  }
+  .attachment-video-thumb {
+    display: block;
+    width: 100%;
+    height: 100%;
+    vertical-align: top;
+    pointer-events: none;
+    object-fit: cover;
+  }
+  .video-overlay {
+    position: absolute;
+    inset: 0;
+    display: flex;
+    align-items: center;
+    justify-content: center;
+    pointer-events: none;
+    background: rgb(0 0 0 / 25%);
+  }
+  .video-overlay .play-icon {
+    font-size: 28px;
+    color: #ffffff;
+  }
+  .dialog-video {
+    display: block;
+    width: 100%;
+    max-height: 70vh;
+  }
   .detail-footer {
     padding: 20px 0 0;
     margin-top: 20px;
     text-align: center;
     border-top: 1px solid #ebeef5;
   }
-  :deep(.el-upload--picture-card) {
-    width: 100px;
-    height: 100px;
-  }
-  :deep(.el-upload-list--picture-card .el-upload-list__item) {
-    width: 100px;
-    height: 100px;
-  }
 }
 </style>

ファイルの差分が大きいため隠しています
+ 68 - 700
src/views/storeDecoration/officialPhotoAlbum/index.vue


+ 833 - 0
src/views/storeDecoration/storeCoverMap/index.vue

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

+ 218 - 452
src/views/storeDecoration/storeHeadMap/index.vue

@@ -1,511 +1,277 @@
 <template>
-  <div class="head-map-container">
+  <div class="store-map-readonly" v-loading="loading">
     <div class="content-wrapper">
-      <!-- 顶部:大图模式预览 -->
-      <div class="mode-preview-section">
-        <div class="preview-box">
-          <div class="preview-card">
-            <div class="preview-images-carousel">
-              <el-carousel
-                v-if="carouselPreviewImages.length > 0"
-                height="250px"
-                :autoplay="carouselPreviewImages.length > 1"
-                :interval="4000"
-                indicator-position="none"
-                :arrow="carouselPreviewImages.length > 1 ? 'hover' : 'never'"
-                class="head-preview-carousel"
-              >
-                <el-carousel-item v-for="(url, index) in carouselPreviewImages" :key="index">
-                  <img :src="url" alt="头图" class="carousel-preview-img" />
-                </el-carousel-item>
-              </el-carousel>
-              <div v-else class="carousel-preview-empty">
-                <div class="preview-placeholder">示例图</div>
-              </div>
-              <div class="overlay-button">
-                <span>相册</span>
-                <el-icon><ArrowRight /></el-icon>
-              </div>
+      <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 class="store-title">
+              {{ storeNameDisplay }}
             </div>
-            <div class="preview-info">
-              <div class="store-name">示例门店</div>
-              <div class="store-rating">
-                <el-rate v-model="previewData.rating" disabled show-score text-color="#ff9900" score-template="{value}" />
+            <div class="store-rating-row">
+              <div class="rating-left">
+                <el-rate :model-value="5" disabled show-score text-color="#f47d1f" score-template="{value}" />
+              </div>
+              <div class="reviews-right">
+                {{ previewReviews }} 条评价
+                <el-icon class="reviews-arrow">
+                  <ArrowRight />
+                </el-icon>
               </div>
-              <div class="store-reviews">{{ previewData.reviews }} 条评价</div>
-              <el-icon class="arrow-icon">
-                <ArrowRight />
-              </el-icon>
             </div>
           </div>
-          <div class="mode-label">
-            <span>大图模式</span>
-          </div>
         </div>
       </div>
 
-      <!-- 中间:上传区域 -->
-      <div class="upload-section">
-        <h2 class="section-title">图片</h2>
-
-        <div class="instructions">
-          <ol class="instruction-list">
-            <li>图片会在店铺头图区域展示。</li>
-            <li>至少上传3张图片,可拖拽排序。</li>
-            <li>建议上传16:9尺寸的图片,大小不超过20MB 避免图片出现显示不全问题。</li>
-          </ol>
+      <h2 class="block-heading">头图</h2>
+      <div class="source-panel">
+        <div class="source-frame">
+          <span class="source-badge" v-if="firstImageUrl" role="button" tabindex="0" @click.stop="openHeadPreview(0)">查看</span>
+          <img
+            v-if="firstImageUrl"
+            :src="firstImageUrl"
+            alt="头图"
+            class="source-img source-img--clickable"
+            @click.stop="openHeadPreview(0)"
+          />
+          <div v-else class="source-empty">暂无图片</div>
         </div>
-
-        <el-form ref="multipleFormRef" :model="formData" :rules="multipleRules" label-width="0">
-          <el-form-item prop="multipleImages">
-            <div class="multiple-upload-wrapper" :class="{ 'upload-full': formData.multipleImages.length >= 6 }">
-              <UploadImgs
-                v-model:file-list="formData.multipleImages"
-                :limit="6"
-                :file-size="20"
-                :file-type="['image/jpeg', 'image/png', 'image/gif', 'image/webp']"
-                :width="'200px'"
-                :height="'112px'"
-                :border-radius="'8px'"
-                :api="uploadImageResult"
-                :on-success="handleStoreOcrAfterUploadMore"
-                :show-success-notification="false"
-              >
-                <template #tip>
-                  <div class="upload-tip">上传图片({{ formData.multipleImages.length }}/6)</div>
-                </template>
-              </UploadImgs>
-            </div>
-          </el-form-item>
-        </el-form>
-      </div>
-
-      <!-- 底部:保存按钮 -->
-      <div class="footer">
-        <el-button type="primary" size="large" :loading="loading" @click="handleSave"> 保存 </el-button>
       </div>
     </div>
+
+    <el-image-viewer
+      v-if="headPreviewVisible"
+      :url-list="imageUrls"
+      :initial-index="headPreviewInitialIndex"
+      @close="headPreviewVisible = false"
+    />
   </div>
 </template>
 
 <script setup lang="ts">
-import { ref, reactive, computed, onMounted, watch, nextTick } from "vue";
-import { ElMessage, ElNotification } from "element-plus";
-import type { FormInstance, FormRules } from "element-plus";
-import type { UploadUserFile } from "element-plus";
-
-/** 服务端图片主键,用于 saveOrUpdateImg 区分更新与新增 */
-type HeadMapUploadFile = UploadUserFile & { serverImgId?: number | string };
+import { ref, computed, onMounted } from "vue";
 import { ArrowRight } from "@element-plus/icons-vue";
-import Sortable from "sortablejs";
-import UploadImgs from "@/components/Upload/Imgs.vue";
-import { getStoreOcrData } from "@/api/modules/newLoginApi";
-import { uploadFilesToOss } from "@/api/upload.js";
-import { getStoreHeadImg, saveStoreHeadImg } from "@/api/modules/storeDecoration";
+import { getStoreHeadImg } from "@/api/modules/storeDecoration";
 import { getDetail } from "@/api/modules/homeEntry";
 import { localGet } from "@/utils";
-import { useRouter } from "vue-router";
+import previewDemoImg from "@/assets/images/uDianShiImg4.svg";
 
-const loading = ref(false);
-const multipleFormRef = ref<FormInstance>();
-const sortableInstance = ref<Sortable | null>(null);
-const router = useRouter();
-const storeName = ref("");
-
-const handleStoreOcrAfterUploadMore = async (imageUrl: string) => {
-  try {
-    await handleStoreOcr({ imageUrl });
-  } catch (error) {
-    formData.multipleImages = formData.multipleImages.filter(item => item.url !== imageUrl);
-    throw error;
-  }
-};
-
-const handleStoreOcr = async (params: { imageUrl: string }) => {
-  try {
-    const res: any = await getStoreOcrData({ imageUrl: params.imageUrl, merchantName: storeName.value });
-    if (res.code === 200) {
-      if (res.data.overall_match) {
-        ElNotification({
-          title: "OCR识别成功",
-          message: `识别结果: ${res.msg || "无"}`,
-          type: "success",
-          duration: 5000
-        });
-      } else {
-        ElNotification({
-          title: "OCR识别失败",
-          message: "图片不符合规范,请重新上传",
-          type: "error",
-          duration: 5000
-        });
-        formData.multipleImages = [];
-      }
-    } else {
-      const errorMsg = res?.msg || "未查询到OCR识别数据";
-      ElMessage.error(errorMsg);
-      const error = new Error(errorMsg);
-      formData.multipleImages = [];
-      (error as any).handled = true;
-      throw error;
-    }
-  } catch (error: any) {
-    if (!error.handled) {
-    }
-    throw error;
-  }
-};
+/** 与商户端头图 imgType 一致 */
+const IMG_TYPE_HEAD = 22;
 
-const formData = reactive({
-  multipleImages: [] as HeadMapUploadFile[]
-});
-
-const previewData = reactive({
-  rating: 5.0,
-  reviews: 1853
-});
-
-/** 预览轮播:展示已上传头图,一次一张 */
-const carouselPreviewImages = computed(() => {
-  return formData.multipleImages.map(item => item.url || "").filter((url): url is string => Boolean(url));
-});
-
-/**
- * OSS 直传:满足 UploadImgs(读 response.fileUrl)。
- * 第二参由 UploadImgs 透传(如 skipSimpleUploadOverlay),多图/连续选图只走组件侧统一弹层。
- */
-const uploadImageResult = async (payload: FormData, options?: Record<string, unknown>): Promise<any> => {
-  const raw = payload.get("file");
-  const file = raw instanceof File ? raw : null;
-  if (!file) {
-    throw new Error("请选择文件");
-  }
-  const isVideo = typeof file.type === "string" && file.type.startsWith("video/");
-  const urls = await uploadFilesToOss(file, isVideo ? "video" : "image", options ?? {});
-  const url = urls[0];
-  if (!url) {
-    throw new Error("上传失败,未返回地址");
-  }
-  return {
-    fileUrl: url,
-    data: { fileUrl: url }
-  };
-};
-
-const multipleRules = reactive<FormRules>({
-  multipleImages: [
-    {
-      validator: (rule, value, callback) => {
-        if (!value || value.length < 3) {
-          callback(new Error("至少上传3张图片"));
-        } else {
-          callback();
-        }
-      },
-      trigger: "change"
-    }
-  ]
-});
-
-watch(
-  () => formData.multipleImages.length,
-  () => {
-    nextTick(() => {
-      initDragSort();
-    });
-  }
-);
+const loading = ref(false);
+const imageUrls = ref<string[]>([]);
+const storeNameDisplay = ref("示例门店");
+const previewReviews = ref(1853);
 
-const initDragSort = () => {
-  if (sortableInstance.value) {
-    sortableInstance.value.destroy();
-    sortableInstance.value = null;
-  }
+const firstImageUrl = computed(() => imageUrls.value[0] || "");
 
-  const uploadList = document.querySelector(".el-upload-list--picture-card");
-  if (!uploadList) return;
+const headPreviewVisible = ref(false);
+const headPreviewInitialIndex = ref(0);
 
-  sortableInstance.value = Sortable.create(uploadList as HTMLElement, {
-    animation: 300,
-    onEnd({ newIndex, oldIndex }) {
-      if (newIndex === undefined || oldIndex === undefined) return;
-      const newList = [...formData.multipleImages];
-      const [removedItem] = newList.splice(oldIndex, 1);
-      newList.splice(newIndex, 0, removedItem);
-      formData.multipleImages = newList;
-    }
-  });
+const openHeadPreview = (index: number) => {
+  if (!imageUrls.value.length) return;
+  const i = Math.min(Math.max(0, index), imageUrls.value.length - 1);
+  headPreviewInitialIndex.value = i;
+  headPreviewVisible.value = true;
 };
 
-const getStoreHeadImgData = async () => {
+const fetchStoreDetail = async () => {
   try {
-    const userInfo: any = localGet("geeker-user")?.userInfo || {};
-    const storeId = userInfo.storeId;
-    if (!storeId) {
-      console.warn("未找到店铺ID");
-      return;
-    }
-
-    const res: any = await getStoreHeadImg(storeId, 20);
-    if (res && (res.code === 200 || res.code === "200") && res.data) {
-      const dataAny = res.data as any;
-      const imgList = Array.isArray(dataAny) ? dataAny : dataAny.storeImgList || [];
-      if (imgList.length > 0) {
-        formData.multipleImages = imgList.map((item: any, index: number) => ({
-          uid: item.id != null ? item.id : `loaded-${index}-${Date.now()}`,
-          name: `image-${index + 1}`,
-          url: item.imgUrl,
-          status: "success",
-          serverImgId: item.id != null ? item.id : undefined
-        }));
-        nextTick(() => {
-          initDragSort();
-        });
-      }
-    }
-  } catch (error) {
-    console.error("获取头图失败:", error);
-  }
-};
-
-const handleDetail = async () => {
-  try {
-    const param = {
-      id: localGet("geeker-user")?.userInfo.storeId
-    };
+    const param = { id: localGet("geeker-user")?.userInfo?.storeId };
+    if (!param.id) return;
     const res: any = await getDetail(param as any);
-    if (res.code == 200) {
-      storeName.value = res.data.storeName;
+    if (res?.code === 200 && res.data?.storeName) {
+      storeNameDisplay.value = String(res.data.storeName);
     }
-  } catch (error) {
-    console.error("未找到用户信息:", error);
+  } catch {
+    /* ignore */
   }
 };
 
-onMounted(async () => {
-  await handleDetail();
-  await getStoreHeadImgData();
-  nextTick(() => {
-    initDragSort();
-  });
-});
-
-const handleSave = async () => {
-  if (!multipleFormRef.value) return;
-
+const fetchHeadImages = async () => {
   const userInfo: any = localGet("geeker-user")?.userInfo || {};
   const storeId = userInfo.storeId;
   if (!storeId) {
-    ElMessage.error("未找到店铺ID,请先完成店铺信息填写");
+    imageUrls.value = [];
     return;
   }
-
   loading.value = true;
   try {
-    const storeImgList = formData.multipleImages.map((item, index) => {
-      const row: Record<string, unknown> = {
-        imgType: 20,
-        imgUrl: item.url || "",
-        imgSort: index + 1,
-        storeId: Number(storeId)
-      };
-      const sid = (item as HeadMapUploadFile).serverImgId;
-      if (sid != null && sid !== "") {
-        row.id = typeof sid === "string" ? Number(sid) || sid : sid;
-      }
-      return row;
-    });
-
-    const params = {
-      imgMode: 1,
-      imgType: 20,
-      storeId: Number(storeId),
-      storeImgList
-    };
-
-    const result: any = await saveStoreHeadImg(params);
-    if (result && (result.code === 200 || result.code === "200")) {
-      ElNotification({
-        title: "保存成功",
-        type: "success",
-        duration: 3000
-      });
-      router.push("/home/index");
+    const res: any = await getStoreHeadImg(storeId, IMG_TYPE_HEAD);
+    if (res && (res.code === 200 || res.code === "200") && res.data) {
+      const dataAny = res.data as any;
+      const imgList = Array.isArray(dataAny) ? dataAny : dataAny.storeImgList || [];
+      const sorted = [...imgList].sort((a: any, b: any) => (Number(a?.imgSort) || 0) - (Number(b?.imgSort) || 0));
+      imageUrls.value = sorted.map((item: any) => String(item?.imgUrl || "").trim()).filter(Boolean);
     } else {
-      ElMessage.error(result?.msg || "保存失败");
+      imageUrls.value = [];
     }
-  } catch (error: any) {
-    console.error("保存失败:", error);
-    ElMessage.error(error?.msg || "保存失败,请重试");
+  } catch {
+    imageUrls.value = [];
   } finally {
     loading.value = false;
   }
 };
+
+onMounted(async () => {
+  await fetchStoreDetail();
+  await fetchHeadImages();
+});
 </script>
 
 <style scoped lang="scss">
-.head-map-container {
+.store-map-readonly {
   min-height: 100%;
-  padding: 20px;
-  background-color: white;
-  .content-wrapper {
-    max-width: 1400px;
-    margin: 0 auto;
+  padding: 24px;
+  background: #ffffff;
+}
+.content-wrapper {
+  /* 与移动端卡片预览宽度接近,整块靠左不靠中 */
+  width: 100%;
+  max-width: 360px;
+  margin: 0;
+}
+.block-heading {
+  margin: 0 0 12px;
+  font-size: 18px;
+  font-weight: 700;
+  color: #151515;
+  text-align: left;
+}
+.preview-area {
+  margin-bottom: 32px;
+}
+.preview-card {
+  padding: 12px;
+  background: #ffffff;
+  border-radius: 12px;
+  box-shadow: 0 2px 12px rgb(0 0 0 / 8%);
+}
+.preview-card--blue {
+  border: 2px solid #6c8ff8;
+}
+.preview-media {
+  position: relative;
+  margin-bottom: 12px;
+  overflow: hidden;
+  background: #f0f2f5;
+  border-radius: 8px;
+}
+.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;
   }
-  .mode-preview-section {
-    margin-bottom: 40px;
-    .preview-box {
-      width: 100%;
-      max-width: 560px;
-      .preview-card {
-        padding: 16px;
-        background-color: white;
-        border: 2px solid #409eff;
-        border-radius: 8px;
-        box-shadow: 0 2px 12px 0 rgb(0 0 0 / 10%);
-        transition: all 0.3s;
-        .preview-images-carousel {
-          position: relative;
-          width: 100%;
-          margin-bottom: 16px;
-          overflow: visible;
-          background-color: #f0f0f0;
-          border-radius: 8px;
-          .head-preview-carousel {
-            width: 100%;
-            :deep(.el-carousel__container) {
-              height: 200px;
-              border-radius: 8px;
-            }
-            :deep(.el-carousel__item) {
-              display: flex;
-              align-items: center;
-              justify-content: center;
-              overflow: hidden;
-              background-color: #e4e7ed;
-            }
-            :deep(.el-carousel__arrow) {
-              background-color: rgb(0 0 0 / 35%);
-            }
-          }
-          .carousel-preview-img {
-            width: 100%;
-            height: 100%;
-            object-fit: cover;
-          }
-          .carousel-preview-empty {
-            display: flex;
-            align-items: center;
-            justify-content: center;
-            height: 200px;
-            background-color: #e4e7ed;
-            border-radius: 8px;
-            .preview-placeholder {
-              font-size: 12px;
-              color: #909399;
-            }
-          }
-          .overlay-button {
-            position: absolute;
-            right: 12px;
-            bottom: 12px;
-            z-index: 2;
-            display: flex;
-            gap: 4px;
-            align-items: center;
-            padding: 6px 12px;
-            font-size: 12px;
-            color: white;
-            pointer-events: none;
-            background-color: rgb(0 0 0 / 60%);
-            border-radius: 4px;
-          }
-        }
-        .preview-info {
-          position: relative;
-          .store-name {
-            margin-bottom: 8px;
-            font-size: 16px;
-            font-weight: bold;
-            color: #000000;
-          }
-          .store-rating {
-            display: flex;
-            align-items: center;
-            margin-bottom: 4px;
-            :deep(.el-rate) {
-              .el-rate__icon {
-                font-size: 14px;
-              }
-            }
-          }
-          .store-reviews {
-            font-size: 14px;
-            color: #606266;
-          }
-          .arrow-icon {
-            position: absolute;
-            top: 50%;
-            right: 0;
-            font-size: 18px;
-            color: #909399;
-            transform: translateY(-50%);
-          }
-        }
-      }
-      .mode-label {
-        display: flex;
-        gap: 8px;
-        align-items: center;
-        justify-content: center;
-        margin-top: 12px;
-        font-size: 14px;
-        color: #606266;
-        text-align: center;
-      }
-    }
+}
+.preview-img {
+  width: 100%;
+  height: 100%;
+  object-fit: cover;
+}
+.preview-img--single {
+  display: block;
+  width: 100%;
+  max-height: 198px;
+  object-fit: cover;
+}
+.preview-meta {
+  .store-title {
+    margin-bottom: 10px;
+    font-size: 16px;
+    font-weight: 700;
+    color: #151515;
+    text-align: left;
   }
-  .upload-section {
-    margin-bottom: 40px;
-    .section-title {
-      margin-bottom: 20px;
-      font-size: 18px;
-      font-weight: bold;
-      color: #000000;
-    }
-    .instructions {
-      margin-bottom: 24px;
-      .instruction-list {
-        padding-left: 20px;
-        margin: 0;
-        font-size: 14px;
-        line-height: 24px;
-        color: #606266;
-        li {
-          margin-bottom: 8px;
-        }
-      }
-    }
-    .multiple-upload-wrapper {
+  .store-rating-row {
+    display: flex;
+    gap: 8px;
+    align-items: center;
+    justify-content: space-between;
+    .rating-left {
       display: flex;
-      justify-content: center;
-      &.upload-full {
-        :deep(.el-upload--picture-card) {
-          display: none !important;
+      flex: 1;
+      align-items: center;
+      min-width: 0;
+      :deep(.el-rate) {
+        .el-rate__icon {
+          font-size: 14px;
         }
       }
     }
-    .upload-tip {
-      margin-top: 12px;
-      font-size: 14px;
-      color: #909399;
-      text-align: center;
+    .reviews-right {
+      display: inline-flex;
+      flex-shrink: 0;
+      gap: 2px;
+      align-items: center;
+      font-size: 13px;
+      color: #606266;
+      white-space: nowrap;
+      .reviews-arrow {
+        font-size: 14px;
+        color: #909399;
+      }
     }
   }
-  .footer {
-    display: flex;
-    justify-content: center;
-    margin-top: 40px;
-  }
+}
+.source-panel {
+  padding-top: 0;
+}
+.source-frame {
+  position: relative;
+  overflow: hidden;
+  border: 1px solid #aaaaaa;
+  border-radius: 8px;
+}
+.source-badge {
+  position: absolute;
+  top: 50%;
+  left: 50%;
+  z-index: 2;
+  padding: 8px 16px;
+  font-size: 12px;
+  color: #ffffff;
+  cursor: pointer;
+  background-color: rgb(0 0 0 / 60%);
+  border-radius: 6px;
+  transform: translate(-50%, -50%);
+}
+.source-img {
+  display: block;
+  width: 100%;
+  max-height: 220px;
+  object-fit: contain;
+}
+.source-img--clickable {
+  cursor: pointer;
+}
+.source-empty {
+  display: flex;
+  align-items: center;
+  justify-content: center;
+  min-height: 140px;
+  font-size: 14px;
+  color: #909399;
 }
 </style>

この差分においてかなりの量のファイルが変更されているため、一部のファイルを表示していません