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

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

# Conflicts:
#	.env.production
LuTong 10 часов назад
Родитель
Сommit
de47653916

+ 1 - 1
.env.development

@@ -25,7 +25,7 @@ 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://upload.ailien.shop:8443"]] # 邹建宇
+VITE_PROXY = [["/api","http://120.26.186.130:8000"],["/ai-upload","https://uat.ailien.shop"]] # 邹建宇
 
 
 # WebSocket 基础地址(分享等能力,与商家端一致)

+ 26 - 10
.env.production

@@ -1,32 +1,48 @@
-# 本地环境
+# 线上环境
 VITE_USER_NODE_ENV = production
 
 # 公共基础路径
 VITE_PUBLIC_PATH = /group_web_merchant/
-# VITE_PUBLIC_PATH = /
 
 # 路由模式
 # Optional: hash | history
 VITE_ROUTER_MODE = hash
 
+# 是否启用 gzip 或 brotli 压缩打包,如果需要多个压缩规则,可以使用 “,” 分隔
+# Optional: gzip | brotli | none
+VITE_BUILD_COMPRESS = none
+
+# 打包压缩后是否删除源文件
+VITE_BUILD_COMPRESS_DELETE_ORIGIN_FILE = false
+
 # 打包时是否删除 console
 VITE_DROP_CONSOLE = true
 
 # 是否开启 VitePWA
-VITE_PWA = false
+VITE_PWA = true
 
+# 线上环境接口地址
 VITE_API = /api
-VITE_API_URL_STORE = /api/alienStore #生产环境使用
+VITE_API_URL_STORE = /api/alienStore
 VITE_API_URL = /api/alienStore
 VITE_API_URL_SECOND = /api/alienSecond
 VITE_API_URL_PLATFORM = /api/alienStorePlatform
 
-# 代理
-VITE_PROXY = [["/api","https://prod.ailien.shop"]]
-
-# WebSocket 基础地址(分享等能力,与商家端一致)
-VITE_WS_BASE = wss://prod.ailien.shop/alienStore/socket/
-
+# 生产环境跨域代理,支持配置多个
+VITE_PROXY = [["/alienStore","http://120.26.186.130:8000/alienStore"]]
+
+# AI接口
+VITE_PROXY_AI = [["/ai-api","http://124.93.18.180:9000"]]
+
+# 上传请求:不配则走同源 /ai-upload(Nginx 反代示例)
+# location /ai-upload/ {
+#   proxy_pass https://upload.ailien.shop:8443/;
+#   proxy_set_header Host upload.ailien.shop;
+#   proxy_ssl_server_name on;
+# }
+# 仅在上传服务已配置完整 CORS 时才直连:
+# VITE_AI_UPLOAD_BASE = https://upload.ailien.shop:8443
+# VITE_AI_FILES_PUBLIC_BASE = https://upload.ailien.shop:8443/files
 
 # 接口加密配置
 # 加密功能总开关

+ 5 - 2
.env.test

@@ -38,8 +38,11 @@ VITE_API_URL_PLATFORM = /alienStorePlatform
 
 
 
-# 生产环境跨域代理,支持配置多个
-VITE_PROXY = [["/api","http://localhost:8888"]]
+# 测试环境跨域代理,支持配置多个
+VITE_PROXY = [["/api","http://120.26.186.130:8000"],["/ai-upload","https://uat.ailien.shop"]] 
+
+# 简单上传与 Tus:与 UAT 上传服务一致(不配则默认同源 /ai-upload,测试部署在非 uat 域名时常 404)
+VITE_AI_UPLOAD_BASE = https://uat.ailien.shop
 
 # 接口加密配置
 # 加密功能总开关

+ 2 - 0
src/App.vue

@@ -1,5 +1,6 @@
 <template>
   <el-config-provider :locale="locale" :size="assemblySize" :button="buttonConfig">
+    <SimpleUploadOverlayHost />
     <router-view v-slot="{ Component }">
       <component :is="Component" :key="$route.fullPath" />
     </router-view>
@@ -16,6 +17,7 @@ import { LanguageType } from "./stores/interface";
 import { useGlobalStore } from "@/stores/modules/global";
 import en from "element-plus/es/locale/lang/en";
 import zhCn from "element-plus/es/locale/lang/zh-cn";
+import SimpleUploadOverlayHost from "@/components/popupLoading/SimpleUploadOverlayHost.vue";
 
 const globalStore = useGlobalStore();
 

+ 46 - 42
src/api/modules/aiImageUpload.ts

@@ -3,6 +3,7 @@
  */
 import { useUserStore } from "@/stores/modules/user";
 import { AI_UPLOAD_FILES_PUBLIC_BASE, BASE_AI_URL, BASE_DEV_UPLOAD_SIMPLE } from "@/utils/config";
+import { withSimpleUploadOverlay } from "@/utils/withSimpleUploadOverlay";
 
 const TUS_VERSION = "1.0.0";
 const TUS_CHUNK_SIZE = 1024 * 1024;
@@ -116,7 +117,7 @@ function normalizeSimpleUploadUrls(body: unknown, fileUrl: string): { fileUrl: s
   return { fileUrl: url, coverUrl: coverUrl || undefined };
 }
 
