Przeglądaj źródła

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

LuTong 2 miesięcy temu
rodzic
commit
69fdde7252
33 zmienionych plików z 2362 dodań i 219 usunięć
  1. 5 1
      .env.development
  2. 18 12
      src/api/modules/businessData.ts
  3. 85 0
      src/api/modules/contractManagement.ts
  4. 23 0
      src/api/modules/headerNotice.ts
  5. 3 5
      src/api/modules/priceList.ts
  6. 21 0
      src/api/modules/storeDecoration.ts
  7. 48 2
      src/assets/json/authMenuList.json
  8. 0 1
      src/hooks/useTable.ts
  9. 1 1
      src/layouts/components/Header/ToolBarRight.vue
  10. 82 5
      src/layouts/components/Header/components/NotificationBell.vue
  11. 295 61
      src/layouts/components/Header/components/NotificationDrawerContent.vue
  12. 143 0
      src/stores/modules/websocket.ts
  13. 1 0
      src/typings/global.d.ts
  14. 2 4
      src/views/businessData/overview.vue
  15. 152 0
      src/views/contractManagement/detail.vue
  16. 306 0
      src/views/contractManagement/index.vue
  17. 2 2
      src/views/dynamicManagement/friendCoupon.vue
  18. 52 6
      src/views/dynamicManagement/index.vue
  19. 4 2
      src/views/dynamicManagement/reviewAppeal.vue
  20. 364 2
      src/views/dynamicManagement/userDynamic.vue
  21. 1 1
      src/views/operationManagement/activityDetail.vue
  22. 2 2
      src/views/operationManagement/caseDetail.vue
  23. 2 21
      src/views/operationManagement/cases.vue
  24. 2 2
      src/views/operationManagement/personnel.vue
  25. 4 10
      src/views/operationManagement/personnelDetail.vue
  26. 1 4
      src/views/performance/edit.vue
  27. 0 4
      src/views/priceList/edit.vue
  28. 1 0
      src/views/priceList/index.vue
  29. 635 0
      src/views/storeDecoration/decorationChat.vue
  30. 21 11
      src/views/storeDecoration/decorationCompany.vue
  31. 33 39
      src/views/storeDecoration/decorationCompanyDetail.vue
  32. 42 16
      src/views/storeDecoration/detail.vue
  33. 11 5
      src/views/storeDecoration/personnelConfig/index.vue

+ 5 - 1
.env.development

@@ -24,7 +24,11 @@ VITE_API_URL_PLATFORM = /api/alienStorePlatform
 
 # 开发环境跨域代理,支持配置多个
 # VITE_PROXY = [["/api","https://api.ailien.shop"]] #生产环境
-# VITE_PROXY = [["/api","http://192.168.2.57:7000"]] # 邹建宇
+VITE_PROXY = [["/api","http://120.26.186.130:8000"]] # 邹建宇
+
+# WebSocket 基础地址(分享等能力,与商家端一致)
+VITE_WS_BASE = ws://120.26.186.130:8000/alienStore/socket/
+
 
 # 接口加密配置
 # 加密功能总开关

+ 18 - 12
src/api/modules/businessData.ts

@@ -1,4 +1,6 @@
 import httpApi from "@/api/indexApi";
