lxr 2 месяцев назад
Родитель
Сommit
1c735bd60a

+ 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 });

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

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

+ 45 - 2
src/stores/modules/websocket.ts

@@ -3,7 +3,7 @@ import { ref } from "vue";
 
 /**
  * WebSocket Store(浏览器端)
- * 与商家端 @/store/websocket 消息格式一致,用于分享动态等
+ * 与商家端 @/store/websocket 消息格式一致,用于分享动态、聊天
  */
 export const useWebSocketStore = defineStore("websocket", () => {
   const socket = ref<WebSocket | null>(null);
@@ -11,6 +11,9 @@ export const useWebSocketStore = defineStore("websocket", () => {
   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);
@@ -39,6 +42,29 @@ export const useWebSocketStore = defineStore("websocket", () => {
           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;
@@ -58,6 +84,21 @@ export const useWebSocketStore = defineStore("websocket", () => {
     });
   };
 
+  /** 订阅消息(返回取消订阅函数) */
+  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) {
@@ -86,6 +127,7 @@ export const useWebSocketStore = defineStore("websocket", () => {
     isConnected.value = false;
     isConnecting.value = false;
     lastConnectedUrl.value = "";
+    messageHandlers.clear();
   };
 
   return {
@@ -95,6 +137,7 @@ export const useWebSocketStore = defineStore("websocket", () => {
     lastConnectedUrl,
     connect,
     sendMessage,
-    disconnect
+    disconnect,
+    subscribe
   };
 });

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

@@ -0,0 +1,623 @@
+<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">
+              <!-- 文本 -->
+              <div v-if="msg.type === 1 || msg.type === '1'" class="msg-text">
+                {{ msg.content || msg.text }}
+              </div>
+              <!-- 图片 -->
+              <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]"
+              />
+              <!-- 视频 -->
+              <video
+                v-else-if="msg.type === 8 || msg.type === '8'"
+                :src="msg.content || msg.text"
+                class="msg-video"
+                controls
+                preload="metadata"
+              />
+              <!-- 分享卡片等 -->
+              <div v-else-if="msg.type === 3 || msg.type === '3'" class="msg-share">
+                <span v-if="typeof (msg.content || msg.text) === 'object'">
+                  {{ (msg.content || msg.text)?.title || "分享链接" }}
+                </span>
+                <span v-else>{{ msg.content || msg.text }}</span>
+              </div>
+              <!-- 其他类型 -->
+              <div v-else class="msg-text">
+                {{ msg.content || msg.text || "[消息]" }}
+              </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: "store_" + 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: "store_" + 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: "store_" + 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: "store_" + 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;
+  }
+};
+
+const loadChatRecord = async () => {
+  if (!receiverId.value || !sendId.value) {
+    loading.value = false;
+    return;
+  }
+  try {
+    loading.value = true;
+    await messageRead({ receiverId: sendId.value, senderId: receiverId.value });
+    const res: any = await getChatRecord({
+      receiverId: sendId.value,
+      senderId: "store_" + 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(() => {
+  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-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;

+ 2 - 2
src/views/storeDecoration/personnelConfig/index.vue

@@ -222,10 +222,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'"