-const DEV_SIMPLE_UPLOAD_PATH = "/dev-upload-ailien/upload/simple";
+const DEV_SIMPLE_UPLOAD_PATH = "/upload/simple";
 
 function buildDevSimpleUploadRequestUrl(): string {
   const base = String(BASE_DEV_UPLOAD_SIMPLE || "").replace(/\/$/, "");
@@ -131,53 +132,56 @@ function buildDevSimpleUploadRequestUrl(): string {
  * POST `/dev-upload-ailien/upload/simple`,表单字段 `file`
  */
 export async function uploadFileViaDevSimpleEndpoint(file: File): Promise<{ fileUrl: string; coverUrl?: string }> {
-  const reqUrl = buildDevSimpleUploadRequestUrl();
-  const fd = new FormData();
-  fd.append("file", file, file.name);
+  return withSimpleUploadOverlay(async signal => {
+    const reqUrl = buildDevSimpleUploadRequestUrl();
+    const fd = new FormData();
+    fd.append("file", file, file.name);
+
+    const res = await fetch(reqUrl, {
+      method: "POST",
+      headers: {
+        Authorization: authHeader()
+      },
+      body: fd,
+      credentials: "omit",
+      signal
+    });
 
-  const res = await fetch(reqUrl, {
-    method: "POST",
-    headers: {
-      Authorization: authHeader()
-    },
-    body: fd,
-    credentials: "omit"
-  });
+    let body: unknown = null;
+    const ct = res.headers.get("content-type") || "";
+    if (ct.includes("application/json")) {
+      try {
+        body = await res.json();
+      } catch {
+        body = null;
+      }
+    } else {
+      const t = await res.text();
+      try {
+        body = t ? JSON.parse(t) : null;
+      } catch {
+        body = t ? { raw: t } : null;
+      }
+    }
 
-  let body: unknown = null;
-  const ct = res.headers.get("content-type") || "";
-  if (ct.includes("application/json")) {
-    try {
-      body = await res.json();
-    } catch {
-      body = null;
+    if (res.status < 200 || res.status >= 300) {
+      const msg =
+        body && typeof body === "object" && (body as any).msg != null ? String((body as any).msg) : `上传失败(${res.status})`;
+      throw new Error(msg);
     }
-  } else {
-    const t = await res.text();
-    try {
-      body = t ? JSON.parse(t) : null;
-    } catch {
-      body = t ? { raw: t } : null;
+    if (body && typeof body === "object" && (body as any).code !== undefined) {
+      const c = (body as any).code;
+      if (c !== 200 && c !== 0) {
+        throw new Error((body as any).msg || (body as any).message || "上传失败");
+      }
     }
-  }
 
-  if (res.status < 200 || res.status >= 300) {
-    const msg =
-      body && typeof body === "object" && (body as any).msg != null ? String((body as any).msg) : `上传失败(${res.status})`;
-    throw new Error(msg);
-  }
-  if (body && typeof body === "object" && (body as any).code !== undefined) {
-    const c = (body as any).code;
-    if (c !== 200 && c !== 0) {
-      throw new Error((body as any).msg || (body as any).message || "上传失败");
+    const rawUrl = pickFileUrlFromBody(body);
+    if (!rawUrl) {
+      throw new Error("上传完成但未返回文件地址");
     }
-  }
-
-  const rawUrl = pickFileUrlFromBody(body);
-  if (!rawUrl) {
-    throw new Error("上传完成但未返回文件地址");
-  }
-  return normalizeSimpleUploadUrls(body, rawUrl);
+    return normalizeSimpleUploadUrls(body, rawUrl);
+  });
 }
 
 /** 创建上传会话 POST /upload */

+ 3 - 3
src/api/modules/newLoginApi.ts

@@ -105,9 +105,9 @@ export const getFriendCouponList = (params: any) => {
 export const getCouponDetail = (params: any) => {
   return httpLogin.get(`/alienStore/coupon/getCouponDetail`, params);
 };
-// 获取好友优惠券列表
-export const getFriendCouponDetail = (params: any) => {
-  return httpLogin.get(`/alienStore/life-discount-coupon/getCounponDetailById`, params);
+// 获取好友优惠券详情(含持有数量)
+export const getFriendCouponDetail = (params: { couponId: string | number; storeId: string | number }) => {
+  return httpLogin.get(`/alienStore/life-discount-coupon/getCouponDetailWithOwnedQty`, params);
 };
 
 // 好友列表

+ 30 - 9
src/api/upload.js

@@ -1,6 +1,7 @@
 import { useUserStore } from "@/stores/modules/user";
 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 转发) */
 const SIMPLE_UPLOAD_PATH = "/upload/simple";
@@ -311,9 +312,10 @@ function assertSimpleUploadBusinessOk(parsed) {
 /**
  * POST multipart:字段 file;可选 filename(此处不传则服务端用原名)
  * @param {File} file
+ * @param {{ signal?: AbortSignal }} [fetchOptions]
  * @returns {Promise<string>} 文件访问 URL
  */
-async function postFileToSimpleUpload(file) {
+async function postFileToSimpleUpload(file, fetchOptions = {}) {
   const base = String(BASE_AI_URL || "").replace(/\/$/, "");
   if (!base) {
     throw new Error("未配置上传服务地址(VITE_AI_UPLOAD_BASE 或默认 /ai-upload)");
@@ -329,12 +331,15 @@ async function postFileToSimpleUpload(file) {
     headers.Authorization = token;
   }
 
+  const { signal } = fetchOptions;
+
   const res = await fetch(`${base}${SIMPLE_UPLOAD_PATH}`, {
     method: "POST",
     headers,
     /** 不带跨域 Cookie,减轻上传服务 CORS 要求(鉴权仅用 Authorization) */
     credentials: "omit",
-    body: formData
+    body: formData,
+    signal: signal ?? undefined
   });
 
   const rawText = await res.text();
@@ -406,33 +411,49 @@ async function postFileToSimpleUpload(file) {
  * 上传文件:图片与视频均走同一接口 POST /upload/simple,formData 键 file。
  * @param {File | File[] | FileList} files 浏览器文件对象;支持单个 File、数组或 FileList
  * @param {string} [_fileType] 保留参数,兼容旧调用(当前不参与分支)
- * @param {{ showLoading?: boolean }} [options] showLoading 为 true 时用 ElMessage 提示上传中(非阻塞)
+ * @param {{ showLoading?: boolean; skipSimpleUploadOverlay?: boolean }} [options]
+ *   showLoading:在未使用全局上传弹层时,用 ElMessage 提示上传中
+ *   skipSimpleUploadOverlay:为 true 时不展示 PopupLoading(不弹「上传成功」)
  * @returns {Promise<string[]>} 上传成功后的文件 URL 列表
  */
 export async function uploadFilesToOss(files, _fileType, options = {}) {
-  const { showLoading = false } = options;
+  const { showLoading = false, skipSimpleUploadOverlay = false } = options;
   const fileArr = normalizeFiles(files);
   if (fileArr.length === 0) {
     throw new Error("请选择要上传的文件");
   }
 
   let closeLoading = () => {};
-  if (showLoading) {
+  if (showLoading && skipSimpleUploadOverlay) {
     const loading = ElMessage({ message: "上传中...", type: "info", duration: 0, showClose: false });
     closeLoading = () => loading.close();
   }
 
   try {
-    const uploadedUrls = [];
-    for (const file of fileArr) {
-      const url = await postFileToSimpleUpload(file);
-      uploadedUrls.push(url);
+    const runUpload = async signal => {
+      const uploadedUrls = [];
+      for (const file of fileArr) {
+        const url = await postFileToSimpleUpload(file, signal ? { signal } : {});
+        uploadedUrls.push(url);
+      }
+      return uploadedUrls;
+    };
+
+    let uploadedUrls;
+    if (skipSimpleUploadOverlay) {
+      uploadedUrls = await runUpload(null);
+    } else {
+      uploadedUrls = await withSimpleUploadOverlay(signal => runUpload(signal));
     }
+
     closeLoading();
     return uploadedUrls;
   } catch (e) {
     closeLoading();
     console.error("上传失败", e);
+    if (e?.name === "AbortError") {
+      throw e;
+    }
     const msg = e?.message || "上传失败";
     ElMessage.error(msg);
     throw e;

+ 27 - 26
src/assets/json/authMenuList.json

@@ -880,7 +880,7 @@
       "name": "dynamicManagement",
       "redirect": "/dynamicManagement/index",
       "meta": {
-        "icon": "Operation", 
+        "icon": "Operation",
         "title": "动态",
         "isLink": "",
         "isHide": false,
@@ -1267,7 +1267,7 @@
       "component": "/appoinmentManagement",
       "meta": {
         "icon": "ChatDotSquare",
-        "title": "预服务",
+        "title": "预服务",
         "isLink": "",
         "isHide": false,
         "isFull": false,
@@ -1279,7 +1279,7 @@
           "path": "/appoinmentManagement/classifyManagement",
           "name": "classifyManagement",
           "component": "/appoinmentManagement/classifyManagement",
-          "meta":{
+          "meta": {
             "icon": "ChatDotSquare",
             "title": "分类管理",
             "isLink": "",
@@ -1293,7 +1293,7 @@
           "path": "/appoinmentManagement/tableManagement",
           "name": "tableManagement",
           "component": "/appoinmentManagement/tableManagement",
-          "meta":{
+          "meta": {
             "icon": "ChatDotSquare",
             "title": "桌号管理",
             "isLink": "",
@@ -1307,7 +1307,7 @@
           "path": "/appoinmentManagement/infoManagement",
           "name": "infoManagement",
           "component": "/appoinmentManagement/infoManagement",
-          "meta":{
+          "meta": {
             "icon": "ChatDotSquare",
             "title": "信息设置",
             "isLink": "",
@@ -1319,24 +1319,25 @@
         }
       ]
     },
-      {
+    {
       "path": "/appoinmentManagement/appoinmentInfo",
       "name": "appoinmentInfo",
       "component": "/appoinmentManagement/appoinmentInfo",
-      "meta":{
+      "meta": {
         "icon": "ChatDotSquare",
-        "title": "预信息",
+        "title": "预信息",
         "isLink": "",
         "isHide": false,
         "isFull": false,
         "isAffix": false,
         "isKeepAlive": false
       }
-    },{
+    },
+    {
       "path": "/businessInfo",
       "name": "businessInfo",
       "component": "/businessInfo",
-      "meta":{
+      "meta": {
         "icon": "ChatDotSquare",
         "title": "设置收款账号",
         "isLink": "",
@@ -1350,7 +1351,7 @@
           "path": "/businessInfo/weChartIndex",
           "name": "/businessInfo/weChartIndex",
           "component": "/businessInfo/weChartIndex",
-          "meta":{
+          "meta": {
             "icon": "ChatDotSquare",
             "title": "绑定微信账号",
             "isLink": "",
@@ -1364,7 +1365,7 @@
           "path": "/businessInfo/weChartExamine",
           "name": "/businessInfo/weChartExamine",
           "component": "/businessInfo/weChartExamine",
-          "meta":{
+          "meta": {
             "icon": "ChatDotSquare",
             "title": "查看微信审核状态",
             "isLink": "",
@@ -1378,7 +1379,7 @@
           "path": "/businessInfo/createApply",
           "name": "/businessInfo/createApply",
           "component": "/businessInfo/createApply",
-          "meta":{
+          "meta": {
             "icon": "Document",
             "title": "选择主体类型",
             "isLink": "",
@@ -1387,11 +1388,12 @@
             "isAffix": false,
             "isKeepAlive": false
           }
-        },{
+        },
+        {
           "path": "/businessInfo/dataEntry",
           "name": "/businessInfo/dataEntry",
           "component": "/businessInfo/dataEntry",
-          "meta":{
+          "meta": {
             "icon": "Document",
             "title": "选择主体类型",
             "isLink": "",
@@ -1405,7 +1407,7 @@
           "path": "/businessInfo/subjectInfo",
           "name": "/businessInfo/subjectInfo",
           "component": "/businessInfo/subjectInfo",
-          "meta":{
+          "meta": {
             "icon": "User",
             "title": "主体信息",
             "isLink": "",
@@ -1419,7 +1421,7 @@
           "path": "/businessInfo/manageInfo",
           "name": "/businessInfo/manageInfo",
           "component": "/businessInfo/manageInfo",
-          "meta":{
+          "meta": {
             "icon": "OfficeBuilding",
             "title": "经营信息",
             "isLink": "",
@@ -1433,7 +1435,7 @@
           "path": "/businessInfo/industryQualifications",
           "name": "/businessInfo/industryQualifications",
           "component": "/businessInfo/industryQualifications",
-          "meta":{
+          "meta": {
             "icon": "Document",
             "title": "行业资质",
             "isLink": "",
@@ -1447,7 +1449,7 @@
           "path": "/businessInfo/accountInfo",
           "name": "/businessInfo/accountInfo",
           "component": "/businessInfo/accountInfo",
-          "meta":{
+          "meta": {
             "icon": "Wallet",
             "title": "结算账户",
             "isLink": "",
@@ -1461,7 +1463,7 @@
           "path": "/businessInfo/adminInfo",
           "name": "/businessInfo/adminInfo",
           "component": "/businessInfo/adminInfo",
-          "meta":{
+          "meta": {
             "icon": "UserFilled",
             "title": "超级管理员",
             "isLink": "",
@@ -1471,11 +1473,11 @@
             "isKeepAlive": false
           }
         },
-         {
+        {
           "path": "/businessInfo/zfbIndex",
           "name": "/businessInfo/zfbIndex",
           "component": "/businessInfo/zfbIndex",
-          "meta":{
+          "meta": {
             "icon": "ChatDotSquare",
             "title": "绑定支付宝账号",
             "isLink": "",
@@ -1485,11 +1487,11 @@
             "isKeepAlive": false
           }
         },
-       {
+        {
           "path": "/businessInfo/zfbIndexTwo",
           "name": "/businessInfo/zfbIndexTwo",
           "component": "/businessInfo/zfbIndexTwo",
-          "meta":{
+          "meta": {
             "icon": "ChatDotSquare",
             "title": "完善结算信息",
             "isLink": "",
@@ -1503,7 +1505,7 @@
           "path": "/businessInfo/zfbExamine",
           "name": "/businessInfo/zfbExamine",
           "component": "/businessInfo/zfbExamine",
-          "meta":{
+          "meta": {
             "icon": "ChatDotSquare",
             "title": "查看支付宝审核状态",
             "isLink": "",
@@ -1518,4 +1520,3 @@
   ],
   "msg": "成功"
 }
-

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

@@ -163,11 +163,11 @@ const beforeUpload: UploadProps["beforeUpload"] = rawFile => {
  * @description 图片上传成功
  * */
 const uploadSuccess = () => {
-  ElNotification({
-    title: "温馨提示",
-    message: "图片上传成功!",
-    type: "success"
-  });
+  // ElNotification({
+  //   title: "温馨提示",
+  //   message: "图片上传成功!11111",
+  //   type: "success"
+  // });
 };
 
 /**

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

@@ -297,11 +297,11 @@ const uploadSuccess = (response: { fileUrl: string } | string | string[] | undef
     hasShownSuccessNotification = true;
     // 判断是否为视频文件,显示相应的提示语
     const isVideo = isVideoFile(uploadFile);
-    ElNotification({
-      title: "温馨提示",
-      message: isVideo ? "视频上传成功!" : "图片上传成功!",
-      type: "success"
-    });
+    // ElNotification({
+    //   title: "温馨提示",
+    //   message: isVideo ? "视频上传成功!" : "图片上传成功!1111",
+    //   type: "success"
+    // });
   }
 };
 

+ 16 - 0
src/components/popupLoading/SimpleUploadOverlayHost.vue

@@ -0,0 +1,16 @@
+<template>
+  <PopupLoading
+    :show="store.show"
+    :percent="Math.round(Number(store.percent) || 0)"
+    :title="store.title"
+    :cancel-text="store.cancelText"
+    @cancel="store.userCancel"
+  />
+</template>
+
+<script setup lang="ts">
+import { useSimpleUploadOverlayStore } from "@/stores/modules/simpleUploadOverlay";
+import PopupLoading from "./index.vue";
+
+const store = useSimpleUploadOverlayStore();
+</script>

+ 208 - 0
src/components/popupLoading/index.vue

@@ -0,0 +1,208 @@
+<template>
+  <div v-if="show" class="pl-mask" @click.stop>
+    <div class="pl-card" @click.stop>
+      <div class="pl-body">
+        <div class="pl-ring-wrap" :style="{ width: ringPx + 'px', height: ringPx + 'px' }">
+          <svg class="pl-ring-svg" :viewBox="viewBox" aria-hidden="true">
+            <defs>
+              <linearGradient :id="progressGradientId" x1="12" y1="18" x2="88" y2="82" gradientUnits="userSpaceOnUse">
+                <stop offset="0%" class="pl-grad-stop pl-grad-stop--start" />
+                <stop offset="52%" class="pl-grad-stop pl-grad-stop--mid" />
+                <stop offset="100%" class="pl-grad-stop pl-grad-stop--end" />
+              </linearGradient>
+            </defs>
+            <circle class="pl-ring-track" :cx="cx" :cy="cy" :r="radius" fill="none" :stroke-width="strokeW" />
+            <circle
+              class="pl-ring-progress"
+              :cx="cx"
+              :cy="cy"
+              :r="radius"
+              fill="none"
+              :stroke="`url(#${progressGradientId})`"
+              :stroke-width="strokeW"
+              stroke-linecap="round"
+              :stroke-dasharray="dashArray"
+              :stroke-dashoffset="dashOffset"
+              :transform="`rotate(-90 ${cx} ${cy})`"
+            />
+          </svg>
+          <div class="pl-percent-wrap">
+            <span class="pl-percent">{{ displayPercent }}%</span>
+          </div>
+        </div>
+        <p class="pl-title">
+          {{ title }}
+        </p>
+      </div>
+      <div class="pl-divider" />
+      <button type="button" class="pl-cancel" @click="onCancel">
+        <span class="pl-cancel__text">{{ cancelText }}</span>
+      </button>
+    </div>
+  </div>
+</template>
+
+<script setup>
+import { computed } from "vue";
+
+/** 避免同页多个实例时 defs id 冲突 */
+const progressGradientId = `pl-ring-grad-${Math.random().toString(36).slice(2, 10)}`;
+
+const props = defineProps({
+  show: {
+    type: Boolean,
+    default: false
+  },
+  percent: {
+    type: Number,
+    default: 0
+  },
+  title: {
+    type: String,
+    default: "上传中"
+  },
+  cancelText: {
+    type: String,
+    default: "取消上传"
+  }
+});
+
+const emit = defineEmits(["cancel", "update:show"]);
+
+const displayPercent = computed(() => {
+  const n = Number(props.percent);
+  if (Number.isNaN(n)) return 0;
+  return Math.round(Math.min(100, Math.max(0, n)));
+});
+
+/** SVG 环形:与 Element 主题色对齐 */
+const vb = 100;
+const cx = vb / 2;
+const cy = vb / 2;
+const radius = 38;
+const strokeW = 7;
+const viewBox = `0 0 ${vb} ${vb}`;
+const ringPx = 118;
+
+const circumference = 2 * Math.PI * radius;
+
+const dashArray = computed(() => `${circumference} ${circumference}`);
+
+const dashOffset = computed(() => {
+  const p = displayPercent.value / 100;
+  return circumference * (1 - p);
+});
+
+function onCancel() {
+  emit("cancel");
+  emit("update:show", false);
+}
+</script>
+
+<style lang="scss" scoped>
+.pl-mask {
+  position: fixed;
+  inset: 0;
+  z-index: 10000;
+  box-sizing: border-box;
+  display: flex;
+  align-items: center;
+  justify-content: center;
+  padding: 24px;
+  background: rgb(0 0 0 / 50%);
+}
+.pl-card {
+  box-sizing: border-box;
+  width: 100%;
+  max-width: 320px;
+  overflow: hidden;
+  background: var(--el-bg-color, #ffffff);
+  border-radius: 12px;
+  box-shadow: var(--el-box-shadow, 0 12px 32px rgb(0 0 0 / 12%));
+}
+.pl-body {
+  box-sizing: border-box;
+  display: flex;
+  flex-direction: column;
+  align-items: center;
+  padding: 32px 24px 24px;
+}
+.pl-ring-wrap {
+  position: relative;
+  flex-shrink: 0;
+}
+.pl-ring-svg {
+  display: block;
+  width: 100%;
+  height: 100%;
+}
+.pl-ring-track {
+  stroke: var(--el-color-primary-light-8, #e8ecfc);
+}
+.pl-ring-progress {
+  transition: stroke-dashoffset 0.35s ease;
+}
+.pl-grad-stop--start {
+  stop-color: var(--el-color-primary-light-5, #a3b9fc);
+}
+.pl-grad-stop--mid {
+  stop-color: var(--el-color-primary, #6c8ff8);
+}
+.pl-grad-stop--end {
+  stop-color: var(--el-color-primary-dark-2, #4a6fd6);
+}
+.pl-percent-wrap {
+  position: absolute;
+  inset: 0;
+  display: flex;
+  align-items: center;
+  justify-content: center;
+  pointer-events: none;
+}
+.pl-percent {
+  font-size: 22px;
+  font-weight: 600;
+  font-variant-numeric: tabular-nums;
+  line-height: 1.2;
+  color: var(--el-color-primary, #6c8ff8);
+}
+.pl-title {
+  margin: 20px 0 0;
+  font-size: 15px;
+  font-weight: 500;
+  line-height: 1.4;
+  color: var(--el-text-color-primary, #303133);
+  text-align: center;
+}
+.pl-divider {
+  height: 1px;
+  margin: 0;
+  background: var(--el-border-color-lighter, #ebeef5);
+}
+.pl-cancel {
+  box-sizing: border-box;
+  display: flex;
+  align-items: center;
+  justify-content: center;
+  width: 100%;
+  padding: 14px 20px 16px;
+  margin: 0;
+  font: inherit;
+  cursor: pointer;
+  background: transparent;
+  border: none;
+  &:hover .pl-cancel__text {
+    color: var(--el-color-primary-light-3, #8aa6fa);
+  }
+  &:active .pl-cancel__text {
+    color: var(--el-color-primary-dark-2, #4a6fd6);
+  }
+}
+.pl-cancel__text {
+  font-size: 15px;
+  font-weight: 500;
+  line-height: 1.4;
+  color: var(--el-color-primary, #6c8ff8);
+  transition: color 0.15s ease;
+}
+</style>

+ 8 - 1
src/layouts/components/Header/components/InfoDialog.vue

@@ -73,12 +73,18 @@ const imageViewerVisible = ref(false);
 const imageViewerUrlList = ref<string[]>([]);
 const imageViewerInitialIndex = ref(0);
 
+/** 与 ToolBarRight 右上角用户名一致:优先 name,否则 nickName */
+function displayNameLikeHeader(data: Record<string, any> | null | undefined): string {
+  if (!data) return "";
+  return data.name != null ? String(data.name) : String(data.nickName ?? "");
+}
+
 const getUserInfo = async () => {
   const res: any = await getMerchantByPhone({ phone: userInfo.phone });
   if (res.code == 200) {
     // 保存用户ID
     userId.value = res.data.id || res.data.userId || "";
-    form.nickname = res.data.nickName || "";
+    form.nickname = displayNameLikeHeader(res.data);
     form.intro = res.data.accountBlurb || "";
     // 设置头像
     const headImg = res.data.headImg;
@@ -158,6 +164,7 @@ const getSaveUserInfo = async () => {
 
     const res: any = await updateMerchantUserInfo({
       id: userId.value,
+      name: form.nickname,
       nickName: form.nickname,
       headImg: avatarUrl || "",
       accountBlurb: form.intro

+ 3 - 3
src/layouts/components/Header/components/NotificationBell.vue

@@ -17,6 +17,7 @@ import { useDebounceFn } from "@vueuse/core";
 import { Bell } from "@element-plus/icons-vue";
 import { getMessageNoRead } from "@/api/modules/headerNotice";
 import { localGet } from "@/utils";
+import { getWebSocketBase } from "@/utils/wsBase";
 import { useWebSocketStore } from "@/stores/modules/websocket";
 import NotificationDrawerContent from "./NotificationDrawerContent.vue";
 
@@ -26,8 +27,6 @@ const drawerContentRef = ref<InstanceType<typeof NotificationDrawerContent> | nu
 const socketStore = useWebSocketStore();
 let cleanMessageFn: (() => void) | null = null;
 
-const WS_BASE = (import.meta.env.VITE_WS_BASE || "ws://120.26.186.130:8000/alienStore/socket/").replace(/\/$/, "");
-
 /** 与商家端 tabbar getMessage 一致:获取未读数量 */
 async function getMessage() {
   const phone = localGet("iphone") || localGet("geeker-user")?.userInfo?.phone;
@@ -59,12 +58,13 @@ async function connectWebSocket() {
   const phone = localGet("iphone") || localGet("geeker-user")?.userInfo?.phone;
   if (!phone) return;
 
+  const WS_BASE = getWebSocketBase();
   const wsUrl =
     WS_BASE.startsWith("wss") || WS_BASE.startsWith("ws")
       ? `${WS_BASE}/store_${phone}`
       : WS_BASE.replace("https", "wss").replace("http", "ws") + `/store_${phone}`;
 
-  if (!socketStore.isConnected || socketStore.lastConnectedUrl !== wsUrl) {
+  if (!socketStore.isSocketOpen() || socketStore.lastConnectedUrl !== wsUrl) {
     await socketStore.connect(wsUrl);
   }
 

+ 62 - 0
src/stores/modules/simpleUploadOverlay.ts

@@ -0,0 +1,62 @@
+import { defineStore } from "pinia";
+import { ref } from "vue";
+
+let progressTimer: ReturnType<typeof setInterval> | null = null;
+let activeController: AbortController | null = null;
+
+function clearProgressTimer() {
+  if (progressTimer) {
+    clearInterval(progressTimer);
+    progressTimer = null;
+  }
+}
+
+export const useSimpleUploadOverlayStore = defineStore("simple-upload-overlay", () => {
+  const show = ref(false);
+  const percent = ref(0);
+  const title = ref("上传中");
+  const cancelText = ref("取消上传");
+
+  function beginUpload(opts?: { title?: string }) {
+    activeController?.abort();
+    clearProgressTimer();
+    activeController = new AbortController();
+    title.value = opts?.title ?? "上传中";
+    percent.value = 3;
+    show.value = true;
+    progressTimer = setInterval(() => {
+      if (percent.value < 88) {
+        percent.value = Math.min(88, percent.value + 2 + Math.random() * 6);
+      }
+    }, 260);
+    return activeController.signal;
+  }
+
+  function bumpToComplete() {
+    clearProgressTimer();
+    percent.value = 100;
+  }
+
+  function dismiss() {
+    clearProgressTimer();
+    show.value = false;
+    percent.value = 0;
+    activeController = null;
+  }
+
+  function userCancel() {
+    activeController?.abort();
+    dismiss();
+  }
+
+  return {
+    show,
+    percent,
+    title,
+    cancelText,
+    beginUpload,
+    bumpToComplete,
+    dismiss,
+    userCancel
+  };
+});

+ 35 - 5
src/stores/modules/websocket.ts

@@ -14,10 +14,22 @@ export const useWebSocketStore = defineStore("websocket", () => {
   // 消息订阅(用于聊天等)
   const messageHandlers = new Map<string, Array<(msg: any) => void>>();
 
+  /** 以底层 readyState 为准,避免 isConnected 与真实连接短暂不一致 */
+  const isSocketOpen = () => !!socket.value && socket.value.readyState === WebSocket.OPEN;
+
+  /** 上一笔尚未结束的 connect 的 resolve;刷新时 Header 与聊天页并发 connect 会顶替,必须结束旧 Promise */
+  let pendingConnectResolve: ((ok: boolean) => void) | null = null;
+
   const connect = (url: string): Promise<boolean> => {
-    if (isConnected.value && lastConnectedUrl.value === url) {
+    if (isSocketOpen() && lastConnectedUrl.value === url) {
+      isConnected.value = true;
       return Promise.resolve(true);
     }
+    if (pendingConnectResolve) {
+      const prev = pendingConnectResolve;
+      pendingConnectResolve = null;
+      prev(false);
+    }
     if (socket.value) {
       try {
         socket.value.close();
@@ -32,17 +44,27 @@ export const useWebSocketStore = defineStore("websocket", () => {
     lastConnectedUrl.value = url;
 
     return new Promise(resolve => {
+      pendingConnectResolve = resolve;
+      const settle = (ok: boolean) => {
+        if (pendingConnectResolve === resolve) {
+          pendingConnectResolve = null;
+          resolve(ok);
+        }
+      };
+
       try {
         const ws = new WebSocket(url);
         socket.value = ws;
 
         ws.onopen = () => {
+          if (socket.value !== ws) return;
           isConnected.value = true;
           isConnecting.value = false;
-          resolve(true);
+          settle(true);
         };
 
         ws.onmessage = (event: MessageEvent) => {
+          if (socket.value !== ws) return;
           try {
             const message = JSON.parse(event.data);
             const category = message.category || "message";
@@ -66,20 +88,26 @@ export const useWebSocketStore = defineStore("websocket", () => {
         };
 
         ws.onclose = () => {
+          // 必须校验实例:新 connect 关闭旧 socket 后,旧 onclose 仍会触发,否则会清空新连接导致「刷新后必现失败」
+          if (socket.value !== ws) return;
           isConnected.value = false;
           isConnecting.value = false;
           socket.value = null;
+          // 握手未完成即关闭时结束 Promise(若已成功 onopen 已 settle,此处不再 resolve)
+          settle(false);
         };
 
         ws.onerror = () => {
+          if (socket.value !== ws) return;
           isConnected.value = false;
           isConnecting.value = false;
-          resolve(false);
+          socket.value = null;
+          settle(false);
         };
       } catch (e) {
         console.error("WebSocket 连接异常:", e);
         isConnecting.value = false;
-        resolve(false);
+        settle(false);
       }
     });
   };
@@ -101,7 +129,8 @@ export const useWebSocketStore = defineStore("websocket", () => {
 
   const sendMessage = (data: Record<string, unknown>): Promise<boolean> => {
     return new Promise(resolve => {
-      if (!isConnected.value || !socket.value || socket.value.readyState !== WebSocket.OPEN) {
+      if (!isSocketOpen()) {
+        if (isConnected.value) isConnected.value = false;
         console.warn("WebSocket 未连接,无法发送消息");
         resolve(false);
         return;
@@ -135,6 +164,7 @@ export const useWebSocketStore = defineStore("websocket", () => {
     isConnected,
     isConnecting,
     lastConnectedUrl,
+    isSocketOpen,
     connect,
     sendMessage,
     disconnect,

+ 2 - 3
src/utils/config.ts

@@ -9,7 +9,7 @@ export const BASE_AI_URL = trimSlash(String(import.meta.env.VITE_AI_UPLOAD_BASE
 
 /** 上传完成后对外可访问的文件 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://upload.ailien.shop:8443/files"
+  String(import.meta.env.VITE_AI_FILES_PUBLIC_BASE || "").trim() || "https://uat.ailien.shop/files"
 );
 
 /**
@@ -17,6 +17,5 @@ export const AI_UPLOAD_FILES_PUBLIC_BASE = trimSlash(
  * 不配时:开发环境请求同源相对路径(需在 VITE_PROXY 中把 `/dev-upload-ailien` 指到上传服务);生产默认 upload.ailien.shop:8443
  */
 export const BASE_DEV_UPLOAD_SIMPLE = trimSlash(
-  String(import.meta.env.VITE_DEV_UPLOAD_SIMPLE_BASE || "").trim() ||
-    (import.meta.env.DEV ? "" : "https://upload.ailien.shop:8443")
+  String(import.meta.env.VITE_DEV_UPLOAD_SIMPLE_BASE || "").trim() || (import.meta.env.DEV ? "" : "https://uat.ailien.shop")
 );

+ 29 - 0
src/utils/withSimpleUploadOverlay.ts

@@ -0,0 +1,29 @@
+import { ElMessage } from "element-plus";
+import { useSimpleUploadOverlayStore } from "@/stores/modules/simpleUploadOverlay";
+
+function sleep(ms: number) {
+  return new Promise<void>(resolve => setTimeout(resolve, ms));
+}
+
+/**
+ * 使用全局 PopupLoading 包裹「/upload/simple」类上传;成功后提示「上传成功」。
+ * 取消(AbortError)不弹成功提示;失败时关闭弹层,由调用方决定是否 ElMessage.error。
+ */
+export async function withSimpleUploadOverlay<T>(
+  task: (signal: AbortSignal) => Promise<T>,
+  options?: { title?: string; successMessage?: string }
+): Promise<T> {
+  const overlay = useSimpleUploadOverlayStore();
+  const signal = overlay.beginUpload({ title: options?.title });
+  try {
+    const result = await task(signal);
+    overlay.bumpToComplete();
+    await sleep(280);
+    overlay.dismiss();
+    ElMessage.success(options?.successMessage ?? "上传成功");
+    return result;
+  } catch (e) {
+    overlay.dismiss();
+    throw e;
+  }
+}

+ 39 - 0
src/utils/wsBase.ts

@@ -0,0 +1,39 @@
+/**
+ * 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://其它域导致握手失败)
+ */
+export function getWebSocketBase(): string {
+  const envBase = (import.meta.env.VITE_WS_BASE as string | undefined)?.trim();
+  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 代理到后端
+    if (isHttps) {
+      return `wss://${host}/alienStore/socket`;
+    }
+    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) {
+        try {
+          const envHost = new URL(e.replace(/^wss:/, "https:")).hostname;
+          if (envHost !== locHost) {
+            return `ws://${locHost}:8000/alienStore/socket`;
+          }
+        } catch (_) {
+          /* ignore */
+        }
+      }
+      return e;
+    }
+    return "ws://120.26.186.130:8000/alienStore/socket";
+  }
+  if (envBase) {
+    return envBase.replace(/\/$/, "");
+  }
+  return "ws://120.26.186.130:8000/alienStore/socket";
+}

+ 2 - 2
src/views/appoinmentManagement/classifyManagement.vue

@@ -319,7 +319,7 @@ async function handleEdit(row: CategoryRow) {
   try {
     const has = await categoryHasReservation(row.id);
     if (has) {
-      ElMessage.warning("此分类所属桌号有预信息,不可修改");
+      ElMessage.warning("此分类所属桌号有预信息,不可修改");
       return;
     }
   } catch (e: any) {
@@ -369,7 +369,7 @@ async function handleDelete(row: CategoryRow) {
   try {
     const has = await categoryHasReservation(row.id);
     if (has) {
-      ElMessage.warning("此分类所属桌号有预信息,不可修改");
+      ElMessage.warning("此分类所属桌号有预信息,不可修改");
       return;
     }
   } catch (e: any) {

+ 13 - 10
src/views/appoinmentManagement/tableManagement.vue

@@ -43,7 +43,7 @@
         v-loading="detailLoading"
         require-asterisk-position="right"
       >
-        <el-form-item label="选择分类" prop="categoryId" required>
+        <el-form-item label="选择分类" prop="categoryId">
           <el-select v-model="form.categoryId" placeholder="请选择分类" clearable>
             <el-option v-for="item in categoryOptions" :key="item.id" :label="item.name" :value="item.id" />
           </el-select>
@@ -55,7 +55,7 @@
             <el-button type="primary" link @click="addTableRow"> 添加 </el-button>
           </div>
           <template v-for="(row, index) in form.tableRows" :key="index">
-            <el-form-item label="桌号" :prop="'tableRows.' + index + '.tableNo'" :rules="tableNoFieldRules" required>
+            <el-form-item label="桌号" :prop="'tableRows.' + index + '.tableNo'" :rules="tableNoFieldRules">
               <el-input
                 :model-value="row.tableNo"
                 placeholder="如 A01"
@@ -63,7 +63,7 @@
                 @update:model-value="(val: string) => onTableNumberInput(row, val)"
               />
             </el-form-item>
-            <el-form-item label="座位数" :prop="'tableRows.' + index + '.seatCount'" :rules="seatCountFieldRulesRow" required>
+            <el-form-item label="座位数" :prop="'tableRows.' + index + '.seatCount'" :rules="seatCountFieldRulesRow">
               <el-input
                 :model-value="row.seatCount"
                 placeholder="请输入"
@@ -81,7 +81,7 @@
 
         <!-- 编辑:单条 -->
         <template v-else>
-          <el-form-item label="桌号" prop="tableNo" required>
+          <el-form-item label="桌号" prop="tableNo">
             <el-input
               :model-value="form.tableNo"
               placeholder="如 A01"
@@ -90,7 +90,7 @@
               @update:model-value="onTableNumberInputEdit"
             />
           </el-form-item>
-          <el-form-item label="座位数" prop="seatCount" required>
+          <el-form-item label="座位数" prop="seatCount">
             <el-input
               :model-value="seatCountEditDisplay"
               placeholder="请输入"
@@ -281,12 +281,14 @@ const form = reactive<{
 /** 与 group_merchant/pages/scheduledService/addTableNumber.vue 一致 */
 const TABLE_NUMBER_REG = /^[A-Z][0-9]{2}$/;
 
+/** 勿在 el-form-item 上写 required:会注入无 message 的必填规则,嵌套 prop 会报英文「tableRows.0.xxx is required」 */
 const tableNoFieldRules: FormItemRule[] = [
+  { required: true, message: "请输入桌号", trigger: ["blur", "change"] },
   {
     validator: (_rule, value, callback) => {
       const num = String(value ?? "").trim();
       if (!num) {
-        callback(new Error("请输入桌号"));
+        callback();
         return;
       }
       if (!TABLE_NUMBER_REG.test(num)) {
@@ -300,11 +302,12 @@ const tableNoFieldRules: FormItemRule[] = [
 ];
 
 const seatCountFieldRulesRow: FormItemRule[] = [
+  { required: true, message: "请输入座位数", trigger: ["blur", "change"] },
   {
     validator: (_rule, value, callback) => {
       const seat = String(value ?? "").trim();
       if (!seat) {
-        callback(new Error("请输入座位数"));
+        callback();
         return;
       }
       const n = parseInt(seat, 10);
@@ -391,7 +394,7 @@ function onSeatCountInputEdit(val: string) {
 /** 新建只校验分类 + 各行(行规则在 el-form-item 上);编辑校验分类 + 单条桌号/座位数 */
 const formRules = computed<FormRules>(() => {
   const base: FormRules = {
-    categoryId: [{ required: true, message: "请选择位置", trigger: "change" }]
+    categoryId: [{ required: true, message: "请选择分类", trigger: "change" }]
   };
   if (editId.value != null) {
     base.tableNo = tableNoFieldRules;
@@ -422,7 +425,7 @@ async function handleEdit(row: TableRow) {
   try {
     const has = await tableHasReservation(row.id);
     if (has) {
-      ElMessage.warning("此分类所属桌号有预信息,不可修改");
+      ElMessage.warning("此分类所属桌号有预信息,不可修改");
       return;
     }
   } catch (e: any) {
@@ -462,7 +465,7 @@ async function handleDelete(row: TableRow) {
   try {
     const has = await tableHasReservation(row.id);
     if (has) {
-      ElMessage.warning("此分类所属桌号有预信息,不可修改");
+      ElMessage.warning("此分类所属桌号有预信息,不可修改");
       return;
     }
   } catch (e: any) {

+ 2 - 1
src/views/dynamicManagement/friendCoupon.vue

@@ -388,7 +388,8 @@ const viewDetail = (row: any) => {
   } else {
     query = {
       couponId: row.couponId,
-      type: activeName.value
+      type: activeName.value,
+      storeId: row.storeId != null && row.storeId !== "" ? row.storeId : localGet("createdId")
     };
   }
   router.push({

+ 66 - 14
src/views/dynamicManagement/friendCouponDetail.vue

@@ -32,8 +32,14 @@
               {{ couponModel.couponName || couponModel.name || "--" }}
             </div>
           </div>
-          <!-- 面值 -->
-          <div class="detail-item">
+          <!-- 折扣券:折扣率;满减券等:面值 -->
+          <div class="detail-item" v-if="Number(couponModel.couponType) === 2">
+            <div class="detail-label">折扣率</div>
+            <div class="detail-value">
+              {{ formatDiscountRateDisplay(couponModel.discountRate) }}
+            </div>
+          </div>
+          <div class="detail-item" v-else>
             <div class="detail-label">面值</div>
             <div class="detail-value" v-if="couponId">
               {{ formatCurrency(couponModel.nominalValue, 2, "¥") }}
@@ -42,11 +48,11 @@
               {{ formatCurrency(couponModel.nominalValue ?? couponModel.price, 2, "¥") }}
             </div>
           </div>
-          <!-- 数量 -->
+          <!-- 数量:优惠券详情接口返回持有数量 ownedQuantity -->
           <div class="detail-item">
             <div class="detail-label">数量</div>
             <div class="detail-value">
-              {{ couponModel.couponNum ?? couponModel.singleQty ?? "--" }}
+              {{ couponModel.ownedQuantity ?? couponModel.couponNum ?? couponModel.singleQty ?? "--" }}
             </div>
           </div>
           <!-- 结束时间 -->
@@ -59,14 +65,14 @@
               {{ couponModel.endDate || "--" }}
             </div>
           </div>
-          <!-- 有效期 -->
+          <!-- 有效期:接口字段 expirationDate -->
           <div class="detail-item">
             <div class="detail-label">有效期</div>
             <div class="detail-value">
-              {{ couponModel.validityPeriod || couponModel.endDate || "--" }}
+              {{ getValidityDisplay() }}
             </div>
           </div>
-          <!-- 最低消费金额(保留原有) -->
+          <!-- 最低消费金额:绑定接口字段 minimumSpendingAmount -->
           <div class="detail-item">
             <div class="detail-label">最低消费金额</div>
             <div class="detail-value">
@@ -100,6 +106,8 @@ const route = useRoute();
 // 页面ID参数
 const couponId = ref<string>("");
 const voucherId = ref<string>("");
+/** 详情接口 getCouponDetailWithOwnedQty 所需店铺 ID(路由 storeId,缺省时为当前登录店铺) */
+const detailStoreId = ref<string>("");
 
 // 优惠券类型(好友赠我 friendMessage / 我赠好友 myGift)
 const type = ref<string>("");
@@ -116,20 +124,24 @@ const couponModel = ref<any>({
   deleteFlag: 0,
   // 结束日期
   endDate: "",
+  // 有效期(接口)
+  expirationDate: "",
   // ID
   id: 0,
   // 图片URL
   imgUrl: "",
   // 详细列表
   lifeDiscountCouponFriendRuleDetailVos: [],
-  // 最低消费金额
-  minimumSpendingAmount: 0,
+  // 最低消费金额(接口字段 minimumSpendingAmount;不设默认数字,未加载时由 formatCurrency 显示为 --)
+  minimumSpendingAmount: undefined,
   // 金额上限
   moneyHigh: 0,
   // 金额下限
   moneyLow: 0,
   // 面值
   nominalValue: 0,
+  // 折扣率(折扣券 couponType=2,接口 discountRate)
+  discountRate: undefined,
   // 状态
   status: "",
   // 店铺ID
@@ -151,7 +163,18 @@ onMounted(async () => {
     couponId.value = (route.query.couponId as string) || "";
   }
   type.value = (route.query.type as string) || "";
-  if (voucherId.value || couponId.value) {
+  detailStoreId.value =
+    (route.query.storeId != null && String(route.query.storeId) !== ""
+      ? String(route.query.storeId)
+      : String(localGet("createdId") ?? "")) || "";
+
+  if (voucherId.value) {
+    await loadDetailData();
+  } else if (couponId.value) {
+    if (!detailStoreId.value) {
+      ElMessage.warning("缺少店铺ID参数");
+      return;
+    }
     await loadDetailData();
   } else {
     ElMessage.warning("缺少优惠券ID参数");
@@ -185,9 +208,9 @@ const loadDetailData = async () => {
         ElMessage.error(res.msg);
       }
     } else if (couponId.value) {
-      // 使用 couponId 获取详情数据
       const res: any = await getFriendCouponDetail({
-        counponId: couponId.value
+        couponId: couponId.value,
+        storeId: detailStoreId.value
       });
 
       if (res.code === 200) {
@@ -201,12 +224,41 @@ const loadDetailData = async () => {
   }
 };
 
+/** 有效期是否有值(0 / "0" 视为有效,仅 null、undefined、"" 视为缺失) */
+const hasValidityValue = (v: unknown) => v !== null && v !== undefined && v !== "";
+
+/**
+ * 有效期展示:优先 expirationDate,且为 0 时显示 0
+ */
+const getValidityDisplay = () => {
+  const m = couponModel.value;
+  if (hasValidityValue(m.expirationDate)) return m.expirationDate;
+  if (hasValidityValue(m.validityPeriod)) return m.validityPeriod;
+  if (hasValidityValue(m.endDate)) return m.endDate;
+  return "--";
+};
+
+/**
+ * 折扣率展示:绑定 discountRate;接口常见为整数 11–100 表示几点几折(与 newCoupon 说明一致),否则原样加「折」
+ */
+const formatDiscountRateDisplay = (rate: unknown) => {
+  if (rate === null || rate === undefined || rate === "") return "--";
+  const n = Number(rate);
+  if (isNaN(n)) return `${rate}折`;
+  if (n > 10 && n <= 100) {
+    const x = n / 10;
+    const s = Number.isInteger(x) ? String(x) : String(Number(x.toFixed(1)));
+    return `${s.replace(/\.0$/, "")}折`;
+  }
+  return `${n}折`;
+};
+
 /**
  * 获取优惠券类型文案
  */
 const getCouponTypeText = (type: number | string | undefined) => {
-  const typeMap: Record<string, string> = { "1": "折扣券", "2": "满减券" };
-  return type != null ? (typeMap[String(type)] ?? String(type)) : "--";
+  const typeMap: Record<string, string> = { "1": "满减券", "2": "折扣券" };
+  return type != null && type !== "" ? (typeMap[String(type)] ?? String(type)) : "--";
 };
 
 /**

+ 1 - 1
src/views/home/components/go-flow.vue

@@ -219,7 +219,7 @@
                   <el-input v-model="step2Form.storePj[2]" placeholder="请输入" maxlength="2" clearable class="store-pj-input" />
                 </div>
               </el-form-item>
-              <el-form-item label="预服务">
+              <el-form-item label="预服务">
                 <el-radio-group v-model="step2Form.appointment">
                   <el-radio label="提供"> 提供 </el-radio>
                   <el-radio label="不提供"> 不提供 </el-radio>

+ 73 - 35
src/views/storeDecoration/decorationChat.vue

@@ -16,9 +16,25 @@
       </div>
       <div v-else class="message-list">
         <div v-for="(msg, index) in messages" :key="msg.id || index" :class="['message-item', isMine(msg) ? 'mine' : 'other']">
-          <el-avatar class="msg-avatar" :src="isMine(msg) ? myAvatar : ownerAvatar" :size="40">
-            <!-- <span class="avatar-fallback">{{ (isMine(msg) ? myName : ownerName).slice(0, 1) }}</span> -->
-          </el-avatar>
+          <!-- 与 dynamicManagement/myDynamic 一致:有图用 headImg 链,无图用灰底 + Avatar 图标(勿用 localhost 假地址) -->
+          <div class="msg-avatar">
+            <template v-if="isMine(msg)">
+              <img v-if="myAvatarUrl" :src="myAvatarUrl" class="msg-avatar-img" alt="" />
+              <div v-else class="msg-avatar-fallback" aria-hidden="true">
+                <el-icon :size="22">
+                  <Avatar />
+                </el-icon>
+              </div>
+            </template>
+            <template v-else>
+              <img v-if="ownerAvatarDisplay" :src="ownerAvatarDisplay" class="msg-avatar-img" alt="" />
+              <div v-else class="msg-avatar-fallback" aria-hidden="true">
+                <el-icon :size="22">
+                  <Avatar />
+                </el-icon>
+              </div>
+            </template>
+          </div>
           <div class="msg-body">
             <div class="msg-time">
               {{ msg.createdTime }}
@@ -96,17 +112,17 @@
 import { ref, computed, onMounted, onBeforeUnmount, nextTick, watch } from "vue";
 import { useRoute, useRouter } from "vue-router";
 import { ElMessage } from "element-plus";
-import { ArrowLeft, Loading, Picture, VideoPlay } from "@element-plus/icons-vue";
+import { ArrowLeft, Avatar, Loading, Picture, VideoPlay } from "@element-plus/icons-vue";
 import { useWebSocketStore } from "@/stores/modules/websocket";
 import { getChatRecord, messageRead, socketStatus, uploadChatFile } from "@/api/modules/storeDecoration";
 import { localGet } from "@/utils";
+import { getWebSocketBase } from "@/utils/wsBase";
 
 const route = useRoute();
 const router = useRouter();
 const socketStore = useWebSocketStore();
 
-// WebSocket 基础地址(与商家端一致)
-const WS_BASE = (import.meta.env.VITE_WS_BASE || "ws://120.26.186.130:8000/alienStore/socket/").replace(/\/$/, "");
+const isWsReady = () => socketStore.isSocketOpen();
 
 // 会话信息
 const sendId = ref("");
@@ -128,14 +144,23 @@ let statusTimer: ReturnType<typeof setInterval> | null = null;
 
 const userInfo = computed(() => localGet("geeker-user")?.userInfo || {});
 
-// 自己头像与名称(用于气泡旁头像)
-const myAvatar = computed(
-  () =>
-    userInfo.value?.headImg ||
-    userInfo.value?.avatar ||
-    userInfo.value?.userImage ||
-    "http://localhost:5173/static/activity/avatar.svg"
-);
+/** 与 myDynamic.vue「cachedHeadImg」一致:geeker-user 的 headImg → avatar;无有效地址则空串走图标占位 */
+function pickUserHeadUrl(u: Record<string, any> | undefined): string {
+  if (!u) return "";
+  const raw = u.headImg || u.avatar || u.userImage || "";
+  const s = String(raw).trim();
+  return s;
+}
+
+const myAvatarUrl = computed(() => pickUserHeadUrl(userInfo.value as Record<string, any>));
+
+const ownerAvatarDisplay = computed(() => {
+  const s = String(ownerAvatar.value ?? "").trim();
+  if (!s) return "";
+  // 历史占位:无效本地路径,与 myDynamic 无头像态一致改用语义占位
+  if (s.includes("localhost:5173/static/activity/avatar.svg")) return "";
+  return s;
+});
 
 const isMine = (msg: any) => String(msg.senderId || "") === String(sendId.value);
 
@@ -151,7 +176,7 @@ const scrollToBottom = () => {
 const handleSend = async () => {
   const text = inputText.value?.trim();
   if (!text || !receiverId.value) return;
-  if (!socketStore.isConnected) {
+  if (!isWsReady()) {
     ElMessage.warning("连接已断开,请稍后重试");
     return;
   }
@@ -203,7 +228,7 @@ const handleImageSelect = async (e: Event) => {
   const file = target.files?.[0];
   target.value = "";
   if (!file || !receiverId.value) return;
-  if (!socketStore.isConnected) {
+  if (!isWsReady()) {
     ElMessage.warning("连接已断开,请稍后重试");
     return;
   }
@@ -253,7 +278,7 @@ const handleVideoSelect = async (e: Event) => {
   const file = target.files?.[0];
   target.value = "";
   if (!file || !receiverId.value) return;
-  if (!socketStore.isConnected) {
+  if (!isWsReady()) {
     ElMessage.warning("连接已断开,请稍后重试");
     return;
   }
@@ -344,13 +369,18 @@ const initWebSocket = async () => {
     return;
   }
   sendId.value = `store_${phone}`;
+  // 与打包 mode 无关:HTTPS 走 wss://当前域名/alienStore/socket,避免 .env.production 仍为 ws 导致混合内容拦截
+  const WS_BASE = getWebSocketBase();
   const wsUrl =
     WS_BASE.startsWith("wss") || WS_BASE.startsWith("ws")
       ? `${WS_BASE}/store_${phone}`
       : WS_BASE.replace("https", "wss").replace("http", "ws") + `/store_${phone}`;
 
-  if (!socketStore.isConnected || socketStore.lastConnectedUrl !== wsUrl) {
-    await socketStore.connect(wsUrl);
+  if (!isWsReady() || socketStore.lastConnectedUrl !== wsUrl) {
+    const ok = await socketStore.connect(wsUrl);
+    if (!ok || !isWsReady()) {
+      ElMessage.error("即时通讯连接失败,请确认 Nginx 已代理 /alienStore/socket/ 后刷新重试");
+    }
   }
   if (cleanMessageFn) {
     cleanMessageFn();
@@ -435,9 +465,7 @@ watch(
     if (q?.receiverId) {
       receiverId.value = String(q.receiverId);
       ownerName.value = decodeURIComponent(String(q.uName || q.ownerName || "业主"));
-      ownerAvatar.value = q.userImage
-        ? decodeURIComponent(String(q.userImage))
-        : "http://localhost:5173/static/activity/avatar.svg";
+      ownerAvatar.value = q.userImage ? decodeURIComponent(String(q.userImage)) : "";
       requirementId.value = String(q.id || q.requirementId || "");
       loadChatRecord();
     }
@@ -448,9 +476,7 @@ watch(
 onMounted(async () => {
   receiverId.value = String(route.query.receiverId || "");
   ownerName.value = decodeURIComponent(String(route.query.uName || route.query.ownerName || "业主"));
-  ownerAvatar.value = route.query.userImage
-    ? decodeURIComponent(String(route.query.userImage))
-    : "http://localhost:5173/static/activity/avatar.svg";
+  ownerAvatar.value = route.query.userImage ? decodeURIComponent(String(route.query.userImage)) : "";
   requirementId.value = String(route.query.id || route.query.requirementId || "");
 
   if (!receiverId.value) {
@@ -520,17 +546,29 @@ onBeforeUnmount(() => {
     align-items: flex-start;
     .msg-avatar {
       flex-shrink: 0;
+      width: 40px;
+      height: 40px;
       overflow: hidden;
-      .avatar-fallback {
-        display: flex;
-        align-items: center;
-        justify-content: center;
-        width: 100%;
-        height: 100%;
-        font-size: 16px;
-        color: #ffffff;
-        background: #c0c4cc;
-      }
+      border-radius: 50%;
+    }
+    .msg-avatar-img {
+      display: block;
+      width: 40px;
+      height: 40px;
+      object-fit: cover;
+      border-radius: 50%;
+    }
+
+    /* 与 myDynamic .user-avatar-large 无图态一致:浅灰圆底 + User 轮廓图标 */
+    .msg-avatar-fallback {
+      display: flex;
+      align-items: center;
+      justify-content: center;
+      width: 40px;
+      height: 40px;
+      color: #909399;
+      background: #f5f7fa;
+      border-radius: 50%;
     }
     &.mine {
       flex-direction: row-reverse;