+import http from "@/api";
+import { PORT_NONE } from "@/api/config/servicePort";
 
 /** 经营统计数据 - 请求参数 */
 export interface GetStatisticsParams {
@@ -85,10 +87,10 @@ export interface GetStatisticsData {
 }
 
 /**
- * 获取经营统计数据(与 getDistrict 同源:api/alienStore
+ * 获取经营统计数据(平台接口,与 getMerchantByPhone 同源:http + VITE_API_URL_PLATFORM
  */
 export const getStatistics = (params: GetStatisticsParams) => {
-  return httpApi.get<GetStatisticsData>(`/alienStore/store/operational/statistics/getStatistics`, params, {
+  return http.get<GetStatisticsData>(PORT_NONE + `/platform/operational/statistics/getPlatformStatistics`, params, {
     loading: false
   });
 };
@@ -132,9 +134,13 @@ export interface GetStatisticsComparisonData {
  * 获取经营统计对比数据
  */
 export const getStatisticsComparison = (params: GetStatisticsComparisonParams) => {
-  return httpApi.get<GetStatisticsComparisonData>(`/alienStore/store/operational/statistics/getStatisticsComparison`, params, {
-    loading: false
-  });
+  return http.get<GetStatisticsComparisonData>(
+    PORT_NONE + `/platform/operational/statistics/getPlatformStatisticsComparison`,
+    params,
+    {
+      loading: false
+    }
+  );
 };
 
 /** AI 分析历史详情 - 请求参数 */
@@ -155,7 +161,7 @@ export interface GetHistoryDetailData {
  * 获取经营统计历史详情(AI 分析),调用方式同 getStatisticsComparison
  */
 export const getHistoryDetail = (params: GetHistoryDetailParams) => {
-  return httpApi.get<GetHistoryDetailData>(`/alienStore/store/operational/statistics/history/detail`, params, {
+  return http.get<GetHistoryDetailData>(PORT_NONE + `/platform/operational/statistics/history/platformDetail`, params, {
     loading: false
   });
 };
@@ -184,10 +190,10 @@ export interface GetHistoryReportListData {
 }
 
 /**
- * 获取历史分析报告列表 /store/operational/statistics/history/list
+ * 获取历史分析报告列表 /platform/operational/statistics/history/platformList
  */
 export const getHistoryReportList = (params: GetHistoryReportListParams) => {
-  return httpApi.get<GetHistoryReportListData>(`/alienStore/store/operational/statistics/history/list`, params, {
+  return http.get<GetHistoryReportListData>(PORT_NONE + `/platform/operational/statistics/history/platformList`, params, {
     loading: false
   });
 };
@@ -198,10 +204,10 @@ export interface BatchDeleteHistoryParams {
 }
 
 /**
- * 批量删除历史分析报告 /store/operational/statistics/history/batchDelete(DELETE)
+ * 批量删除历史分析报告 /platform/operational/statistics/history/batchPlatformDelete(DELETE)
  */
 export const batchDeleteHistoryReport = (params: BatchDeleteHistoryParams) => {
-  return httpApi.delete<unknown>(`/alienStore/store/operational/statistics/history/batchDelete`, params, {
+  return http.delete<unknown>(PORT_NONE + `/platform/operational/statistics/history/batchPlatformDelete`, params, {
     loading: true
   });
 };
@@ -213,11 +219,11 @@ export interface GenerateStatisticsComparisonPdfByHistoryIdParams {
 }
 
 /**
- * 根据历史记录 id 生成对比报告 PDF /store/operational/statistics/generateStatisticsComparisonPdfByHistoryId
+ * 根据历史记录 id 生成对比报告 PDF /platform/operational/statistics/generateStatisticsComparisonPdfByHistoryId
  * 返回 data 为 PDF 文件地址(字符串)
  */
 export const generateStatisticsComparisonPdfByHistoryId = (params: GenerateStatisticsComparisonPdfByHistoryIdParams) => {
-  return httpApi.get<string>(`/alienStore/store/operational/statistics/generateStatisticsComparisonPdfByHistoryId`, params, {
+  return http.get<string>(PORT_NONE + `/platform/operational/statistics/generateStatisticsComparisonPdfByHistoryId`, params, {
     loading: true
   });
 };

+ 85 - 0
src/api/modules/contractManagement.ts

@@ -0,0 +1,85 @@
+/**
+ * 合同管理相关接口
+ * 使用独立的 axios 实例,端口为 33333
+ */
+import axios from "axios";
+import { ResultEnum } from "@/enums/httpEnum";
+import { useUserStore } from "@/stores/modules/user";
+import { ElMessage } from "element-plus";
+import { LOGIN_URL } from "@/config";
+import router from "@/routers";
+
+// 合同接口专用配置 - 使用不同的端口
+const CONTRACT_BASE_URL = "http://120.26.186.130:33333";
+
+// 创建专门用于合同接口的 axios 实例
+// 注意:不使用 withCredentials,因为认证通过 Authorization header 传递,且服务器 CORS 配置为通配符
+const contractAxios = axios.create({
+  baseURL: CONTRACT_BASE_URL,
+  timeout: ResultEnum.TIMEOUT as number,
+  withCredentials: false
+});
+
+// 请求拦截:补充 token
+contractAxios.interceptors.request.use(
+  config => {
+    const userStore = useUserStore();
+    if (config.headers) {
+      (config.headers as any).Authorization = userStore.token;
+    }
+    return config;
+  },
+  error => Promise.reject(error)
+);
+
+// 响应拦截:直接返回响应数据
+contractAxios.interceptors.response.use(
+  response => {
+    const data = response.data;
+    return data;
+  },
+  error => {
+    ElMessage.error("请求失败,请稍后重试");
+    return Promise.reject(error);
+  }
+);
+
+/**
+ * 获取合同列表
+ * @param {string} storeId - 店铺ID
+ * @param {object} params - 请求参数 { page, page_size, status, file_name }
+ * @returns {Promise}
+ */
+export const getContractList = (storeId: string | number, params: any = {}) => {
+  return contractAxios.get(`/api/store/contracts/${storeId}`, { params });
+};
+
+/**
+ * 获取合同详情
+ * @param {string} storeId - 店铺ID
+ * @param {string} contractId - 合同ID
+ * @returns {Promise}
+ */
+export const getContractDetail = (storeId: string | number, contractId: string | number) => {
+  return contractAxios.get(`/api/store/contarcts/${storeId}/${contractId}`);
+};
+
+/**
+ * 签署合同
+ * @param {string} storeId - 店铺ID
+ * @param {string} contractId - 合同ID
+ * @param {object} data - 签署数据
+ * @returns {Promise}
+ */
+export const signContract = (storeId: string | number, contractId: string | number, data: any = {}) => {
+  return contractAxios.post(`/api/store/contarcts/${storeId}/${contractId}/sign`, data);
+};
+
+/**
+ * 获取合同签署链接(查看合同)
+ * @param {object} data - 请求参数 { sign_flow_id, contact_phone }
+ * @returns {Promise}
+ */
+export const getContractSignUrl = (data: any = {}) => {
+  return contractAxios.post(`/api/store/esign/signurl`, data);
+};

+ 23 - 0
src/api/modules/headerNotice.ts

@@ -101,3 +101,26 @@ export const getMessageList = (params: { receiverId: string; friendType?: number
     }
   );
 };
+
+/**
+ * 未读消息数量(与商家端 tabbar getMessageNoRead 一致)
+ * GET /message/getAllNoReadCount?receiverId=xxx(alien-store)
+ */
+export const getMessageNoRead = (params: { receiverId: string }) => {
+  return httpApi.get<number>(`/alienStore/message/getAllNoReadCount`, params, {
+    loading: false
+  });
+};
+
+/**
+ * 陌生人/未关注人消息未读数量(与商家端 getStrangerMessageNum 一致)
+ * GET /message/getStrangerMessageNum?receiverId=xxx(alien-store)
+ * 返回 { notReadCount, userName?, createdTime? } 或类似结构
+ */
+export const getStrangerMessageNum = (params: { receiverId: string }) => {
+  return httpApi.get<{ notReadCount?: number; userName?: string; createdTime?: string; [key: string]: any }>(
+    `/alienStore/message/getStrangerMessageNum`,
+    params,
+    { loading: false }
+  );
+};

+ 3 - 5
src/api/modules/priceList.ts

@@ -118,6 +118,7 @@ export interface PriceListQuery {
   prodName?: string;
   status?: number | string;
   shelfStatus?: number | string;
+  origin?: number | string;
   businessSection?: string | number;
   /** 提交时间起(提交时间段搜索) */
   startCreatedTime?: string;
@@ -153,7 +154,7 @@ export const getPriceListPage = (params: PriceListQuery) => {
       pageSize,
       storeId,
       type: 1, // 1-美食
-      origin: 1
+      origin: params.origin !== undefined && params.origin !== null && params.origin !== "" ? params.origin : 0
     };
 
     if (params.prodName) query.name = params.prodName;
@@ -170,16 +171,13 @@ export const getPriceListPage = (params: PriceListQuery) => {
     pageNum,
     pageSize,
     storeId,
-    origin: 1
+    origin: params.origin !== undefined && params.origin !== null && params.origin !== "" ? params.origin : 0
   };
 
   if (params.prodName) query.name = params.prodName;
   if (params.status !== undefined && params.status !== null && params.status !== "") {
     query.status = params.status;
   }
-  if (params.shelfStatus !== undefined && params.shelfStatus !== null && params.shelfStatus !== "") {
-    query.shelfStatus = params.shelfStatus;
-  }
   if (params.startCreatedTime) query.startCreatedTime = params.startCreatedTime;
   if (params.endCreatedTime) query.endCreatedTime = params.endCreatedTime;
 

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

@@ -41,6 +41,22 @@ export const getDecorationDetail = (params: { id: number | string }) => {
   return httpApi.get(`/alienStore/renovation/requirement/getDetail`, params);
 };
 
+// ==================== 聊天相关(与商家端 message API 一致) ====================
+// 获取聊天记录
+export const getChatRecord = (params: { receiverId: string; senderId: string }) => {
+  return httpApi.get(`/alienStore/message/getMessageListByReceiverId`, params, { loading: false });
+};
+
+// 消息已读
+export const messageRead = (params: { receiverId: string; senderId: string }) => {
+  return httpApi.get(`/alienStore/message/read`, params, { loading: false });
+};
+
+// WebSocket 连接状态
+export const socketStatus = () => {
+  return httpApi.get(`/alienStore/websocket/getTokenStatus`, {}, { loading: false });
+};
+
 // 保存或更新装修需求
 export const saveOrUpdateDecoration = (params: any) => {
   return httpApi.post(`/alienStore/renovation/requirement/saveOrUpdate`, params);
@@ -51,6 +67,11 @@ export const uploadDecorationImage = (params: FormData) => {
   return httpApi.post<Upload.ResFileUrl>(`/alienStore/file/uploadMore`, params, { cancel: false });
 };
 
+// 聊天图片/视频上传(与商家端一致,使用同一接口)
+export const uploadChatFile = (params: FormData) => {
+  return httpApi.post<Upload.ResFileUrl>(`/alienStore/file/uploadMore`, params, { cancel: false });
+};
+
 // 删除装修需求
 export const deleteDecoration = (params: { id: number | string }) => {
   return httpApi.post(`/alienStore/renovation/requirement/delete`, {}, { params });

+ 48 - 2
src/assets/json/authMenuList.json

@@ -282,7 +282,7 @@
             "title": "数据概况",
             "activeMenu": "/businessData",
             "isLink": "",
-            "isHide": true,
+            "isHide": false,
             "isFull": false,
             "isAffix": false,
             "isKeepAlive": false
@@ -297,7 +297,7 @@
             "title": "历史分析",
             "activeMenu": "/businessData",
             "isLink": "",
-            "isHide": true,
+            "isHide": false,
             "isFull": false,
             "isAffix": false,
             "isKeepAlive": false
@@ -1049,6 +1049,21 @@
           }
         },
         {
+          "path": "/storeDecorationManagement/decorationChat",
+          "name": "decorationChat",
+          "component": "/storeDecoration/decorationChat",
+          "meta": {
+            "icon": "ChatDotRound",
+            "title": "联系业主",
+            "activeMenu": "/storeDecorationManagement/decorationCompany",
+            "isLink": "",
+            "isHide": true,
+            "isFull": false,
+            "isAffix": false,
+            "isKeepAlive": false
+          }
+        },
+        {
           "path": "/storeDecorationManagement/decorationCompany",
           "name": "decorationCompany",
           "component": "/storeDecoration/decorationCompany",
@@ -1079,6 +1094,37 @@
         }
       ]
     },
+    {
+      "path": "/contractManagement",
+      "name": "contractManagement",
+      "component": "/contractManagement/index",
+      "meta": {
+        "icon": "Menu",
+        "title": "合同管理",
+        "isLink": "",
+        "isHide": false,
+        "isFull": false,
+        "isAffix": false,
+        "isKeepAlive": false
+      },
+      "children": [
+        {
+          "path": "/contractManagement/detail",
+          "name": "contractManagementDetail",
+          "component": "/contractManagement/detail",
+          "meta": {
+            "icon": "Menu",
+            "title": "合同详情",
+            "activeMenu": "/contractManagement",
+            "isLink": "",
+            "isHide": true,
+            "isFull": false,
+            "isAffix": false,
+            "isKeepAlive": false
+          }
+        }
+      ]
+    },
 
     {
       "path": "/accountRoleManagement",

+ 0 - 1
src/hooks/useTable.ts

@@ -60,7 +60,6 @@ export const useTable = (
       // 先把初始化参数和分页参数放到总参数里面
       Object.assign(state.totalParam, initParam, isPageable ? pageParam.value : {});
       let { data } = await api({ ...state.searchInitParam, ...state.totalParam });
-      console.log("data11", data);
       dataCallBack && (data = dataCallBack(data));
       state.tableData = isPageable ? data.list : data;
       // 解构后台返回的分页数据 (如果有分页更新分页信息)

+ 1 - 1
src/layouts/components/Header/ToolBarRight.vue

@@ -6,7 +6,7 @@
       <SearchMenu id="searchMenu" />
       <ThemeSetting id="themeSetting" />
       <NotificationBell id="notificationBell" />
-      <Message id="message" />
+      <!-- <Message id="message" /> -->
       <Fullscreen id="fullscreen" />
     </div>
     <span class="username">{{ userInfo.name != null ? userInfo.name : userInfo.nickName }}</span>

+ 82 - 5
src/layouts/components/Header/components/NotificationBell.vue

@@ -1,28 +1,104 @@
 <template>
   <div class="notification-bell">
-    <el-badge :value="unreadCount > 0 ? unreadCount : undefined" class="item">
-      <el-icon :size="18" class="toolBar-icon bell-icon" @click="openDialog">
+    <el-badge :is-dot="unreadCount > 0" class="item">
+      <el-icon :size="24" class="toolBar-icon bell-icon" @click="openDialog">
         <Bell />
       </el-icon>
     </el-badge>
     <el-dialog v-model="dialogVisible" width="880px" class="notification-dialog" destroy-on-close :show-close="true" align-center>
-      <NotificationDrawerContent v-if="dialogVisible" />
+      <NotificationDrawerContent v-if="dialogVisible" ref="drawerContentRef" @close="handleDrawerClose" />
     </el-dialog>
   </div>
 </template>
 
 <script setup lang="ts">
-import { ref } from "vue";
+import { ref, onMounted, onBeforeUnmount } from "vue";
+import { useDebounceFn } from "@vueuse/core";
 import { Bell } from "@element-plus/icons-vue";
+import { getMessageNoRead } from "@/api/modules/headerNotice";
+import { localGet } from "@/utils";
+import { useWebSocketStore } from "@/stores/modules/websocket";
 import NotificationDrawerContent from "./NotificationDrawerContent.vue";
 
 const dialogVisible = ref(false);
-// 未读数量(先写死,后续接接口)
 const unreadCount = ref(0);
+const drawerContentRef = ref<InstanceType<typeof NotificationDrawerContent> | null>(null);
+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;
+  if (!phone) {
+    unreadCount.value = 0;
+    return;
+  }
+  try {
+    const res: any = await getMessageNoRead({
+      receiverId: `store_${phone}`
+    });
+    const data = res?.data ?? res;
+    unreadCount.value = Number(data) || 0;
+  } catch (error) {
+    console.error("获取消息数量失败", error);
+    unreadCount.value = 0;
+  }
+}
+
+/** 收到 WebSocket 消息时刷新(与商家端 handleWebSocketMessage 一致,debounce 300ms) */
+const handleWebSocketMessage = useDebounceFn(() => {
+  getMessage();
+  if (dialogVisible.value && drawerContentRef.value?.refresh) {
+    drawerContentRef.value.refresh();
+  }
+}, 300);
+
+async function connectWebSocket() {
+  const phone = localGet("iphone") || localGet("geeker-user")?.userInfo?.phone;
+  if (!phone) return;
+
+  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 (cleanMessageFn) {
+    cleanMessageFn();
+    cleanMessageFn = null;
+  }
+
+  cleanMessageFn = socketStore.subscribe("message", () => {
+    handleWebSocketMessage();
+  });
+}
 
 function openDialog() {
   dialogVisible.value = true;
+  getMessage();
 }
+
+function handleDrawerClose() {
+  dialogVisible.value = false;
+  getMessage();
+}
+
+onMounted(() => {
+  getMessage();
+  connectWebSocket();
+});
+
+onBeforeUnmount(() => {
+  if (cleanMessageFn) {
+    cleanMessageFn();
+    cleanMessageFn = null;
+  }
+});
 </script>
 
 <style scoped lang="scss">
@@ -31,6 +107,7 @@ function openDialog() {
   align-items: center;
   .item {
     :deep(.el-badge__content) {
+      background-color: #ff2442;
       border: none;
     }
   }

+ 295 - 61
src/layouts/components/Header/components/NotificationDrawerContent.vue

@@ -36,11 +36,12 @@
               <component :is="cat.icon" />
             </el-icon>
             <span class="side-text">{{ cat.title }}</span>
+            <el-badge v-if="cat.unread > 0" :value="handleReadCount(cat.unread)" class="side-badge" />
           </div>
         </template>
       </div>
 
-      <!-- 右侧:通知列表 -->
+      <!-- 右侧:通知列表(系统通知/订单提醒:卡片式;互动:头像+发送者+内容+日期) -->
       <div v-if="activeTab === 'notice'" class="drawer-main-wrap">
         <div class="drawer-main">
           <div v-if="currentLoading" class="empty-tip">
@@ -50,27 +51,58 @@
             <span style="margin-left: 8px">加载中...</span>
           </div>
           <template v-else>
-            <div
-              v-for="(item, index) in currentList"
-              :key="item.id + '_' + index"
-              class="notice-card"
-              :class="{ unread: item.unread }"
-            >
-              <div class="card-row">
-                <span class="card-title">
-                  {{ item.title }}
-                  <span v-if="item.unread" class="unread-dot" />
-                </span>
-                <span class="card-date">{{ item.date }}</span>
-              </div>
-              <div class="card-content">
-                {{ item.content }}
+            <!-- 系统通知、订单提醒:卡片式 -->
+            <template v-if="activeCategory !== 'related'">
+              <div
+                v-for="(item, index) in currentList"
+                :key="item.id + '_' + index"
+                class="notice-card"
+                :class="{ unread: item.unread }"
+              >
+                <div class="card-row">
+                  <span class="card-title">
+                    {{ item.title }}
+                    <span v-if="item.unread" class="unread-dot" />
+                  </span>
+                  <span class="card-date">{{ item.date }}</span>
+                </div>
+                <div class="card-content">
+                  {{ processContent(item) }}
+                </div>
+                <div class="card-actions">
+                  <el-button size="small" @click="handleViewDetail(item)"> 查看详情 </el-button>
+                  <!-- <el-button size="small" @click="handleDelete(item, index)"> 删除 </el-button> -->
+                </div>
               </div>
-              <div class="card-actions">
-                <el-button size="small" @click="handleViewDetail(item)"> 查看详情 </el-button>
-                <el-button size="small" @click="handleDelete(item, index)"> 删除 </el-button>
+            </template>
+            <!-- 互动:头像+发送者+内容+日期+删除(与商家端 inform 列表一致) -->
+            <template v-else>
+              <div
+                v-for="(item, index) in currentList"
+                :key="item.id + '_' + index"
+                class="message-card"
+                :class="{ unread: item.unread }"
+              >
+                <div class="message-avatar">
+                  <el-avatar :size="40" :src="item.userImage">
+                    <el-icon><UserFilled /></el-icon>
+                  </el-avatar>
+                  <span v-if="item.unread" class="unread-dot message-unread-dot" />
+                </div>
+                <div class="message-body" @click="handleViewDetail(item)">
+                  <div class="message-row">
+                    <span class="message-sender">{{ item.userName || item.title || "未知" }}</span>
+                    <span class="message-date">{{ item.date }}</span>
+                  </div>
+                  <div class="message-content">
+                    {{ processContent(item) }}
+                  </div>
+                  <!-- <div class="message-actions">
+                    <el-button size="small" type="default" @click.stop="handleDelete(item, index)"> 删除 </el-button>
+                  </div> -->
+                </div>
               </div>
-            </div>
+            </template>
             <div v-if="currentList.length === 0" class="empty-tip">暂无数据</div>
           </template>
         </div>
@@ -97,8 +129,15 @@
             </el-icon>
             <span style="margin-left: 8px">加载中...</span>
           </div>
+          <!-- @click="handleMessageItemClick(item)" -->
           <template v-else>
-            <div v-for="(item, index) in currentMessageList" :key="item.id + '_' + index" class="message-card">
+            <div
+              v-for="(item, index) in currentMessageList"
+              :key="item.id + '_' + index"
+              class="message-card message-card-clickable"
+              :class="{ unread: item.unread }"
+              @click="handleMessageItemClick(item)"
+            >
               <div class="message-avatar">
                 <el-avatar :size="40" :src="item.avatar">
                   <el-icon><UserFilled /></el-icon>
@@ -109,11 +148,16 @@
                   <span class="message-sender">{{ item.senderName }}</span>
                   <span class="message-date">{{ item.date }}</span>
                 </div>
-                <div class="message-content">
-                  {{ item.content }}
-                </div>
-                <div class="message-actions">
-                  <el-button size="small" type="default" @click="handleMessageDelete(item, index)"> 删除 </el-button>
+                <div class="message-content-row">
+                  <span class="message-content">{{ processContent(item) }}</span>
+                  <div class="message-status">
+                    <!-- 非免打扰:显示数字徽标 -->
+                    <span v-if="item.isNotDisturb !== '1' && (item.notReadCount ?? 0) > 0" class="message-count">
+                      {{ handleReadCount(item.notReadCount!) }}
+                    </span>
+                    <!-- 免打扰:显示小红点 -->
+                    <span v-else-if="item.isNotDisturb === '1' && (item.notReadCount ?? 0) > 0" class="message-dot" />
+                  </div>
                 </div>
               </div>
             </div>
@@ -133,24 +177,33 @@
 </template>
 
 <script setup lang="ts">
-import { ref, computed, onMounted, watch } from "vue";
+import { ref, computed, onMounted, watch, markRaw } from "vue";
+import { useRouter } from "vue-router";
 import { House, List, User, Loading, Message, UserFilled } from "@element-plus/icons-vue";
 import { localGet } from "@/utils";
 import {
   getNoticeListForHeader,
   readNoticeById,
   getCountUnreadByType,
-  getNoFriendMessage,
-  getMessageList
+  getMessageList,
+  getStrangerMessageNum
 } from "@/api/modules/headerNotice";
 import type { NoFriendMessageItem } from "@/api/modules/headerNotice";
 
+const emit = defineEmits<{ (e: "close"): void }>();
+const router = useRouter();
+
 interface NoticeItem {
   id: string;
   title: string;
   content: string;
   date: string;
   unread: boolean;
+  /** 互动类可能有发送者与头像 */
+  userName?: string;
+  userImage?: string;
+  /** 类型,用于 processContent 展示 */
+  type?: string;
 }
 
 interface MessageItem {
@@ -159,7 +212,21 @@ interface MessageItem {
   content: string;
   date: string;
   unread: boolean;
+  /** 未读数量,用于展示红点/数字 */
+  notReadCount?: number;
+  /** 免打扰:'0' 非免打扰显示数字,'1' 免打扰显示小红点(与商家端一致) */
+  isNotDisturb?: string;
   avatar?: string;
+  /** 发送方 phoneId,用于跳转聊天页 */
+  phoneId?: string;
+  /** 发送方 id,用于跳转聊天页 */
+  senderId?: string;
+  /** 消息类型,用于 processContent 展示 */
+  type?: string;
+  /** 语音时长等,用于 getVoiceDuration */
+  voiceDuration?: number;
+  duration?: number;
+  [key: string]: any;
 }
 
 const activeTab = ref<"notice" | "message">("notice");
@@ -172,16 +239,17 @@ const noticeTypeByKey: Record<string, number> = {
   order: 2,
   related: 0
 };
+// 与商家端一致:系统通知、订单提醒、互动(其余通知均为互动类别)。icon 用 markRaw 避免被转为响应式触发 Vue 警告
 const noticeCategories = ref([
-  { key: "system", title: "系统通知", icon: House, unread: 0 },
-  { key: "order", title: "订单提醒", icon: List, unread: 0 },
-  { key: "related", title: "与我相关", icon: User, unread: 0 }
+  { key: "system", title: "系统通知", icon: markRaw(House), unread: 0 },
+  { key: "order", title: "订单提醒", icon: markRaw(List), unread: 0 },
+  { key: "related", title: "互动", icon: markRaw(User), unread: 0 }
 ]);
 
 // 消息 Tab 分类(如图:未关注人消息、消息列表)
 const messageCategories = ref([
-  { key: "unfollowed", title: "未关注人消息", icon: User, unread: 0 },
-  { key: "messageList", title: "消息列表", icon: Message, unread: 0 }
+  { key: "unfollowed", title: "未关注人消息", icon: markRaw(User), unread: 0 },
+  { key: "messageList", title: "消息列表", icon: markRaw(Message), unread: 0 }
 ]);
 
 const listByCategory = ref<Record<string, NoticeItem[]>>({
@@ -248,41 +316,75 @@ function switchMessageCategory(key: string) {
   }
 }
 
-// 未关注人消息:/message/getNoFriendMessage,参数 receiverId
+// 未关注人消息:/message/getNoFriendMessage,参数 receiverId。映射与商家端 message-notices 一致
 function mapNoFriendToMessageItem(item: NoFriendMessageItem): MessageItem {
   const createdTime = item.createdTime ?? "";
   const dateStr = createdTime.includes(" ") ? createdTime.split(" ")[0].replace(/-/g, "/") : createdTime.replace(/-/g, "/");
+  const notReadCount = item.notReadCount ?? (item.isRead === 0 ? 1 : 0);
   return {
     id: String(item.id ?? ""),
     senderName: item.senderName ?? item.userName ?? "",
     content: item.content ?? "",
     date: dateStr,
     unread: item.isRead === 0,
-    avatar: item.userImage ?? item.senderImg ?? item.storeImg ?? undefined
+    notReadCount,
+    isNotDisturb: (item as any).isNotDisturb ?? "0",
+    avatar: item.userImage ?? item.senderImg ?? item.storeImg ?? undefined,
+    phoneId: (item as any).phoneId != null ? String((item as any).phoneId) : undefined,
+    senderId: item.senderId != null ? String(item.senderId) : undefined,
+    type: item.type,
+    voiceDuration: (item as any).voiceDuration ?? (item as any).duration,
+    duration: (item as any).duration
   };
 }
 
-async function fetchNoFriendMessage() {
+// 未关注人消息未读数:使用 message/getStrangerMessageNum 接口,取 data.notReadCount
+async function fetchUnfollowedUnreadCount() {
   const receiverId = getReceiverId();
   if (!receiverId) {
-    messageListByCategory.value.unfollowed = [];
     const cat = messageCategories.value.find(c => c.key === "unfollowed");
     if (cat) cat.unread = 0;
     return;
   }
+  try {
+    const res: any = await getStrangerMessageNum({ receiverId: "store_" + receiverId });
+    if (res?.mas == "暂无承载数据" || res?.msg == "暂无承载数据") {
+      const cat = messageCategories.value.find(c => c.key === "unfollowed");
+      if (cat) cat.unread = 0;
+      return;
+    }
+    const data = res?.data ?? res;
+    const notReadCount = data?.notReadCount ?? 0;
+    const cat = messageCategories.value.find(c => c.key === "unfollowed");
+    if (cat) cat.unread = Number(notReadCount) || 0;
+  } catch (e) {
+    const cat = messageCategories.value.find(c => c.key === "unfollowed");
+    if (cat) cat.unread = 0;
+  }
+}
+
+// 陌生人/未关注人消息列表:使用 message/getMessageList,friendType=2(与商家端 message-notFriend 一致)
+async function fetchNoFriendMessage() {
+  const receiverId = getReceiverId();
+  if (!receiverId) {
+    messageListByCategory.value.unfollowed = [];
+    await fetchUnfollowedUnreadCount();
+    return;
+  }
   messageLoading.value = true;
   try {
-    const res: any = await getNoFriendMessage({ receiverId: "store_" + receiverId });
+    const res: any = await getMessageList({ receiverId: "store_" + receiverId, friendType: 2 });
     const data = res?.data ?? res;
+    if (data?.msg == "暂无承载数据") {
+      messageListByCategory.value.unfollowed = [];
+      const cat = messageCategories.value.find(c => c.key === "unfollowed");
+      if (cat) cat.unread = 0;
+      return;
+    }
     const rawList = Array.isArray(data) ? data : data ? [data] : [];
     const list: MessageItem[] = rawList.map((item: NoFriendMessageItem) => mapNoFriendToMessageItem(item));
     messageListByCategory.value.unfollowed = list;
-    const unreadTotal = rawList.reduce(
-      (sum: number, item: NoFriendMessageItem) => sum + (item.notReadCount ?? (item.isRead === 0 ? 1 : 0)),
-      0
-    );
-    const cat = messageCategories.value.find(c => c.key === "unfollowed");
-    if (cat) cat.unread = unreadTotal;
+    await fetchUnfollowedUnreadCount();
   } catch (e) {
     messageListByCategory.value.unfollowed = [];
     const cat = messageCategories.value.find(c => c.key === "unfollowed");
@@ -292,7 +394,7 @@ async function fetchNoFriendMessage() {
   }
 }
 
-// 消息列表:/message/getMessageList,参数 friendType 默认 0、receiverId,返回结构与 getNoFriendMessage 一致
+// 消息列表:/message/getMessageList,friendType=1 仅返回已关注/好友消息(不包含未关注人,与商家端一致)
 async function fetchMessageList() {
   const receiverId = getReceiverId();
   if (!receiverId) {
@@ -303,7 +405,7 @@ async function fetchMessageList() {
   }
   messageLoading.value = true;
   try {
-    const res: any = await getMessageList({ receiverId: "store_" + receiverId, friendType: 0 });
+    const res: any = await getMessageList({ receiverId: "store_" + receiverId, friendType: 1 });
     const data = res?.data ?? res;
     const rawList = Array.isArray(data) ? data : data ? [data] : [];
     const list: MessageItem[] = rawList.map((item: NoFriendMessageItem) => mapNoFriendToMessageItem(item));
@@ -328,6 +430,60 @@ function getReceiverId(): string {
   return localGet("iphone") || localGet("geeker-user")?.userInfo?.phone || "";
 }
 
+// 未读数量展示:超过 99 显示 99(与商家端 handleReadCount 一致)
+function handleReadCount(val: number): string {
+  if (Number(val) > 99) return "99";
+  return String(val);
+}
+
+// 点击消息列表项:关闭抽屉并跳转聊天页(与 storeDecoration 装修聊天一致,receiverId 用 phoneId)
+function handleMessageItemClick(item: MessageItem) {
+  const receiverId = item.phoneId ?? item.senderId;
+
+  if (!receiverId) return;
+  emit("close");
+  const params = new URLSearchParams({
+    receiverId: String(receiverId),
+    uName: encodeURIComponent(item.senderName || "用户"),
+    userImage: item.avatar ? encodeURIComponent(item.avatar) : ""
+  });
+  router.push(`/storeDecorationManagement/decorationChat?${params.toString()}`);
+}
+
+// 根据类型显示内容(与商家端一致)
+function getVoiceDuration(item: { voiceDuration?: number; duration?: number; [key: string]: any }): string {
+  const sec = item?.voiceDuration ?? item?.duration ?? 0;
+  return sec ? `${sec}` : "0";
+}
+function processContent(item: { type?: string; content?: string; [key: string]: any }): string {
+  switch (String(item.type ?? "")) {
+    case "1":
+      return item.content ?? "";
+    case "2":
+      return "[图片]";
+    case "3":
+      return "[分享]";
+    case "4":
+      return "[交易请求]";
+    case "5":
+      return "[签到]";
+    case "6":
+      return "[签到确认]";
+    case "8":
+      return "[视频]";
+    case "9":
+      return "[修改交易请求]";
+    case "10":
+      return "[定位共享]";
+    case "11":
+      return "[委托]";
+    case "13":
+      return "[语音]" + getVoiceDuration(item) + '"';
+    default:
+      return item.content ?? "";
+  }
+}
+
 // 与原有通知页面一致的 context 解析
 function parseContext(context: string | undefined): string {
   if (!context) return "";
@@ -339,6 +495,15 @@ function parseContext(context: string | undefined): string {
   }
 }
 
+// 互动类内容:商家端 context 可能为 "类型|split|内容1|split|内容2",取第一段或解析 JSON
+function getInteractionContent(context: string | undefined): string {
+  if (!context) return "";
+  if (typeof context === "string" && context.includes("|split|")) {
+    return context.split("|split|")[0]?.trim() || context;
+  }
+  return parseContext(context);
+}
+
 // 统一请求:/alienStorePlatform/notice/getNoticeList,noticeType 系统=1、订单提醒=2、与我相关=0,分页查询
 async function fetchNoticeList(catKey: string) {
   const receiverId = getReceiverId();
@@ -360,13 +525,25 @@ async function fetchNoticeList(catKey: string) {
     const data = res?.data ?? res;
     const records = data?.records ?? data?.list ?? [];
     const total = data?.total ?? 0;
-    const list: NoticeItem[] = records.map((item: any) => ({
-      id: String(item.id),
-      title: item.title ?? "",
-      content: parseContext(item.context ?? item.content),
-      date: item.createdTime ?? "",
-      unread: !item.isRead
-    }));
+    const isRelated = catKey === "related";
+    const list: NoticeItem[] = records.map((item: any) => {
+      const rawContext = item.context ?? item.content ?? "";
+      const content = isRelated ? getInteractionContent(rawContext) : parseContext(rawContext);
+      const dateRaw = item.createdTime ?? "";
+      const dateStr = dateRaw.includes(" ") ? dateRaw.split(" ")[0].replace(/-/g, "/") : dateRaw.replace(/-/g, "/");
+      return {
+        id: String(item.id),
+        title: item.title ?? "",
+        content,
+        date: dateStr,
+        unread: !item.isRead,
+        type: item.type,
+        ...(isRelated && {
+          userName: item.userName ?? item.senderName ?? item.title ?? "",
+          userImage: item.userImage ?? item.storeImg ?? item.senderImg
+        })
+      };
+    });
     listByCategory.value[catKey] = list;
     paginationByCategory.value[catKey].total = total;
   } catch (e) {
@@ -448,14 +625,20 @@ function handleDelete(item: NoticeItem, index: number) {
   const cat = noticeCategories.value.find(c => c.key === key);
   if (cat && item.unread) cat.unread = Math.max(0, cat.unread - 1);
 }
-
-function handleMessageDelete(item: MessageItem, index: number) {
-  const key = messageCategory.value;
-  messageListByCategory.value[key] = currentMessageList.value.filter((_, i) => i !== index);
-  const cat = messageCategories.value.find(c => c.key === key);
-  if (cat && item.unread) cat.unread = Math.max(0, cat.unread - 1);
+/** 刷新全部(供 WebSocket 消息到达时实时更新,与商家端一致) */
+function refresh() {
+  if (activeTab.value === "notice") {
+    fetchNoticeUnreadCounts();
+    fetchNoticeList(activeCategory.value);
+  } else {
+    fetchUnfollowedUnreadCount();
+    fetchNoFriendMessage();
+    fetchMessageList();
+  }
 }
 
+defineExpose({ refresh });
+
 onMounted(() => {
   refreshAllNoticeCategories();
 });
@@ -576,12 +759,26 @@ watch(activeTab, val => {
   &:last-child {
     border-bottom: none;
   }
+  &.message-card-clickable {
+    cursor: pointer;
+  }
   .message-avatar {
+    position: relative;
     flex-shrink: 0;
+    .message-unread-dot {
+      position: absolute;
+      top: 0;
+      right: 0;
+      width: 8px;
+      height: 8px;
+      background: var(--el-color-danger);
+      border-radius: 50%;
+    }
   }
   .message-body {
     flex: 1;
     min-width: 0;
+    cursor: pointer;
   }
   .message-row {
     display: flex;
@@ -605,14 +802,51 @@ watch(activeTab, val => {
     }
   }
   .message-date {
+    flex-shrink: 0;
     font-size: 12px;
     color: var(--el-text-color-secondary);
   }
-  .message-content {
+  .message-content-row {
+    display: flex;
+    gap: 10px;
+    align-items: center;
+    justify-content: space-between;
     margin-bottom: 8px;
+  }
+  .message-content {
+    flex: 1;
+    min-width: 0;
+    overflow: hidden;
     font-size: 14px;
     line-height: 1.5;
     color: var(--el-text-color-regular);
+    text-overflow: ellipsis;
+    white-space: nowrap;
+  }
+  .message-status {
+    position: relative;
+    display: flex;
+    flex-shrink: 0;
+    align-items: center;
+  }
+  .message-count {
+    display: inline-flex;
+    align-items: center;
+    justify-content: center;
+    min-width: 20px;
+    height: 20px;
+    padding: 0 6px;
+    font-size: 12px;
+    font-weight: 500;
+    color: #ffffff;
+    background: #ff2442;
+    border-radius: 10px;
+  }
+  .message-dot {
+    width: 8px;
+    height: 8px;
+    background: var(--el-color-danger);
+    border-radius: 50%;
   }
   .message-actions {
     display: flex;

+ 143 - 0
src/stores/modules/websocket.ts

@@ -0,0 +1,143 @@
+import { defineStore } from "pinia";
+import { ref } from "vue";
+
+/**
+ * WebSocket Store(浏览器端)
+ * 与商家端 @/store/websocket 消息格式一致,用于分享动态、聊天等
+ */
+export const useWebSocketStore = defineStore("websocket", () => {
+  const socket = ref<WebSocket | null>(null);
+  const isConnected = ref(false);
+  const isConnecting = ref(false);
+  const lastConnectedUrl = ref("");
+
+  // 消息订阅(用于聊天等)
+  const messageHandlers = new Map<string, Array<(msg: any) => void>>();
+
+  const connect = (url: string): Promise<boolean> => {
+    if (isConnected.value && lastConnectedUrl.value === url) {
+      return Promise.resolve(true);
+    }
+    if (socket.value) {
+      try {
+        socket.value.close();
+      } catch (e) {
+        console.warn("关闭旧连接失败:", e);
+      }
+      socket.value = null;
+      isConnected.value = false;
+    }
+
+    isConnecting.value = true;
+    lastConnectedUrl.value = url;
+
+    return new Promise(resolve => {
+      try {
+        const ws = new WebSocket(url);
+        socket.value = ws;
+
+        ws.onopen = () => {
+          isConnected.value = true;
+          isConnecting.value = false;
+          resolve(true);
+        };
+
+        ws.onmessage = (event: MessageEvent) => {
+          try {
+            const message = JSON.parse(event.data);
+            const category = message.category || "message";
+            // 按 category 分发
+            const handlers = messageHandlers.get(category);
+            if (handlers?.length) {
+              handlers.forEach(cb => cb(message));
+            }
+            // 兼容:若后端推送的 category 不是 "message" 但明显是聊天消息,也派发给 message 订阅者(解决业主发消息不实时更新)
+            const isChatLike =
+              category !== "message" &&
+              (message.senderId != null || message.receiverId != null) &&
+              (message.text != null || message.content != null || message.type != null);
+            if (isChatLike) {
+              const messageHandlersList = messageHandlers.get("message");
+              if (messageHandlersList?.length) {
+                messageHandlersList.forEach(cb => cb(message));
+              }
+            }
+          } catch (_) {}
+        };
+
+        ws.onclose = () => {
+          isConnected.value = false;
+          isConnecting.value = false;
+          socket.value = null;
+        };
+
+        ws.onerror = () => {
+          isConnected.value = false;
+          isConnecting.value = false;
+          resolve(false);
+        };
+      } catch (e) {
+        console.error("WebSocket 连接异常:", e);
+        isConnecting.value = false;
+        resolve(false);
+      }
+    });
+  };
+
+  /** 订阅消息(返回取消订阅函数) */
+  const subscribe = (type: string, callback: (msg: any) => void) => {
+    if (!messageHandlers.has(type)) {
+      messageHandlers.set(type, []);
+    }
+    messageHandlers.get(type)!.push(callback);
+    return () => {
+      const handlers = messageHandlers.get(type) || [];
+      messageHandlers.set(
+        type,
+        handlers.filter(h => h !== callback)
+      );
+    };
+  };
+
+  const sendMessage = (data: Record<string, unknown>): Promise<boolean> => {
+    return new Promise(resolve => {
+      if (!isConnected.value || !socket.value || socket.value.readyState !== WebSocket.OPEN) {
+        console.warn("WebSocket 未连接,无法发送消息");
+        resolve(false);
+        return;
+      }
+      try {
+        const message = JSON.stringify(data);
+        socket.value.send(message);
+        resolve(true);
+      } catch (e) {
+        console.error("消息发送异常:", e);
+        resolve(false);
+      }
+    });
+  };
+
+  const disconnect = () => {
+    if (socket.value) {
+      try {
+        socket.value.close();
+      } catch (_) {}
+      socket.value = null;
+    }
+    isConnected.value = false;
+    isConnecting.value = false;
+    lastConnectedUrl.value = "";
+    messageHandlers.clear();
+  };
+
+  return {
+    socket,
+    isConnected,
+    isConnecting,
+    lastConnectedUrl,
+    connect,
+    sendMessage,
+    disconnect,
+    subscribe
+  };
+});

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

@@ -61,6 +61,7 @@ declare interface ViteEnv {
   VITE_API_ENCRYPTION_ENABLED: string;
   VITE_CRYPTO_KEY: string;
   VITE_CRYPTO_IV: string;
+  VITE_WS_BASE?: string;
 }
 
 interface ImportMetaEnv extends ViteEnv {

+ 2 - 4
src/views/businessData/overview.vue

@@ -228,13 +228,11 @@ function setService(d: ServiceQualityData | undefined) {
 
 const toDateString = (d: Date) => d.toISOString().slice(0, 10);
 
-/** 默认日期区间:本周一到当天 */
+/** 默认日期区间:共 7 天(当天 -6 天到当天,含当天) */
 function initDateRange() {
   const end = new Date();
   const start = new Date(end);
-  const day = start.getDay();
-  const daysToMonday = day === 0 ? 6 : day - 1;
-  start.setDate(start.getDate() - daysToMonday);
+  start.setDate(start.getDate() - 6);
   start.setHours(0, 0, 0, 0);
   dateRange.value = [toDateString(start), toDateString(end)];
 }

+ 152 - 0
src/views/contractManagement/detail.vue

@@ -0,0 +1,152 @@
+<template>
+  <div class="contract-detail-page">
+    <div class="page-header">
+      <el-button @click="goBack"> 返回 </el-button>
+      <h2 class="page-title">
+        {{ isSigned ? "合同" : "合同签署" }}
+      </h2>
+    </div>
+
+    <div class="content-wrapper">
+      <div v-if="loading" class="loading-container">
+        <el-loading :loading="loading" text="加载中..." />
+      </div>
+      <div v-else-if="contractUrl" class="contract-content">
+        <iframe :src="contractUrl" class="contract-iframe" frameborder="0" />
+      </div>
+      <div v-else class="error-container">
+        <el-empty description="合同内容加载失败" />
+      </div>
+    </div>
+  </div>
+</template>
+
+<script setup lang="ts" name="contractDetail">
+import { ref, onMounted } from "vue";
+import { useRoute, useRouter } from "vue-router";
+import { ElMessage } from "element-plus";
+import { getContractSignUrl } from "@/api/modules/contractManagement";
+
+const route = useRoute();
+const router = useRouter();
+
+// 合同URL
+const contractUrl = ref<string>("");
+// 加载状态
+const loading = ref<boolean>(true);
+// 是否已签署
+const isSigned = ref<boolean>(false);
+
+// 加载合同详情
+const loadContractDetail = async () => {
+  try {
+    loading.value = true;
+    const signFlowId = route.query.signFlowId as string;
+    const contactPhone = route.query.contactPhone as string;
+    const signed = route.query.signed as string;
+
+    // 判断是否已签署
+    isSigned.value = signed === "true" || signed === "1";
+
+    if (!signFlowId) {
+      ElMessage.error("缺少合同ID参数");
+      return;
+    }
+
+    // 调用获取合同签署链接的接口
+    const res: any = await getContractSignUrl({
+      sign_flow_id: signFlowId,
+      contact_phone: contactPhone || ""
+    });
+
+    if (res) {
+      // 如果返回的是URL字符串,直接使用
+      if (typeof res.data === "string") {
+        contractUrl.value = res.data;
+      } else if (res.data.url) {
+        // 如果返回的是对象,取url字段
+        contractUrl.value = res.data.url;
+      } else {
+        ElMessage.error("合同链接格式错误");
+      }
+    } else {
+      ElMessage.error(res?.msg || "获取合同详情失败");
+    }
+  } catch (error: any) {
+    console.error("加载合同详情失败:", error);
+    ElMessage.error(error?.message || "加载合同详情失败,请重试");
+  } finally {
+    loading.value = false;
+  }
+};
+
+// 返回
+const goBack = () => {
+  router.go(-1);
+};
+
+// 初始化
+onMounted(() => {
+  loadContractDetail();
+});
+</script>
+
+<style lang="scss" scoped>
+.contract-detail-page {
+  display: flex;
+  flex-direction: column;
+  width: 100%;
+  min-height: 100%;
+  background-color: #ffffff;
+  .page-header {
+    display: flex;
+    align-items: center;
+    justify-content: center;
+    padding: 20px 24px;
+    background-color: #ffffff;
+    border-bottom: 1px solid #e4e7ed;
+    box-shadow: 0 2px 4px rgb(0 0 0 / 2%);
+    .page-title {
+      flex: 1;
+      margin: 0;
+      font-size: 18px;
+      font-weight: 600;
+      color: #303133;
+      text-align: center;
+    }
+  }
+  .content-wrapper {
+    position: relative;
+    flex: 1;
+    padding: 20px;
+    background: #f5f7fa;
+    .loading-container {
+      display: flex;
+      align-items: center;
+      justify-content: center;
+      width: 100%;
+      height: 600px;
+    }
+    .contract-content {
+      width: 100%;
+      height: calc(100vh - 120px);
+      overflow: hidden;
+      background: #ffffff;
+      border-radius: 8px;
+      box-shadow: 0 2px 8px rgb(0 0 0 / 10%);
+      .contract-iframe {
+        width: 100%;
+        height: 100%;
+        border: none;
+      }
+    }
+    .error-container {
+      display: flex;
+      align-items: center;
+      justify-content: center;
+      width: 100%;
+      height: 600px;
+    }
+  }
+}
+</style>

+ 306 - 0
src/views/contractManagement/index.vue

@@ -0,0 +1,306 @@
+<template>
+  <div class="contract-management-page">
+    <!-- Tab切换 -->
+    <div class="tab-container">
+      <div class="tab-item" :class="{ active: activeTab === 'unsigned' }" @click="switchTab('unsigned')">
+        <span>未签署</span>
+      </div>
+      <div class="tab-item" :class="{ active: activeTab === 'signed' }" @click="switchTab('signed')">
+        <span>已签署</span>
+      </div>
+    </div>
+
+    <!-- 表格 -->
+    <div class="table-container">
+      <ProTable
+        ref="proTable"
+        :columns="columns"
+        :request-api="getTableList"
+        :init-param="initParam"
+        :data-callback="dataCallback"
+        :refresh-reset-page="true"
+      >
+        <template #contractName="scope">
+          <div class="contract-row">
+            <div class="contract-indicator" />
+            <span>{{ scope.row.file_name || "—" }}</span>
+          </div>
+        </template>
+        <template #operation="scope">
+          <template v-if="activeTab === 'unsigned'">
+            <el-button type="primary" link @click="handleViewDetail(scope.row, false)"> 签署 </el-button>
+          </template>
+          <template v-else>
+            <el-button type="primary" link @click="handleViewDetail(scope.row, true)"> 查看 </el-button>
+            <el-button type="primary" link @click="handleDownload(scope.row)"> 下载 </el-button>
+          </template>
+        </template>
+      </ProTable>
+    </div>
+  </div>
+</template>
+
+<script setup lang="tsx" name="contractManagement">
+import { reactive, ref, computed } from "vue";
+import { useRouter } from "vue-router";
+import { ElMessage } from "element-plus";
+import ProTable from "@/components/ProTable/index.vue";
+import { ColumnProps, ProTableInstance } from "@/components/ProTable/interface";
+import { getContractList, getContractSignUrl } from "@/api/modules/contractManagement";
+import { localGet } from "@/utils";
+
+const router = useRouter();
+const proTable = ref<ProTableInstance>();
+
+// 当前选中的tab
+const activeTab = ref<"unsigned" | "signed">("unsigned");
+
+// 初始化请求参数
+const initParam = reactive({
+  storeId: localGet("createdId") || ""
+});
+
+// 表格列配置
+const columns = reactive<ColumnProps<any>[]>([
+  {
+    prop: "file_name",
+    label: "合同名称",
+    minWidth: 200,
+    search: {
+      el: "input",
+      props: { placeholder: "请输入" }
+    }
+  },
+  {
+    prop: "store_name",
+    label: "对方单位",
+    minWidth: 250
+  },
+  {
+    prop: "effective_time",
+    label: "有效期",
+    width: 300,
+    render: (scope: any) => {
+      if (scope.row.effective_time && scope.row.expiry_time) {
+        const start = formatDate(scope.row.effective_time);
+        const end = formatDate(scope.row.expiry_time);
+        return `${start}-${end}`;
+      }
+      return "—";
+    }
+  },
+  { prop: "operation", label: "操作", fixed: "right", width: 200 }
+]);
+
+// 格式化日期
+const formatDate = (dateStr: string) => {
+  if (!dateStr) return "";
+  const date = new Date(dateStr);
+  const year = date.getFullYear();
+  const month = String(date.getMonth() + 1).padStart(2, "0");
+  const day = String(date.getDate()).padStart(2, "0");
+  return `${year}/${month}/${day}`;
+};
+
+// 数据回调处理
+const dataCallback = (data: any) => {
+  // 根据实际接口返回格式处理
+  if (data) {
+    return {
+      list: data.items || [],
+      total: data.total || 0
+    };
+  }
+
+  return {
+    list: [],
+    total: 0
+  };
+};
+
+// 获取表格列表
+const getTableList = async (params: any) => {
+  const storeId = params.storeId || localGet("createdId");
+  // 根据选中的tab设置状态参数
+  const status = activeTab.value === "signed" ? 1 : 0; // 1-已签署, 0-未签署
+
+  // 构建请求参数
+  const requestParams: any = {
+    page: params.pageNum || params.page || 1,
+    page_size: params.pageSize || params.size || 10,
+    status: status
+  };
+
+  // 添加搜索条件(从 ProTable 的 params 中获取)
+  if (params.file_name) {
+    requestParams.file_name = params.file_name;
+  }
+
+  const res = await getContractList(storeId, requestParams);
+  // 包装返回格式以匹配 useTable 的期望格式 { data: ... }
+  return { data: res };
+};
+
+// 切换tab
+const switchTab = (tab: "unsigned" | "signed") => {
+  if (activeTab.value === tab) return;
+  activeTab.value = tab;
+  // 刷新表格
+  proTable.value?.reset();
+};
+
+// 查看合同详情(统一入口,未签署和已签署都跳转到详情页)
+const handleViewDetail = (row: any, isSigned: boolean) => {
+  const signFlowId = row.sign_flow_id || row.id;
+  const contactPhone = row.contact_phone || "";
+
+  if (!signFlowId) {
+    ElMessage.error("缺少合同ID");
+    return;
+  }
+
+  // 跳转到合同详情页
+  router.push({
+    path: "/contractManagement/detail",
+    query: {
+      signFlowId: signFlowId,
+      contactPhone: contactPhone,
+      signed: isSigned ? "true" : "false"
+    }
+  });
+};
+
+// 下载合同
+const handleDownload = async (row: any) => {
+  try {
+    // 如果合同有文件URL,直接下载
+    if (row.file_url || row.download_url || row.url) {
+      const downloadUrl = row.file_url || row.download_url || row.url;
+      // 创建临时链接下载
+      const link = document.createElement("a");
+      link.href = downloadUrl;
+      link.download = row.file_name || "合同文件";
+      link.target = "_blank";
+      document.body.appendChild(link);
+      link.click();
+      document.body.removeChild(link);
+      ElMessage.success("下载开始");
+      return;
+    }
+
+    // 如果没有直接的文件URL,尝试通过接口获取下载链接
+    const res: any = await getContractSignUrl({
+      sign_flow_id: row.sign_flow_id || row.id,
+      contact_phone: row.contact_phone || ""
+    });
+
+    if (res && res.code === 200 && res.data) {
+      // 如果返回的是文件URL,直接下载
+      const link = document.createElement("a");
+      link.href = res.data;
+      link.download = row.file_name || "合同文件";
+      link.target = "_blank";
+      document.body.appendChild(link);
+      link.click();
+      document.body.removeChild(link);
+      ElMessage.success("下载开始");
+    } else {
+      ElMessage.error(res?.msg || "获取下载链接失败");
+    }
+  } catch (error: any) {
+    console.error("下载合同失败:", error);
+    ElMessage.error(error?.message || "下载合同失败,请重试");
+  }
+};
+</script>
+
+<style lang="scss" scoped>
+.contract-management-page {
+  min-height: 100%;
+  padding: 20px;
+  background: #ffffff;
+  .tab-container {
+    display: flex;
+    gap: 0;
+    margin-bottom: 20px;
+    border-bottom: 1px solid #e4e7ed;
+    .tab-item {
+      position: relative;
+      padding: 12px 24px;
+      font-size: 14px;
+      color: #666666;
+      cursor: pointer;
+      transition: all 0.3s;
+      &:hover {
+        color: #409eff;
+      }
+      &.active {
+        font-weight: bold;
+        color: #409eff;
+        &::after {
+          position: absolute;
+          right: 0;
+          bottom: -1px;
+          left: 0;
+          height: 2px;
+          content: "";
+          background: #409eff;
+        }
+      }
+    }
+  }
+  .table-container {
+    min-height: 400px;
+    :deep(.el-table) {
+      min-height: 300px;
+
+      // 确保表格体有最小高度
+      .el-table__body-wrapper {
+        min-height: 300px;
+      }
+
+      // 确保空状态时表格有高度
+      .el-table__empty-block {
+        position: relative !important;
+        display: flex !important;
+        align-items: center;
+        justify-content: center;
+        min-height: 300px;
+
+        // 确保空状态内容正常显示
+        .table-empty {
+          display: flex;
+          flex-direction: column;
+          gap: 12px;
+          align-items: center;
+          justify-content: center;
+          line-height: normal;
+          img {
+            display: inline-flex !important;
+            width: 180px;
+            height: 180px;
+          }
+        }
+      }
+      .contract-row {
+        position: relative;
+        display: flex;
+        align-items: center;
+        padding-left: 8px;
+        .contract-indicator {
+          position: absolute;
+          top: 0;
+          bottom: 0;
+          left: 0;
+          width: 4px;
+          background: linear-gradient(180deg, #ff6b35 0%, #ff8c5a 100%);
+          border-radius: 2px;
+        }
+        span {
+          margin-left: 12px;
+        }
+      }
+    }
+  }
+}
+</style>

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

@@ -11,9 +11,9 @@
       <!-- 表格 header 按钮 -->
       <template #tableHeader="scope">
         <div class="table-header-content">
-          <div class="header-button">
+          <!-- <div class="header-button">
             <el-button type="primary" @click="openGiftDialog"> 赠送好友优惠券 </el-button>
-          </div>
+          </div> -->
           <el-tabs v-model="activeName" class="header-tabs" @tab-click="handleTabClick">
             <el-tab-pane label="好友赠我" name="friendMessage" />
             <el-tab-pane label="我赠好友" name="myGift" />

+ 52 - 6
src/views/dynamicManagement/index.vue

@@ -533,9 +533,14 @@ import {
 
 // import { uploadImg } from "@/api/modules/upload";
 import { useUserStore } from "@/stores/modules/user";
+import { useWebSocketStore } from "@/stores/modules/websocket";
 
 const router = useRouter();
 const userStore = useUserStore();
+const socketStore = useWebSocketStore();
+
+// WebSocket 基础地址(与商家端一致,分享前连接)
+const WS_BASE = import.meta.env.VITE_WS_BASE || "ws://192.168.10.80:8000/alienStore/socket/";
 
 // 举报原因到违规类型的映射
 const violationTypeMap: Record<string, number> = {
@@ -1142,8 +1147,24 @@ const handleCancelReply = () => {
   commentInput.value = "";
 };
 
-// 分享
+// 分享(参考商家端 newDetail:分享前先连接 WebSocket)
 const handleShare = async () => {
+  const phone = userStore.userInfo?.phone || "";
+  const senderId = phone.startsWith("store_") ? phone : `store_${phone}`;
+  const wsUrl = `${WS_BASE.replace(/\/$/, "")}/${senderId}`;
+  try {
+    if (!socketStore.isConnected || socketStore.lastConnectedUrl !== wsUrl) {
+      const connected = await socketStore.connect(wsUrl);
+      if (!connected) {
+        ElMessage.warning("连接失败,请稍后重试");
+        return;
+      }
+    }
+  } catch (e) {
+    console.error("WebSocket 连接失败:", e);
+    ElMessage.warning("连接失败,请稍后重试");
+    return;
+  }
   shareDialogVisible.value = true;
   await loadShareFriendList();
 };
@@ -1204,7 +1225,7 @@ const handleRemoveFriend = (friendId: number) => {
   }
 };
 
-// 确认分享
+// 确认分享(参考商家端 newDetail:通过 WebSocket sendMessage 发送给每位好友,再调 addTransferCount)
 const handleConfirmShare = async () => {
   if (selectedFriends.value.length === 0) {
     ElMessage.warning("请选择要分享的好友");
@@ -1216,10 +1237,34 @@ const handleConfirmShare = async () => {
     return;
   }
 
+  const phone = userStore.userInfo?.phone || "";
+  const senderId = phone.startsWith("store_") ? phone : `store_${phone}`;
+  const detailPayload = {
+    ...currentDetail.value,
+    imageList: currentDetail.value.imagePath.split(","),
+    cover: currentDetail.value.imagePath.split(",")[0]
+  };
   try {
     shareSubmitting.value = true;
 
-    // 调用 addTransferCount 接口,传递动态 id
+    // 先通过 WebSocket 给每位选中好友发送动态分享消息(与商家端 sendMessage 格式一致)
+    for (const friendId of selectedFriends.value) {
+      const friend = shareFriendList.value.find(f => f.id === friendId);
+      const receiverId = friend?.phoneId || friend?.id;
+      if (!receiverId) continue;
+      await socketStore.sendMessage({
+        category: "message",
+        receiverId,
+        senderId,
+        type: 3,
+        text: {
+          sendType: "dynamicShare",
+          url: JSON.stringify(detailPayload)
+        }
+      });
+    }
+
+    // 再调用 addTransferCount 接口,传递动态 id(与商家端一致)
     const res: any = await addTransferCount({
       id: currentDetail.value.id
     });
@@ -1227,9 +1272,10 @@ const handleConfirmShare = async () => {
     if (res.code === 200) {
       ElMessage.success(`已分享给 ${selectedFriends.value.length} 位好友`);
       shareDialogVisible.value = false;
-
-      // 可以在这里更新动态的分享数(如果需要的话)
-      console.log("分享成功,动态ID:", currentDetail.value.id);
+      // 更新当前详情的分享数
+      if (typeof (currentDetail.value as any).transferCount === "number") {
+        (currentDetail.value as any).transferCount += selectedFriends.value.length;
+      }
     } else {
       ElMessage.error(res.message || "分享失败");
     }

+ 4 - 2
src/views/dynamicManagement/reviewAppeal.vue

@@ -51,7 +51,7 @@
         </div>
         <el-tabs v-model="activeTab" @tab-click="handleTabClick">
           <el-tab-pane :label="`全部 (${tabCounts.all})`" name="0" />
-          <el-tab-pane :label="`待回复差评 (${tabCounts.pending})`" name="pending" />
+          <el-tab-pane :label="`待回复差评 (${tabCounts.noReplyCount})`" name="pending" />
           <el-tab-pane :label="`差评 (${tabCounts.bad})`" name="3" />
           <el-tab-pane :label="`好评 (${tabCounts.good})`" name="1" />
           <el-tab-pane :label="`中评 (${tabCounts.neutral})`" name="2" />
@@ -252,7 +252,8 @@ const tabCounts = reactive({
   pending: 0,
   bad: 0,
   good: 0,
-  neutral: 0
+  neutral: 0,
+  noReplyCount: 0
 });
 
 // 当前激活的标签
@@ -359,6 +360,7 @@ const loadStatistics = async () => {
     tabCounts.neutral = rc?.midCount ?? 0;
     tabCounts.bad = rc?.badCount ?? 0;
     tabCounts.pending = rc?.pending ?? 0;
+    tabCounts.noReplyCount = rc?.noReplyCount ?? 0;
 
     statistics.totalReviews = tabCounts.all;
     statistics.badTextReviews = tabCounts.bad;

+ 364 - 2
src/views/dynamicManagement/userDynamic.vue

@@ -37,6 +37,7 @@
             </el-button>
             <template #dropdown>
               <el-dropdown-menu>
+                <el-dropdown-item v-if="isFriend && isStoreUser" command="gift"> 赠券 </el-dropdown-item>
                 <el-dropdown-item command="report"> 举报 </el-dropdown-item>
                 <el-dropdown-item command="block"> 拉黑 </el-dropdown-item>
               </el-dropdown-menu>
@@ -501,6 +502,70 @@
         </div>
       </template>
     </el-dialog>
+
+    <!-- 赠送好友优惠券弹窗(按设计:赠送类型 + 多行优惠券 + 添加商家优惠券) -->
+    <el-dialog
+      v-model="giftCouponDialogVisible"
+      title="赠送好友优惠券"
+      width="600px"
+      destroy-on-close
+      @close="closeGiftCouponDialog"
+    >
+      <div class="gift-coupon-dialog-body">
+        <div class="gift-type-section">
+          <div class="section-label">请选择赠送类型</div>
+          <el-radio-group v-model="giftCouponFormData.giftType" @change="loadGiftCouponList(giftCouponFormData.giftType)">
+            <el-radio label="coupon"> 优惠券 </el-radio>
+            <el-radio label="voucher"> 代金券 </el-radio>
+          </el-radio-group>
+        </div>
+        <div class="coupon-rows-section">
+          <div class="section-label">选择优惠券</div>
+          <div v-for="(row, index) in giftCouponFormData.rows" :key="row.key" class="coupon-row">
+            <el-select
+              v-model="row.couponId"
+              placeholder="选择优惠券"
+              style="flex: 1; min-width: 0"
+              clearable
+              :loading="giftCouponListLoading"
+              @visible-change="(visible: boolean) => visible && loadGiftCouponList(giftCouponFormData.giftType)"
+              @change="onGiftRowCouponChange(row)"
+            >
+              <el-option
+                v-for="coupon in giftCouponList"
+                :key="coupon.id"
+                :label="coupon.name"
+                :value="coupon.id"
+                :disabled="isGiftCouponSelectedInOtherRow(coupon.id, index)"
+              />
+            </el-select>
+            <div class="quantity-cell">
+              <el-input
+                type="number"
+                :model-value="row.quantity === 0 ? '' : row.quantity"
+                placeholder="请输入赠券数量"
+                class="quantity-input"
+                :min="1"
+                :max="getGiftRowMaxQuantity(row)"
+                @input="(val: string | number) => setGiftRowQuantity(row, val)"
+              />
+              <span v-if="row.couponId" class="quantity-limit-hint"> 最多可赠 {{ getGiftRowMaxQuantity(row) }} 张 </span>
+            </div>
+            <el-button type="danger" link :icon="Delete" circle title="删除" @click="removeGiftCouponRow(index)" />
+          </div>
+          <el-button type="primary" link class="add-coupon-btn" @click="addGiftCouponRow">
+            <el-icon><Plus /></el-icon>
+            添加商家优惠券
+          </el-button>
+        </div>
+      </div>
+      <template #footer>
+        <div class="dialog-footer">
+          <el-button @click="closeGiftCouponDialog"> 取消 </el-button>
+          <el-button type="primary" :disabled="!canSubmitGiftCoupon" @click="handleGiftCouponSubmit"> 确定 </el-button>
+        </div>
+      </template>
+    </el-dialog>
   </div>
 </template>
 
@@ -516,6 +581,7 @@ import {
   Share,
   MoreFilled,
   Plus,
+  Delete,
   Warning,
   CircleClose,
   ChatDotRound,
@@ -528,10 +594,20 @@ import {
   likeDynamicNew,
   unlikeDynamicNew
 } from "@/api/modules/dynamicManagement";
-import { getUserDynamicsList, cancelFollewed, toggleFollowUser } from "@/api/modules/newLoginApi";
+import {
+  getUserDynamicsList,
+  cancelFollewed,
+  toggleFollowUser,
+  getMutualAttention,
+  addTransferCount,
+  getCouponList,
+  setFriendCoupon,
+  saveComment,
+  commentList
+} from "@/api/modules/newLoginApi";
 import { uploadImg } from "@/api/modules/upload";
 import { useUserStore } from "@/stores/modules/user";
-import { saveComment, commentList, getMutualAttention, addTransferCount } from "@/api/modules/newLoginApi";
+import { localGet } from "@/utils";
 
 const route = useRoute();
 const router = useRouter();
@@ -575,6 +651,7 @@ interface ShareFriend {
 const activeTab = ref("dynamic");
 const contentList = ref<ContentItem[]>([]);
 const isFollowed = ref(false); // 是否已关注
+const isFriend = ref(false); // 是否互为好友(互相关注)
 
 // 详情 Drawer 相关
 const detailDrawerVisible = ref(false);
@@ -644,6 +721,9 @@ const userInfo = reactive({
   likeCount: 0 // 获赞数
 });
 
+// 当前主页用户是否为商家(phoneId 以 store_ 开头)
+const isStoreUser = computed(() => !!targetPhoneId.value && targetPhoneId.value.startsWith("store_"));
+
 // 判断是否是当前用户自己的主页
 const isMyPage = computed(() => {
   const currentUserStoreId = userStore.userInfo?.storeId;
@@ -1084,6 +1164,9 @@ const handleFollowInDetail = async () => {
 // 更多操作菜单
 const handleCommand = (command: string) => {
   switch (command) {
+    case "gift":
+      handleGiftCoupon();
+      break;
     case "report":
       handleReportUser();
       break;
@@ -1093,6 +1176,221 @@ const handleCommand = (command: string) => {
   }
 };
 
+// 赠券弹窗相关(按设计:赠送类型 + 多行优惠券)
+const giftCouponDialogVisible = ref(false);
+const giftFriendList = ref<{ id: number | string; name: string; phoneId?: string }[]>([]);
+const giftFriendListLoading = ref(false);
+const giftCouponList = ref<{ id: number | string; name: string; singleQty?: number }[]>([]);
+const giftCouponListLoaded = ref(false);
+const giftCouponListLoading = ref(false);
+let giftCouponRowKey = 0;
+const giftCouponFormData = reactive<{
+  friendId: string | number;
+  giftType: "coupon" | "voucher";
+  rows: { key: number; couponId: string | number; quantity: number }[];
+}>({
+  friendId: "",
+  giftType: "coupon",
+  rows: [{ key: ++giftCouponRowKey, couponId: "", quantity: 1 }]
+});
+
+const loadGiftFriendList = async () => {
+  giftFriendListLoading.value = true;
+  try {
+    const phone = userStore.userInfo?.phone || "";
+    const fansId = phone.startsWith("store_") ? phone : `store_${phone}`;
+    const res: any = await getMutualAttention({ page: 1, size: 999, fansId, name: "" });
+    if (res?.code === 200) {
+      const records = res.data?.records || res.data?.list || res.data || [];
+      giftFriendList.value = records.map((item: any) => ({
+        id: item.id ?? item.userId,
+        name: item.username ?? item.userName ?? item.nickname ?? "用户",
+        phoneId: item.phoneId ?? item.fansId
+      }));
+      const target = giftFriendList.value.find(
+        (f: any) => String(f.phoneId) === targetPhoneId.value || String(f.id) === targetUserId.value
+      );
+      if (target) giftCouponFormData.friendId = target.id;
+    } else {
+      giftFriendList.value = [];
+    }
+  } catch {
+    giftFriendList.value = [];
+  } finally {
+    giftFriendListLoading.value = false;
+  }
+};
+
+const loadGiftCouponList = async (giftType: "coupon" | "voucher" = giftCouponFormData.giftType) => {
+  giftCouponListLoading.value = true;
+  try {
+    const storeId = localGet("createdId") || userStore.userInfo?.storeId || userStore.userInfo?.createdId;
+    let params: any = {};
+    if (giftType === "coupon") {
+      params = { storeId, status: 0 };
+    } else if (giftType === "voucher") {
+      params = { storeId, status: 0, type: 4 };
+    }
+
+    const api = getCouponList;
+    const res: any = await api(params);
+    if (res?.code === 200) {
+      const list = res.data?.records ?? res.data?.list ?? res.data ?? [];
+      const rawList = Array.isArray(list) ? list : [];
+      if (giftType === "coupon") {
+        giftCouponList.value = rawList.map((item: any) => ({
+          id: item.id ?? item.couponId,
+          name: item.name ?? item.couponName ?? "",
+          singleQty: item.singleQty != null ? Number(item.singleQty) : 100
+        }));
+      } else if (giftType === "voucher") {
+        giftCouponList.value = rawList.map((item: any) => ({
+          id: item.voucherId,
+          name: item.name ?? "",
+          singleQty: item.singleQty != null ? Number(item.singleQty) : 100
+        }));
+      }
+    } else {
+      giftCouponList.value = [];
+      giftCouponListLoaded.value = true;
+    }
+  } catch {
+    giftCouponList.value = [];
+  } finally {
+    giftCouponListLoading.value = false;
+  }
+};
+
+const addGiftCouponRow = () => {
+  giftCouponFormData.rows.push({
+    key: ++giftCouponRowKey,
+    couponId: "",
+    quantity: 1
+  });
+};
+
+const removeGiftCouponRow = (index: number) => {
+  giftCouponFormData.rows.splice(index, 1);
+};
+
+// 判断该券是否已被其他行选中(当前行可选,其他行已选则禁用)
+const isGiftCouponSelectedInOtherRow = (couponId: string | number, currentRowIndex: number) => {
+  return giftCouponFormData.rows.some(
+    (row, i) => i !== currentRowIndex && row.couponId !== "" && row.couponId != null && String(row.couponId) === String(couponId)
+  );
+};
+
+// 根据所选券的 singleQty 限制该行数量上限
+const getGiftRowMaxQuantity = (row: { couponId: string | number; quantity: number }) => {
+  if (row.couponId === "" || row.couponId == null) return 100;
+  const coupon = giftCouponList.value.find((c: any) => String(c.id) === String(row.couponId));
+  const max = coupon?.singleQty != null ? Number(coupon.singleQty) : 100;
+  return Math.max(1, max);
+};
+
+// 切换所选券时,若当前数量超过新券 singleQty 则自动压到上限
+const onGiftRowCouponChange = (row: { couponId: string | number; quantity: number }) => {
+  const max = getGiftRowMaxQuantity(row);
+  if (row.quantity > max) row.quantity = max;
+};
+
+// 输入时即时限制:不允许超出最大数量;允许先清空再输入其他数字(空时暂存为 0,展示为空)
+const setGiftRowQuantity = (row: { couponId: string | number; quantity: number }, val: string | number | undefined) => {
+  const max = getGiftRowMaxQuantity(row);
+  if (val === "" || val == null || Number.isNaN(Number(val))) {
+    row.quantity = 0; // 允许清空,便于用户删除后输入新数量
+    return;
+  }
+  const num = Number(val);
+  row.quantity = Math.min(Math.max(1, num), max);
+};
+
+// 严格校验:赠送对象存在 + 至少一行已选券且数量有效 + 每一行要么未选券(可忽略)要么已选券且数量有效,不允许存在「未选券但占着一行」的不完整行
+const canSubmitGiftCoupon = computed(() => {
+  if (!giftCouponFormData.friendId) return false;
+  const validRows = giftCouponFormData.rows.filter(r => r.couponId !== "" && r.couponId != null && r.quantity >= 1);
+  if (validRows.length < 1) return false;
+  // 存在未选券的行则不允许提交(避免一行有券、一行没选券仍能点确定)
+  const hasIncompleteRow = giftCouponFormData.rows.some(r => (r.couponId === "" || r.couponId == null) && r.quantity >= 1);
+  return !hasIncompleteRow;
+});
+
+const closeGiftCouponDialog = () => {
+  giftCouponDialogVisible.value = false;
+  giftCouponFormData.friendId = "";
+  giftCouponFormData.giftType = "coupon";
+  giftCouponFormData.rows = [{ key: ++giftCouponRowKey, couponId: "", quantity: 1 }];
+  giftCouponListLoaded.value = false;
+};
+
+const handleGiftCouponSubmit = async () => {
+  if (!giftCouponFormData.friendId) {
+    ElMessage.warning("数据填写不完整,无法确定赠送对象");
+    return;
+  }
+  const validRows = giftCouponFormData.rows.filter(r => r.couponId !== "" && r.couponId != null && r.quantity >= 1);
+  if (validRows.length === 0) {
+    ElMessage.warning("数据填写不完整,请至少选择一张优惠券并填写数量");
+    return;
+  }
+  const hasIncompleteRow = giftCouponFormData.rows.some(r => (r.couponId === "" || r.couponId == null) && r.quantity >= 1);
+  if (hasIncompleteRow) {
+    ElMessage.warning("数据填写不完整,请为每一行选择优惠券或删除未选券的行");
+    return;
+  }
+  if (!canSubmitGiftCoupon.value) {
+    ElMessage.warning("数据填写不完整,请至少选择一张优惠券并填写数量");
+    return;
+  }
+  try {
+    let params: any = {};
+    if (giftCouponFormData.giftType === "coupon") {
+      params = {
+        couponIds: validRows.map(r => ({
+          couponId: r.couponId,
+          singleQty: r.quantity
+        })),
+        friendStoreUserId: String(giftCouponFormData.friendId)
+      };
+    } else if (giftCouponFormData.giftType === "voucher") {
+      params = {
+        couponIds: validRows.map(r => ({
+          voucherId: r.couponId,
+          singleQty: r.quantity
+        })),
+        friendStoreUserId: String(giftCouponFormData.friendId)
+      };
+    }
+    const res: any = await setFriendCoupon(params);
+    if (res?.code === 200) {
+      ElMessage.success("赠送成功");
+      closeGiftCouponDialog();
+    } else {
+      ElMessage.error(res?.msg || "赠送失败");
+    }
+  } catch (error: any) {
+    console.error("赠送失败:", error);
+    ElMessage.error(error?.message || "赠送失败");
+  }
+};
+
+// 赠券:打开赠送好友优惠券弹窗(不跳转)
+const handleGiftCoupon = () => {
+  giftCouponDialogVisible.value = true;
+  loadGiftFriendList();
+  loadGiftCouponList();
+};
+
+// 切换优惠券/代金券时:清空下拉列表、只保留一行选择,下次聚焦下拉时按类型重新加载
+watch(
+  () => giftCouponFormData.giftType,
+  () => {
+    giftCouponList.value = [];
+    giftCouponListLoaded.value = false;
+    giftCouponFormData.rows = [{ key: ++giftCouponRowKey, couponId: "", quantity: 1 }];
+  }
+);
+
 // 举报用户
 const handleReportUser = () => {
   reportDialogVisible.value = true;
@@ -1358,9 +1656,33 @@ const loadContentList = async () => {
   }
 };
 
+// 加载是否与当前主页用户互为好友(用于展示赠券入口)
+const loadIsFriend = async () => {
+  if (!targetPhoneId.value) return;
+  try {
+    const phone = userStore.userInfo?.phone || "";
+    const fansId = phone.startsWith("store_") ? phone : `store_${phone}`;
+    const res: any = await getMutualAttention({
+      page: 1,
+      size: 1000,
+      fansId,
+      name: ""
+    });
+    if (res?.code === 200) {
+      const dataList = res.data?.records || res.data?.list || res.data || [];
+      isFriend.value = dataList.some(
+        (item: any) => (item.phoneId || item.fansId || item.storeUserId || "") === targetPhoneId.value
+      );
+    }
+  } catch {
+    isFriend.value = false;
+  }
+};
+
 // 初始化
 onMounted(() => {
   loadContentList();
+  loadIsFriend();
 });
 </script>
 
@@ -2168,6 +2490,46 @@ onMounted(() => {
     }
   }
 }
+.gift-coupon-dialog-body {
+  .gift-type-section,
+  .coupon-rows-section {
+    margin-bottom: 20px;
+  }
+  .section-label {
+    margin-bottom: 12px;
+    font-size: 14px;
+    font-weight: 500;
+    color: var(--el-text-color-primary);
+  }
+  .coupon-row {
+    display: flex;
+    gap: 12px;
+    align-items: center;
+    align-items: start;
+    margin-bottom: 12px;
+    .quantity-cell {
+      display: flex;
+      flex-shrink: 0;
+      flex-direction: column;
+      gap: 4px;
+    }
+    .quantity-input {
+      width: 140px;
+    }
+    .quantity-limit-hint {
+      font-size: 12px;
+      line-height: 1.2;
+      color: var(--el-color-warning);
+    }
+  }
+  .add-coupon-btn {
+    display: inline-flex;
+    gap: 4px;
+    align-items: center;
+    padding-left: 0;
+    color: var(--el-color-primary);
+  }
+}
 </style>
 
 <style scoped>

+ 1 - 1
src/views/operationManagement/activityDetail.vue

@@ -99,7 +99,7 @@
           <div class="detail-item">
             <div class="detail-label">审核状态</div>
             <div class="detail-value">
-              {{ getAuditStatusLabel(activityModel.status) }}
+              {{ activityModel.auditStatus == 1 ? "审核通过" : "审核驳回" }}
             </div>
           </div>
           <!-- 审核时间 -->

+ 2 - 2
src/views/operationManagement/caseDetail.vue

@@ -11,13 +11,13 @@
             <div class="detail-label">所属活动 : {{ detail.activityName || detail.activityTitle || "-" }}</div>
           </div>
           <div class="detail-item">
-            <div class="detail-label">用户昵称 : {{ detail.nickName || detail.nickname || "-" }}</div>
+            <div class="detail-label">用户昵称 : {{ detail.signupName || "-" }}</div>
           </div>
           <div class="detail-item">
             <div class="detail-label">姓名 : {{ detail.userName || "-" }}</div>
           </div>
           <div class="detail-item">
-            <div class="detail-label">联系方式 : {{ detail.phone || "-" }}</div>
+            <div class="detail-label">联系方式 : {{ detail.signupPhone || "-" }}</div>
           </div>
           <div class="detail-item">
             <div class="detail-label">报名时间 : {{ formatTime(detail.createdTime) }}</div>

+ 2 - 21
src/views/operationManagement/cases.vue

@@ -27,15 +27,9 @@ import { localGet } from "@/utils";
 const router = useRouter();
 const proTable = ref<ProTableInstance>();
 
-// 上传情况枚举
-const uploadStatusEnum = [
-  { label: "未上传", value: 0 },
-  { label: "已上传", value: 1 }
-];
-
 const columns = reactive<ColumnProps<any>[]>([
-  { prop: "nickName", label: "姓名", minWidth: 300 },
-  { prop: "phone", label: "联系方式", width: 300 },
+  { prop: "signupName", label: "姓名", minWidth: 300 },
+  { prop: "signupPhone", label: "联系方式", width: 300 },
   {
     prop: "activityName",
     label: "所属活动",
@@ -46,19 +40,6 @@ const columns = reactive<ColumnProps<any>[]>([
       order: 2
     }
   },
-  {
-    prop: "hasResult",
-    label: "上传情况",
-    width: 300,
-    enum: uploadStatusEnum,
-    fieldNames: { label: "label", value: "value" },
-    search: { el: "select", props: { placeholder: "请选择" }, order: 1 },
-    render: (scope: any) => {
-      const status = scope.row.hasResult;
-      const statusItem = uploadStatusEnum.find(item => item.value === status);
-      return statusItem ? statusItem.label : "-";
-    }
-  },
   { prop: "operation", label: "操作", fixed: "right", width: 300 }
 ]);
 

+ 2 - 2
src/views/operationManagement/personnel.vue

@@ -40,8 +40,8 @@ const registrationStatusEnum = [
 ];
 
 const columns = reactive<ColumnProps<any>[]>([
-  { prop: "userName", label: "姓名", minWidth: 220 },
-  { prop: "phone", label: "联系方式", width: 300 },
+  { prop: "signupName", label: "姓名", minWidth: 220 },
+  { prop: "signupPhone", label: "联系方式", width: 300 },
   {
     prop: "activityName",
     label: "所属活动",

+ 4 - 10
src/views/operationManagement/personnelDetail.vue

@@ -11,13 +11,13 @@
             <div class="detail-label">所属活动 : {{ detail.activityName || "-" }}</div>
           </div>
           <div class="detail-item">
-            <div class="detail-label">用户昵称 : {{ detail.nickName || detail.nickname || "-" }}</div>
+            <div class="detail-label">用户昵称 : {{ detail.nickName || "-" }}</div>
           </div>
           <div class="detail-item">
-            <div class="detail-label">姓名 : {{ detail.userName || "-" }}</div>
+            <div class="detail-label">姓名 : {{ detail.signupName || "-" }}</div>
           </div>
           <div class="detail-item">
-            <div class="detail-label">联系方式 : {{ detail.phone || "-" }}</div>
+            <div class="detail-label">联系方式 : {{ detail.signupPhone || "-" }}</div>
           </div>
           <div class="detail-item">
             <div class="detail-label">报名时间 : {{ formatTime(detail.signupTime) }}</div>
@@ -112,38 +112,32 @@ onMounted(async () => {
   padding: 16px;
   background: #ffffff;
 }
-
 .header {
   display: flex;
   gap: 16px;
   align-items: center;
   margin-bottom: 16px;
 }
-
 .title {
   margin: 0;
   font-size: 18px;
   font-weight: 600;
 }
-
 .detail-list {
   display: flex;
   flex-direction: column;
   gap: 16px;
 }
-
 .detail-item {
   display: flex;
   flex-direction: column;
   gap: 8px;
 }
-
 .detail-label {
   font-size: 14px;
-  color: #606266;
   font-weight: 500;
+  color: #606266;
 }
-
 .detail-value {
   font-size: 14px;
   color: #303133;

+ 1 - 4
src/views/performance/edit.vue

@@ -495,9 +495,7 @@ async function loadDetail() {
       };
     }
     syncPosterAndDetailFiles();
-  } catch (e) {
-    ElMessage.error("获取详情失败");
-  }
+  } catch (e) {}
 }
 
 function syncPosterAndDetailFiles() {
@@ -591,7 +589,6 @@ async function submit() {
       ElMessage.error(res?.msg || "提交失败");
     }
   } catch (e) {
-    ElMessage.error("提交失败");
   } finally {
     submitting.value = false;
   }

+ 0 - 4
src/views/priceList/edit.vue

@@ -658,7 +658,6 @@ const fetchDetail = async () => {
     }
   } catch (error) {
     console.error("获取价目表详情失败:", error);
-    ElMessage.error("获取详情失败");
   }
 };
 
@@ -732,12 +731,9 @@ async function submit(isDraft: boolean) {
       if (res && res.code === 200) {
         ElMessage.success(id.value ? "保存成功" : "新建成功");
         router.back();
-      } else {
-        ElMessage.error(res?.msg || "操作失败");
       }
     } catch (error) {
       console.error("提交失败:", error);
-      ElMessage.error("操作失败");
     } finally {
       submitting.value = false;
     }

+ 1 - 0
src/views/priceList/index.vue

@@ -183,6 +183,7 @@ const baseColumns: ColumnProps<PriceListRow>[] = [
     prop: "shelfStatus",
     label: "上下架状态",
     search: {
+      key: "origin",
       el: "select",
       props: { placeholder: "请选择上下架状态" }
     },

+ 635 - 0
src/views/storeDecoration/decorationChat.vue

@@ -0,0 +1,635 @@
+<template>
+  <div class="decoration-chat">
+    <div class="chat-header">
+      <el-button text @click="handleBack" class="back-btn">
+        <el-icon><ArrowLeft /></el-icon>
+      </el-button>
+      <span class="chat-title">{{ ownerName || "联系业主" }}</span>
+    </div>
+
+    <div ref="chatContainerRef" class="chat-content" @scroll="handleScroll">
+      <div v-if="loading" class="loading-wrap">
+        <el-icon class="is-loading">
+          <Loading />
+        </el-icon>
+        <span>加载中...</span>
+      </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>
+          <div class="msg-body">
+            <div class="msg-time">
+              {{ msg.createdTime }}
+            </div>
+            <div class="msg-bubble">
+              <!-- type 1、2、8 正常展示 -->
+              <template v-if="[1, 2, 8].includes(Number(msg.type))">
+                <!-- 文本 type=1 -->
+                <div v-if="msg.type === 1 || msg.type === '1'" class="msg-text">
+                  {{ msg.content || msg.text }}
+                </div>
+                <!-- 图片 type=2 -->
+                <el-image
+                  v-else-if="msg.type === 2 || msg.type === '2'"
+                  :src="msg.content || msg.text"
+                  fit="cover"
+                  class="msg-image"
+                  :preview-src-list="[msg.content || msg.text]"
+                />
+                <!-- 视频 type=8 -->
+                <video
+                  v-else-if="msg.type === 8 || msg.type === '8'"
+                  :src="msg.content || msg.text"
+                  class="msg-video"
+                  controls
+                  preload="metadata"
+                />
+              </template>
+              <!-- 其他 type 显示提示 -->
+              <div v-else class="msg-text msg-tip">此消息请去商家App查看</div>
+            </div>
+          </div>
+        </div>
+      </div>
+    </div>
+
+    <div class="chat-input-bar">
+      <input ref="imageInputRef" type="file" accept="image/*" class="hidden-file-input" @change="handleImageSelect" />
+      <input ref="videoInputRef" type="file" accept="video/*" class="hidden-file-input" @change="handleVideoSelect" />
+      <div class="input-row">
+        <div class="tool-btns">
+          <el-tooltip content="图片" placement="top">
+            <el-button text :disabled="sending || uploading" @click="triggerImageInput" class="tool-btn">
+              <el-icon :size="22">
+                <Picture />
+              </el-icon>
+            </el-button>
+          </el-tooltip>
+          <el-tooltip content="视频" placement="top">
+            <el-button text :disabled="sending || uploading" @click="triggerVideoInput" class="tool-btn">
+              <el-icon :size="22">
+                <VideoPlay />
+              </el-icon>
+            </el-button>
+          </el-tooltip>
+        </div>
+        <el-input
+          v-model="inputText"
+          type="textarea"
+          :rows="2"
+          :maxlength="500"
+          show-word-limit
+          placeholder="请输入消息"
+          resize="none"
+          @keydown.enter.exact.prevent="handleSend"
+        />
+        <el-button type="primary" :loading="sending" @click="handleSend" class="send-btn"> 发送 </el-button>
+      </div>
+      <div v-if="uploading" class="uploading-tip">上传中...</div>
+    </div>
+  </div>
+</template>
+
+<script setup lang="ts" name="decorationChat">
+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 { useWebSocketStore } from "@/stores/modules/websocket";
+import { getChatRecord, messageRead, socketStatus, uploadChatFile } from "@/api/modules/storeDecoration";
+import { localGet } from "@/utils";
+
+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 sendId = ref("");
+const receiverId = ref("");
+const ownerName = ref("");
+const ownerAvatar = ref("");
+const requirementId = ref("");
+
+const messages = ref<any[]>([]);
+const loading = ref(true);
+const sending = ref(false);
+const uploading = ref(false);
+const inputText = ref("");
+const chatContainerRef = ref<HTMLElement>();
+const imageInputRef = ref<HTMLInputElement | null>(null);
+const videoInputRef = ref<HTMLInputElement | null>(null);
+let cleanMessageFn: (() => void) | null = null;
+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"
+);
+
+const isMine = (msg: any) => String(msg.senderId || "") === String(sendId.value);
+
+const handleBack = () => router.go(-1);
+
+const scrollToBottom = () => {
+  nextTick(() => {
+    const el = chatContainerRef.value;
+    if (el) el.scrollTop = el.scrollHeight;
+  });
+};
+
+const handleSend = async () => {
+  const text = inputText.value?.trim();
+  if (!text || !receiverId.value) return;
+  if (!socketStore.isConnected) {
+    ElMessage.warning("连接已断开,请稍后重试");
+    return;
+  }
+  sending.value = true;
+  const ok = await socketStore.sendMessage({
+    category: "message",
+    type: 1,
+    receiverId: receiverId.value,
+    senderId: sendId.value,
+    text
+  });
+  sending.value = false;
+  if (ok) {
+    messages.value = [
+      ...messages.value,
+      {
+        id: `temp_${Date.now()}`,
+        type: 1,
+        content: text,
+        senderId: sendId.value,
+        receiverId: receiverId.value,
+        createdTime: formatTime(new Date())
+      }
+    ];
+    inputText.value = "";
+    scrollToBottom();
+  } else {
+    ElMessage.error("发送失败");
+  }
+};
+
+// 解析上传接口返回的 fileUrl(与商家端一致)
+const getFileUrlFromRes = (res: any): string => {
+  const data = res?.data;
+  if (Array.isArray(data) && data.length > 0) {
+    const first = data[0];
+    return typeof first === "string" ? first : first?.fileUrl || first?.url || first?.path || "";
+  }
+  if (typeof data === "string") return data;
+  if (data && typeof data === "object") return data.fileUrl || data.url || data.path || "";
+  return "";
+};
+
+const triggerImageInput = () => imageInputRef.value?.click();
+const triggerVideoInput = () => videoInputRef.value?.click();
+
+const handleImageSelect = async (e: Event) => {
+  const target = e.target as HTMLInputElement;
+  const file = target.files?.[0];
+  target.value = "";
+  if (!file || !receiverId.value) return;
+  if (!socketStore.isConnected) {
+    ElMessage.warning("连接已断开,请稍后重试");
+    return;
+  }
+  uploading.value = true;
+  try {
+    const form = new FormData();
+    form.append("file", file);
+    const res: any = await uploadChatFile(form);
+    const fileUrl = getFileUrlFromRes(res);
+    if (!fileUrl) {
+      ElMessage.error("图片上传失败");
+      return;
+    }
+    const ok = await socketStore.sendMessage({
+      category: "message",
+      type: 2,
+      receiverId: receiverId.value,
+      senderId: sendId.value,
+      text: fileUrl
+    });
+    if (ok) {
+      messages.value = [
+        ...messages.value,
+        {
+          id: `temp_${Date.now()}`,
+          type: 2,
+          content: fileUrl,
+          senderId: sendId.value,
+          receiverId: receiverId.value,
+          createdTime: formatTime(new Date())
+        }
+      ];
+      scrollToBottom();
+    } else {
+      ElMessage.error("发送失败");
+    }
+  } catch (err) {
+    console.error("发送图片失败", err);
+    ElMessage.error("图片上传或发送失败");
+  } finally {
+    uploading.value = false;
+  }
+};
+
+const handleVideoSelect = async (e: Event) => {
+  const target = e.target as HTMLInputElement;
+  const file = target.files?.[0];
+  target.value = "";
+  if (!file || !receiverId.value) return;
+  if (!socketStore.isConnected) {
+    ElMessage.warning("连接已断开,请稍后重试");
+    return;
+  }
+  uploading.value = true;
+  try {
+    const form = new FormData();
+    form.append("file", file);
+    const res: any = await uploadChatFile(form);
+    const fileUrl = getFileUrlFromRes(res);
+    if (!fileUrl) {
+      ElMessage.error("视频上传失败");
+      return;
+    }
+    const ok = await socketStore.sendMessage({
+      category: "message",
+      type: 8,
+      receiverId: receiverId.value,
+      senderId: sendId.value,
+      text: fileUrl
+    });
+    if (ok) {
+      messages.value = [
+        ...messages.value,
+        {
+          id: `temp_${Date.now()}`,
+          type: 8,
+          content: fileUrl,
+          senderId: sendId.value,
+          receiverId: receiverId.value,
+          createdTime: formatTime(new Date())
+        }
+      ];
+      scrollToBottom();
+    } else {
+      ElMessage.error("发送失败");
+    }
+  } catch (err) {
+    console.error("发送视频失败", err);
+    ElMessage.error("视频上传或发送失败");
+  } finally {
+    uploading.value = false;
+  }
+};
+
+// 消息已读(与商家端 chat.vue 一致:receiverId=当前登录人,senderId=当前聊天人)
+const readMessage = async () => {
+  if (!receiverId.value || !sendId.value) return;
+  try {
+    await messageRead({
+      receiverId: sendId.value,
+      senderId: receiverId.value
+    });
+  } catch (e) {
+    console.error("消息已读接口调用失败", e);
+  }
+};
+
+const loadChatRecord = async () => {
+  if (!receiverId.value || !sendId.value) {
+    loading.value = false;
+    return;
+  }
+  try {
+    loading.value = true;
+    await readMessage();
+    const res: any = await getChatRecord({
+      receiverId: sendId.value,
+      senderId: receiverId.value
+    });
+    const list = res?.data?.messageList || res?.messageList || [];
+    list.forEach((item: any) => {
+      item.messageId = item.id;
+    });
+    const filterData = list.filter((v: any) => !(v.type === "5" && v.senderId === sendId.value));
+    messages.value = filterData;
+  } catch (e) {
+    console.error("获取聊天记录失败", e);
+  } finally {
+    loading.value = false;
+    scrollToBottom();
+  }
+};
+
+const initWebSocket = async () => {
+  const phone = userInfo.value?.phone || localGet("iphone");
+  if (!phone) {
+    ElMessage.warning("未获取到商家信息,无法连接");
+    return;
+  }
+  sendId.value = `store_${phone}`;
+  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 (cleanMessageFn) {
+    cleanMessageFn();
+    cleanMessageFn = null;
+  }
+
+  // 归一化 ID:统一去掉 store_/user_ 前缀再比较,避免业主端与商家端 ID 格式不一致导致收不到
+  const normalizeId = (id: string) => {
+    if (!id) return "";
+    const s = String(id).trim();
+    return s.replace(/^(store_|user_)/i, "");
+  };
+
+  cleanMessageFn = socketStore.subscribe("message", (val: any) => {
+    const sId = String(sendId.value);
+    const rId = String(receiverId.value);
+    const vSender = String(val.senderId ?? "");
+    const vReceiver = String(val.receiverId ?? "");
+    const sIdNorm = normalizeId(sId);
+    const rIdNorm = normalizeId(rId);
+    const vSenderNorm = normalizeId(vSender);
+    const vReceiverNorm = normalizeId(vReceiver);
+    // 当前会话:对方发给我(vReceiver 是我)且 vSender 是当前业主;或反向
+    const isCurrent = (vReceiver === sId || vReceiverNorm === sIdNorm) && (vSender === rId || vSenderNorm === rIdNorm);
+    const isCurrentReverse = (vReceiver === rId || vReceiverNorm === rIdNorm) && (vSender === sId || vSenderNorm === sIdNorm);
+    if (!isCurrent && !isCurrentReverse) return;
+
+    const newMsg = {
+      ...val,
+      id: val.messageId || val.id,
+      content: val.text || val.content,
+      createdTime: val.createdTime || formatTime(new Date())
+    };
+    const contentOrText = String(newMsg.content ?? val.text ?? "").trim();
+    const isFromMe = vSender === sId || vSenderNorm === sIdNorm;
+
+    // 1) 先按服务器 id 去重
+    let existingIndex = messages.value.findIndex(item => newMsg.id && (item.id === newMsg.id || item.messageId === newMsg.id));
+    // 2) 若是自己发的消息,可能是服务端回显:用“临时消息(temp_) + 相同内容”替换,避免显示两条
+    if (existingIndex === -1 && isFromMe) {
+      const sameContent = (a: any) => String(a?.content ?? a?.text ?? "").trim();
+      existingIndex = messages.value.findIndex(
+        item =>
+          String(item.senderId ?? "") === sId &&
+          String(item.id ?? "").startsWith("temp_") &&
+          sameContent(item) === sameContent(newMsg) &&
+          (newMsg.type == null || Number(item.type) === Number(newMsg.type))
+      );
+    }
+    if (existingIndex !== -1) {
+      messages.value = messages.value.map((m, i) => (i === existingIndex ? { ...m, ...newMsg } : m));
+    } else {
+      messages.value = [...messages.value, newMsg];
+    }
+    nextTick(() => scrollToBottom());
+
+    if (vSender !== sId) {
+      socketStore.sendMessage({ ...val, category: "receipt" }).catch((err: any) => console.error("回执发送失败", err));
+    }
+  });
+
+  statusTimer = setInterval(() => {
+    socketStatus().catch(() => {});
+  }, 5000);
+};
+
+const formatTime = (d: Date) => {
+  const y = d.getFullYear();
+  const m = String(d.getMonth() + 1).padStart(2, "0");
+  const day = String(d.getDate()).padStart(2, "0");
+  const h = String(d.getHours()).padStart(2, "0");
+  const min = String(d.getMinutes()).padStart(2, "0");
+  const s = String(d.getSeconds()).padStart(2, "0");
+  return `${y}-${m}-${day} ${h}:${min}:${s}`;
+};
+
+const handleScroll = () => {};
+
+watch(
+  () => route.query,
+  q => {
+    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";
+      requirementId.value = String(q.id || q.requirementId || "");
+      loadChatRecord();
+    }
+  },
+  { immediate: true }
+);
+
+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";
+  requirementId.value = String(route.query.id || route.query.requirementId || "");
+
+  if (!receiverId.value) {
+    ElMessage.warning("缺少会话信息");
+    loading.value = false;
+    return;
+  }
+  await initWebSocket();
+  await loadChatRecord();
+});
+
+onBeforeUnmount(() => {
+  readMessage();
+  if (cleanMessageFn) {
+    cleanMessageFn();
+    cleanMessageFn = null;
+  }
+  if (statusTimer) {
+    clearInterval(statusTimer);
+    statusTimer = null;
+  }
+});
+</script>
+
+<style lang="scss" scoped>
+.decoration-chat {
+  display: flex;
+  flex-direction: column;
+  height: calc(100vh - 100px);
+  background: #f4f6fb;
+}
+.chat-header {
+  display: flex;
+  align-items: center;
+  padding: 12px 16px;
+  background: #ffffff;
+  border-bottom: 1px solid #ebeef5;
+  .back-btn {
+    margin-right: 12px;
+    font-size: 18px;
+  }
+  .chat-title {
+    font-size: 16px;
+    font-weight: 500;
+  }
+}
+.chat-content {
+  flex: 1;
+  padding: 16px;
+  overflow-y: auto;
+  .loading-wrap {
+    display: flex;
+    gap: 8px;
+    align-items: center;
+    justify-content: center;
+    padding: 24px;
+    color: #909399;
+  }
+  .message-list {
+    display: flex;
+    flex-direction: column;
+    gap: 16px;
+  }
+  .message-item {
+    display: flex;
+    gap: 12px;
+    align-items: flex-start;
+    .msg-avatar {
+      flex-shrink: 0;
+      overflow: hidden;
+      .avatar-fallback {
+        display: flex;
+        align-items: center;
+        justify-content: center;
+        width: 100%;
+        height: 100%;
+        font-size: 16px;
+        color: #ffffff;
+        background: #c0c4cc;
+      }
+    }
+    &.mine {
+      flex-direction: row-reverse;
+      .msg-body {
+        align-items: flex-end;
+      }
+      .msg-bubble {
+        color: #ffffff;
+        background: #409eff;
+      }
+    }
+    .msg-body {
+      display: flex;
+      flex-direction: column;
+      align-items: flex-start;
+      max-width: 70%;
+    }
+    .msg-time {
+      margin-bottom: 4px;
+      font-size: 12px;
+      color: #909399;
+    }
+    .msg-bubble {
+      padding: 10px 14px;
+      word-break: break-word;
+      background: #ffffff;
+      border-radius: 8px;
+      box-shadow: 0 1px 2px rgb(0 0 0 / 6%);
+    }
+    .msg-text {
+      font-size: 14px;
+      line-height: 1.5;
+      &.msg-tip {
+        font-style: italic;
+        color: #ffffff;
+      }
+    }
+    .msg-image {
+      max-width: 200px;
+      max-height: 200px;
+      cursor: pointer;
+      border-radius: 4px;
+    }
+    .msg-video {
+      max-width: 240px;
+      max-height: 180px;
+      background: #000000;
+      border-radius: 4px;
+    }
+    .msg-share {
+      font-size: 14px;
+      color: #606266;
+    }
+  }
+}
+.hidden-file-input {
+  position: absolute;
+  width: 0;
+  height: 0;
+  overflow: hidden;
+  pointer-events: none;
+  opacity: 0;
+}
+.chat-input-bar {
+  position: relative;
+  display: flex;
+  flex-direction: column;
+  gap: 8px;
+  padding: 12px 16px;
+  background: #ffffff;
+  border-top: 1px solid #ebeef5;
+  .input-row {
+    display: flex;
+    gap: 8px;
+    align-items: flex-end;
+    :deep(.el-input) {
+      flex: 1;
+      min-width: 0;
+    }
+  }
+  .tool-btns {
+    display: flex;
+    flex-shrink: 0;
+    gap: 4px;
+    align-items: center;
+    .tool-btn {
+      padding: 6px;
+    }
+  }
+  :deep(.el-textarea__inner) {
+    border-radius: 8px;
+  }
+  .send-btn {
+    flex-shrink: 0;
+    align-self: flex-end;
+  }
+  .uploading-tip {
+    font-size: 12px;
+    color: #909399;
+  }
+}
+</style>

+ 21 - 11
src/views/storeDecoration/decorationCompany.vue

@@ -3,8 +3,8 @@
     <ProTable ref="proTable" :columns="columns" :request-api="getTableList" :init-param="initParam" :data-callback="dataCallback">
       <!-- 表格操作 -->
       <template #operation="scope">
-        <el-button link type="primary" @click="handleContact(scope.row)">联系业主</el-button>
-        <el-button link type="primary" @click="handleView(scope.row)">查看详情</el-button>
+        <el-button link type="primary" @click="handleContact(scope.row)"> 联系业主 </el-button>
+        <el-button link type="primary" @click="handleView(scope.row)"> 查看详情 </el-button>
       </template>
     </ProTable>
   </div>
@@ -60,7 +60,7 @@ const dataCallback = (data: any) => {
 // 获取表格列表
 const getTableList = (params: any) => {
   let newParams: any = {};
-  
+
   // 只保留分页参数 page 和 size
   if (params.pageNum) {
     newParams.page = params.pageNum;
@@ -68,7 +68,7 @@ const getTableList = (params: any) => {
   if (params.pageSize) {
     newParams.size = params.pageSize;
   }
-  
+
   // 处理沟通状态参数 hasCommunicated(将字符串转换为布尔值)
   // true:已沟通, false:未沟通
   if (params.hasCommunicated !== undefined && params.hasCommunicated !== "" && params.hasCommunicated !== null) {
@@ -79,20 +79,31 @@ const getTableList = (params: any) => {
       newParams.hasCommunicated = false;
     }
   }
-  
+
   // 处理装修类型参数
   if (params.renovationType !== undefined && params.renovationType !== "" && params.renovationType !== null) {
     newParams.renovationType = params.renovationType;
   }
-  
+
   // 调用 /renovation/requirement/getPage 接口(只传 page、size 和筛选条件)
   return getDecorationPage(newParams);
 };
 
-// 联系业主
+// 联系业主(跳转聊天页面)
 const handleContact = (row: any) => {
-  // TODO: 实现联系业主功能
-  ElMessage.info("联系业主功能待开发");
+  // receiverId: 业主/用户ID
+  const receiverId = row.storeTel;
+  if (!receiverId) {
+    ElMessage.warning("无法获取业主信息,请联系管理员");
+    return;
+  }
+  const params = new URLSearchParams({
+    receiverId: String(receiverId),
+    uName: encodeURIComponent(row.contactName || row.userNickname || "业主"),
+    userImage: encodeURIComponent(row.storeAvatar || ""),
+    id: String(row.id || "")
+  });
+  router.push(`/storeDecorationManagement/decorationChat?${params.toString()}`);
 };
 
 // 查看详情
@@ -193,5 +204,4 @@ onActivated(() => {
 });
 </script>
 
-<style lang="scss" scoped>
-</style>
+<style lang="scss" scoped></style>

+ 33 - 39
src/views/storeDecoration/decorationCompanyDetail.vue

@@ -16,7 +16,7 @@
             fit="cover"
             :preview-src-list="[formData.storeAvatar]"
           />
-          <span v-else style="color: #999">暂无头像</span>
+          <span v-else style="color: #999999">暂无头像</span>
         </el-form-item>
 
         <el-form-item label="用户昵称">
@@ -35,9 +35,9 @@
 
         <el-form-item label="装修类型">
           <el-radio-group v-model="formData.renovationType" disabled>
-            <el-radio :label="1">新房装修</el-radio>
-            <el-radio :label="2">旧房改造</el-radio>
-            <el-radio :label="3">局部装修</el-radio>
+            <el-radio :label="1"> 新房装修 </el-radio>
+            <el-radio :label="2"> 旧房改造 </el-radio>
+            <el-radio :label="3"> 局部装修 </el-radio>
           </el-radio-group>
         </el-form-item>
 
@@ -50,13 +50,7 @@
         </el-form-item>
 
         <el-form-item label="详细需求">
-          <el-input
-            v-model="formData.detailedRequirement"
-            type="textarea"
-            :rows="4"
-            placeholder="请输入"
-            disabled
-          />
+          <el-input v-model="formData.detailedRequirement" type="textarea" :rows="4" placeholder="请输入" disabled />
         </el-form-item>
 
         <el-form-item label="期望装修时间" required>
@@ -96,19 +90,13 @@
         </el-form-item>
 
         <el-form-item label="详细地址">
-          <el-input
-            v-model="formData.detailedAddress"
-            type="textarea"
-            :rows="3"
-            placeholder="请输入"
-            disabled
-          />
+          <el-input v-model="formData.detailedAddress" type="textarea" :rows="3" placeholder="请输入" disabled />
         </el-form-item>
       </el-form>
 
       <div class="detail-footer">
-        <el-button @click="handleClose">返回</el-button>
-        <el-button type="primary" @click="handleContact">联系业主</el-button>
+        <el-button @click="handleClose"> 返回 </el-button>
+        <el-button type="primary" @click="handleContact"> 联系业主 </el-button>
       </div>
     </div>
 
@@ -149,7 +137,9 @@ const formData = ref<any>({
   contactPhone: "",
   city: "",
   detailedAddress: "",
-  attachmentUrls: []
+  attachmentUrls: [],
+  createUserId: "", // 业主/创建者ID,用于聊天
+  userId: "" // 兼容字段
 });
 
 const fileList = ref<UploadFile[]>([]);
@@ -157,10 +147,21 @@ const imageViewerVisible = ref(false);
 const imageViewerUrlList = ref<string[]>([]);
 const imageViewerInitialIndex = ref(0);
 
-// 联系业主
+// 联系业主(跳转聊天页面)
 const handleContact = () => {
-  // TODO: 实现联系业主功能
-  ElMessage.info("联系业主功能待开发");
+  const data = formData.value as any;
+  const receiverId = data.createUserId || data.userId || data.contactPhone;
+  if (!receiverId) {
+    ElMessage.warning("无法获取业主信息,请联系管理员");
+    return;
+  }
+  const params = new URLSearchParams({
+    receiverId: String(receiverId),
+    uName: encodeURIComponent(data.contactName || data.userNickname || "业主"),
+    userImage: encodeURIComponent(data.storeAvatar || ""),
+    id: String(route.query.id || "")
+  });
+  router.push(`/storeDecorationManagement/decorationChat?${params.toString()}`);
 };
 
 // 获取详情数据
@@ -191,7 +192,9 @@ const initData = async () => {
         contactPhone: data.contactPhone || "",
         city: data.city || "",
         detailedAddress: data.detailedAddress || "",
-        attachmentUrls: data.attachmentUrls || []
+        attachmentUrls: data.attachmentUrls || [],
+        createUserId: data.createUserId || data.userId || "",
+        userId: data.userId || data.createUserId || ""
       };
 
       // 处理附件列表
@@ -252,53 +255,44 @@ onMounted(() => {
   width: 100%;
   min-height: 100%;
   background-color: white;
-
   .content-box {
     padding: 20px;
   }
-
   .detail-header {
     display: flex;
-    justify-content: space-between;
     align-items: center;
-    margin-bottom: 20px;
+    justify-content: space-between;
     padding-bottom: 15px;
+    margin-bottom: 20px;
     border-bottom: 1px solid #ebeef5;
-
     h3 {
       margin: 0;
       font-size: 18px;
       font-weight: 500;
     }
   }
-
   :deep(.el-form-item) {
     margin-bottom: 20px;
   }
-
   :deep(.el-radio-group) {
     display: flex;
     gap: 20px;
   }
-
   .upload-tip {
-    font-size: 12px;
-    color: #999;
     margin-top: 5px;
+    font-size: 12px;
+    color: #999999;
   }
-
   .detail-footer {
-    text-align: center;
     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;

+ 42 - 16
src/views/storeDecoration/detail.vue

@@ -53,15 +53,17 @@
             </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-wrapper">
+                <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>
               <div class="upload-tip">({{ fileList.length }}/9)</div>
             </el-form-item>
 
@@ -319,14 +321,38 @@ onMounted(() => {
     border-top: 1px solid #ebeef5;
   }
 
-  :deep(.el-upload--picture-card) {
-    width: 100px;
-    height: 100px;
-  }
+  .upload-wrapper {
+    :deep(.el-upload) {
+      display: inline-block;
+    }
+
+    :deep(.el-upload-list--picture-card) {
+      display: grid;
+      grid-template-columns: repeat(3, 1fr);
+      gap: 10px;
+      margin: 0;
+      width: 100%;
+    }
+
+    :deep(.el-upload--picture-card) {
+      width: 100%;
+      margin: 0;
+      display: none; // 禁用状态下隐藏上传按钮
+    }
+
+    :deep(.el-upload-list--picture-card .el-upload-list__item) {
+      width: 100%;
+      margin: 0;
+      aspect-ratio: 1;
+      height: auto;
+    }
 
-  :deep(.el-upload-list--picture-card .el-upload-list__item) {
-    width: 100px;
-    height: 100px;
+    // 确保图片缩略图正确显示
+    :deep(.el-upload-list__item-thumbnail) {
+      width: 100%;
+      height: 100%;
+      object-fit: cover;
+    }
   }
 }
 </style>

+ 11 - 5
src/views/storeDecoration/personnelConfig/index.vue

@@ -170,6 +170,12 @@
               {{ formatTime(personnelDetail.submissionTime || personnelDetail.createTime || personnelDetail.createdTime) || "—" }}
             </div>
           </div>
+          <div class="detail-item">
+            <div class="detail-label">审核时间:</div>
+            <div class="detail-value">
+              {{ formatTime(personnelDetail.auditTime) || "—" }}
+            </div>
+          </div>
         </template>
         <el-empty v-else-if="!detailLoading" description="暂无数据" />
       </div>
@@ -222,10 +228,10 @@
           <UploadImgs
             :key="uploadComponentKey"
             ref="backgroundImagesUploadRef"
-            v-model:file-list="formData.backgroundImages"
+            v-model:image-url="formData.backgroundImages"
             :limit="9"
             :file-size="100"
-            :file-type="['image/jpeg', 'image/png', 'image/gif', 'image/webp', 'video/mp4'] as any"
+            :file-type="['image/jpeg', 'image/png', 'image/gif', 'image/webp', 'video/mp4']"
             :width="'150px'"
             :height="'150px'"
             :border-radius="'8px'"
@@ -1501,12 +1507,12 @@ const editPersonnel = async (person: Personnel, index: number) => {
             const fileName = urlParts[urlParts.length - 1] || `background-${index + 1}.jpg`;
 
             return {
-              uid: `bg-${Date.now()}-${index}`,
+              uid: "bg-" + Date.now() + index,
               name: fileName,
               url: trimmedUrl,
-              status: "success" as const,
+              status: "success",
               response: { fileUrl: trimmedUrl }
-            } as UploadUserFile;
+            } as unknown as UploadUserFile;
           })
           .filter((file): file is UploadUserFile => file !== null);
       }