网页接入弹窗客服功能的完整实现(Vue3 + WebSocket预备方案)

网页接入弹窗客服功能的完整实现(Vue3 + WebSocket预备方案)

@[toc]

前言

AI对话发展越来越快,大多数场景应用未AI客服对话,再官网或者介绍网页中插入AI机器人聊天功能,能大大的提升用户的体验,今天就做一个vue3实现一个弹窗客服组件,及其如何接入websocket实时通讯的前期准备的案例。

一、基础弹窗客服功能实现

1. 组件结构设计

首先创建一个ChatSupport.vue组件,包含以下核心部分:

vue 复制代码
<template>
  <div class="chat-container">
    <!-- 悬浮按钮 -->
    <div class="chat-button" @click="toggleChat">
      <img src="客服图标.png" alt="客服"/>
    </div>
    
    <!-- 聊天窗口 -->
    <div class="chat-window">
      <!-- 消息区域 -->
      <div class="messages-wrapper">
        <div class="messages">
          <!-- 消息列表 -->
        </div>
      </div>
      
      <!-- 输入区域 -->
      <div class="input-area">
        <!-- 输入框和工具 -->
      </div>
    </div>
  </div>
</template>

2. 核心功能实现步骤

2.1 状态管理
javascript 复制代码
import { ref, computed, watch, nextTick } from 'vue';

export default {
  setup() {
    // 基本状态
    const isOpen = ref(false);
    const inputMessage = ref('');
    const messages = ref([]);
    
    // 其他状态...
    
    return {
      isOpen,
      inputMessage,
      messages,
      // 其他方法...
    };
  }
};
2.2 消息发送与接收
javascript 复制代码
const sendMessage = () => {
  if (!inputMessage.value.trim()) return;
  
  // 添加用户消息
  addMessage({
    sender: 'user',
    type: 'text',
    content: inputMessage.value.trim(),
    time: new Date()
  });
  
  inputMessage.value = '';
  
  // 模拟AI回复
  simulateAIResponse();
};

const addMessage = (message) => {
  messages.value.push(message);
  scrollToBottom();
};

const scrollToBottom = () => {
  nextTick(() => {
    const container = document.querySelector('.messages-wrapper');
    if (container) {
      container.scrollTop = container.scrollHeight;
    }
  });
};
2.3 模拟AI回复
javascript 复制代码
const aiResponses = [
  "您好,请问有什么可以帮您?",
  // 更多预设回复...
];

const simulateAIResponse = () => {
  setTimeout(() => {
    const response = aiResponses[Math.floor(Math.random() * aiResponses.length)];
    addMessage({
      sender: 'ai',
      type: 'text',
      content: response,
      time: new Date()
    });
  }, 1000);
};

3. 样式设计要点

css 复制代码
.chat-container {
  position: fixed;
  bottom: 20px;
  right: 20px;
  z-index: 1000;
}

.chat-window {
  width: 350px;
  height: 500px;
  background: white;
  border-radius: 10px;
  box-shadow: 0 5px 15px rgba(0,0,0,0.2);
  display: flex;
  flex-direction: column;
}

.messages-wrapper {
  flex: 1;
  overflow-y: auto;
  padding: 15px;
}

.input-area {
  padding: 10px;
  border-top: 1px solid #eee;
}

二、历史记录图片表情包功能实现

1. 历史记录管理

javascript 复制代码
const chatSessions = ref([{
  startTime: new Date(),
  messages: []
}]);

const currentSessionIndex = ref(0);

const currentSession = computed(() => {
  return chatSessions.value[currentSessionIndex.value];
});

const loadSession = (index) => {
  currentSessionIndex.value = index;
};

2. 图片发送与预览

javascript 复制代码
const handleImageUpload = (e) => {
  const file = e.target.files[0];
  const reader = new FileReader();
  
  reader.onload = (event) => {
    addMessage({
      sender: 'user',
      type: 'image',
      content: event.target.result,
      time: new Date()
    });
  };
  
  reader.readAsDataURL(file);
};

3. 表情包支持

javascript 复制代码
const emojis = ['😀', '😂', '😍', /*...*/];
const showEmojiPicker = ref(false);

const selectEmoji = (emoji) => {
  inputMessage.value += emoji;
  showEmojiPicker.value = false;
};

三、接入WebSocket方案

1. WebSocket基础集成

javascript 复制代码
const socket = ref(null);
const connectWebSocket = () => {
  socket.value = new WebSocket('wss://your-websocket-endpoint');
  socket.value.onopen = () => {
    console.log('WebSocket连接已建立');
  };
  socket.value.onmessage = (event) => {
    const message = JSON.parse(event.data);
    addMessage({
      sender: 'ai',
      type: 'text',
      content: message.content,
      time: new Date()
    });
  };
  socket.value.onclose = () => {
    console.log('WebSocket连接已关闭');
  };
};
// 组件挂载时连接
onMounted(() => {
  connectWebSocket();
});
// 组件卸载时断开连接
onUnmounted(() => {
  if (socket.value) {
    socket.value.close();
  }
});

2. 消息发送改造

javascript 复制代码
const sendMessage = () => {
  if (!inputMessage.value.trim()) return;
  
  const message = {
    sender: 'user',
    type: 'text',
    content: inputMessage.value.trim(),
    time: new Date()
  };
  
  // 添加到本地消息列表
  addMessage(message);
  
  // 通过WebSocket发送
  if (socket.value && socket.value.readyState === WebSocket.OPEN) {
    socket.value.send(JSON.stringify({
      type: 'text',
      content: message.content
    }));
  }
  
  inputMessage.value = '';
};

3. 处理不同类型的消息

javascript 复制代码
socket.value.onmessage = (event) => {
  const data = JSON.parse(event.data);
  
  switch(data.type) {
    case 'text':
      addMessage({
        sender: 'ai',
        type: 'text',
        content: data.content,
        time: new Date()
      });
      break;
      
    case 'image':
      addMessage({
        sender: 'ai',
        type: 'image',
        content: data.url,
        time: new Date()
      });
      break;
      
    case 'system':
      // 处理系统通知
      break;
      
    default:
      console.warn('未知消息类型:', data.type);
  }
};

4. 心跳检测与重连机制

javascript 复制代码
const heartbeatInterval = ref(null);
const reconnectAttempts = ref(0);
const maxReconnectAttempts = 5;

const setupHeartbeat = () => {
  heartbeatInterval.value = setInterval(() => {
    if (socket.value.readyState === WebSocket.OPEN) {
      socket.value.send(JSON.stringify({ type: 'heartbeat' }));
    }
  }, 30000);
};

const reconnect = () => {
  if (reconnectAttempts.value < maxReconnectAttempts) {
    reconnectAttempts.value++;
    setTimeout(connectWebSocket, 1000 * reconnectAttempts.value);
  }
};

// 在connectWebSocket中添加
socket.value.onclose = () => {
  console.log('连接断开,尝试重连...');
  reconnect();
};

四、完整实现的最佳实践

1. 错误处理与状态管理

javascript 复制代码
const connectionStatus = ref('disconnected'); // 'connecting', 'connected', 'error'

const connectWebSocket = () => {
  connectionStatus.value = 'connecting';
  
  socket.value = new WebSocket('wss://your-endpoint');
  
  socket.value.onerror = (error) => {
    connectionStatus.value = 'error';
    console.error('WebSocket错误:', error);
  };
  
  // ...其他事件处理
};

2. 消息队列处理

javascript 复制代码
const messageQueue = ref([]);
const isProcessingQueue = ref(false);

const processQueue = () => {
  if (isProcessingQueue.value || messageQueue.value.length === 0) return;
  isProcessingQueue.value = true;
  const message = messageQueue.value.shift();
  if (socket.value.readyState === WebSocket.OPEN) {
    socket.value.send(JSON.stringify(message));
    isProcessingQueue.value = false;
    processQueue(); // 处理下一条
  } else {
    // 等待连接恢复
    messageQueue.value.unshift(message);
    isProcessingQueue.value = false;
  }
};

// 修改sendMessage
const sendMessage = () => {
  // ...
  messageQueue.value.push({
    type: 'text',
    content: message.content
  });
  processQueue();
  // ...
};

3. 性能优化建议

  1. 虚拟滚动:对于大量消息实现虚拟滚动

    vue 复制代码
    <VirtualScroll :items="messages" :item-height="60">
      <template v-slot="{ item }">
        <!-- 消息渲染 -->
      </template>
    </VirtualScroll>
  2. 消息分页加载

    javascript 复制代码
    const loadMoreMessages = () => {
      if (isLoading.value) return;
      isLoading.value = true;
      // 加载更多消息...
    };
  3. WebSocket二进制传输:对于图片等大数据量内容

    javascript 复制代码
    socket.value.binaryType = 'arraybuffer';

五、部署与安全考虑

  1. WSS协议 :生产环境务必使用wss://安全连接

  2. 认证机制

    javascript 复制代码
    socket.value.onopen = () => {
      socket.value.send(JSON.stringify({
        type: 'auth',
        token: '用户令牌'
      }));
    };
  3. 消息加密:敏感内容应加密传输

  4. 限流控制:服务器端实现消息频率限制

结语

以上就是一个基础弹窗客服到websocket实时通信的完整实现步骤。后续可以进一步扩展的功能包括: 客服坐席状态显示, 消息已读回执, 文件传输功能, 客服评价系统

总体代码

csharp 复制代码
<template>
  <div class="chat-container" :class="{ 'chat-open': isOpen }">
    <div class="chat-button" @click="toggleChat">
      <span v-if="unreadCount > 0" class="unread-badge">{{ unreadCount }}</span>
      <!-- <i class="icon-chat"></i> -->
      <img src="http://192.168.80.32:8888/客服.png" width="60%" alt="" />
    </div>

    <div class="chat-window">
      <div class="chat-header">
        <h3>在线客服</h3>
        <div class="header-actions">
          <button @click="toggleHistory" class="history-btn">
            {{ showHistory ? "返回当前对话" : "历史记录" }}
          </button>
          <button @click="toggleChat" class="close-btn">×</button>
        </div>
      </div>

      <div class="chat-body">
        <!-- 历史记录面板 -->
        <div v-if="showHistory" class="history-panel">
          <div class="history-list">
            <div
              v-for="(session, index) in currentSession.messages"
              :key="index"
              class="history-item"
              :class="{ active: currentSessionIndex === index }"
            >
              <div class="history-time">
                {{ formatTime(session.time) }}
              </div>
              <img
                style="width: 100%"
                v-if="session.type === 'image'"
                :src="session.content"
              />
              <span v-else-if="session.type === 'emoji'" class="emoji-message">
                {{ session.content }}
              </span>
              <div class="history-preview">
                {{ session.content || "无消息" }}
              </div>
            </div>
          </div>
        </div>

        <!-- 当前聊天面板 -->
        <div v-else class="message-panel">
          <div class="messages-wrapper" ref="messagesContainer">
            <div class="messages">
              <div
                v-for="(message, index) in currentSession.messages"
                :key="index"
                class="message"
                :class="{
                  user: message.sender === 'user',
                  ai: message.sender === 'ai',
                  image: message.type === 'image',
                  emoji: message.type === 'emoji',
                }"
              >
                <div class="message-content">
                  <img
                    v-if="message.type === 'image'"
                    :src="message.content"
                    @click="previewImage(message.content)"
                  />
                  <span
                    v-else-if="message.type === 'emoji'"
                    class="emoji-message"
                  >
                    {{ message.content }}
                  </span>
                  <span v-else>{{ message.content }}</span>
                </div>
                <div class="message-time">{{ formatTime(message.time) }}</div>
              </div>
            </div>
          </div>

          <div class="input-area">
            <div class="toolbar">
              <button @click="toggleEmojiPicker" class="tool-btn">
                <i class="icon-emoji">😊</i>
              </button>
              <button @click="triggerFileInput" class="tool-btn">
                <i class="icon-image">📷</i>
              </button>
              <input
                type="file"
                ref="fileInput"
                @change="handleImageUpload"
                accept="image/*"
                style="display: none"
              />
            </div>

            <div v-if="showEmojiPicker" class="emoji-picker">
              <span
                v-for="emoji in emojis"
                :key="emoji"
                @click="selectEmoji(emoji)"
                >{{ emoji }}</span
              >
            </div>

            <textarea
              v-model="inputMessage"
              @keyup.enter="sendMessage"
              placeholder="输入消息..."
              ref="textInput"
            ></textarea>

            <button @click="sendMessage" class="send-btn">发送</button>
          </div>
        </div>
      </div>
    </div>

    <!-- 图片预览模态框 -->
    <div
      v-if="previewImageUrl"
      class="image-preview-modal"
      @click="previewImageUrl = null"
    >
      <img :src="previewImageUrl" />
    </div>
  </div>
</template>

<script>
import { ref, computed, watch, nextTick, onMounted } from "vue";

export default {
  name: "ChatSupport",
  setup() {
    // 状态管理
    const isOpen = ref(false);
    const inputMessage = ref("");
    const showEmojiPicker = ref(false);
    const showHistory = ref(false);
    const previewImageUrl = ref(null);
    const unreadCount = ref(0);
    const fileInput = ref(null);
    const textInput = ref(null);
    const messagesContainer = ref(null);
    const currentSessionIndex = ref(0);

    // 模拟AI回复的简单逻辑
    const aiResponses = [
      "您好,请问有什么可以帮您?",
      "我明白了,正在为您处理...",
      "这个问题我们需要进一步核实",
      "感谢您的耐心等待",
      "请问您能提供更多细节吗?",
      "我们已经记录您的问题",
      "建议您尝试刷新页面",
      "这个问题可能需要技术支持介入",
      "我理解您的不便,非常抱歉",
      "我们会尽快解决这个问题",
    ];

    // 表情包列表
    const emojis = ["😀", "😂", "😍", "🤔", "😎", "👍", "❤️", "🙏", "🎉", "🔥"];

    // 聊天会话数据
    const chatSessions = ref([
      {
        startTime: new Date(),
        messages: [
          {
            sender: "ai",
            type: "text",
            content: "您好,请问有什么可以帮您?",
            time: new Date(),
          },
        ],
      },
    ]);

    // 计算当前会话
    const currentSession = computed(() => {
      return chatSessions.value[currentSessionIndex.value];
    });

    // 切换聊天窗口
    const toggleChat = () => {
      isOpen.value = !isOpen.value;
      if (isOpen.value) {
        unreadCount.value = 0;
        nextTick(() => {
          scrollToBottom();
          textInput.value?.focus();
        });
      }
    };
    // 计算属性获取所有消息
    const allMessages = computed(() => {
      return chatSessions.value.flatMap((session) => session.messages);
    });
    // 发送消息
    const sendMessage = () => {
      if (!inputMessage.value.trim()) return;

      // 添加用户消息
      addMessage({
        sender: "user",
        type: "text",
        content: inputMessage.value.trim(),
        time: new Date(),
      });

      inputMessage.value = "";

      // // 模拟AI回复
      setTimeout(async () => {
        // const randomResponse =
        //   aiResponses[Math.floor(Math.random() * aiResponses.length)];
        const randomResponse = await connectToAIService(
          inputMessage.value.trim()
        );
        addMessage({
          sender: "ai",
          type: "text",
          content: randomResponse,
          time: new Date(),
        });
      }, 500 + Math.random() * 200); // 1-3秒延迟
    };

    // 添加消息到当前会话
    const addMessage = (message) => {
      currentSession.value.messages.push(message);
      nextTick(() => {
        scrollToBottom();
      });

      // 如果窗口关闭且有新AI消息,增加未读计数
      if (!isOpen.value && message.sender === "ai") {
        unreadCount.value++;
      }
    };

    // 滚动到底部
    const scrollToBottom = () => {
      nextTick(() => {
        console.log("我被执行了", messagesContainer.value);
        if (messagesContainer.value) {
          messagesContainer.value.scrollTop =
            messagesContainer.value.scrollHeight;
        }
      });
    };

    // 切换表情选择器
    const toggleEmojiPicker = () => {
      showEmojiPicker.value = !showEmojiPicker.value;
    };

    // 选择表情
    const selectEmoji = (emoji) => {
      inputMessage.value += emoji;
      showEmojiPicker.value = false;
      textInput.value?.focus();
    };

    // 触发文件选择
    const triggerFileInput = () => {
      fileInput.value?.click();
    };

    // 处理图片上传
    const handleImageUpload = (e) => {
      const file = e.target.files[0];
      if (!file) return;

      const reader = new FileReader();
      reader.onload = (event) => {
        addMessage({
          sender: "user",
          type: "image",
          content: event.target.result,
          time: new Date(),
        });

        // 模拟AI回复图片
        setTimeout(() => {
          addMessage({
            sender: "ai",
            type: "text",
            content: "收到您发送的图片,正在为您处理...",
            time: new Date(),
          });
        }, 1500);
      };
      reader.readAsDataURL(file);
      e.target.value = ""; // 重置input
    };

    // 预览图片
    const previewImage = (url) => {
      previewImageUrl.value = url;
    };

    // 切换历史记录面板
    const toggleHistory = () => {
      showHistory.value = !showHistory.value;
    };

    // 加载历史会话
    const loadSession = (index) => {
      currentSessionIndex.value = index;
      showHistory.value = false;
      nextTick(() => {
        scrollToBottom();
      });
    };

    // 创建新会话
    const createNewSession = () => {
      chatSessions.value.push({
        startTime: new Date(),
        messages: [
          {
            sender: "ai",
            type: "text",
            content: "您好,请问有什么可以帮您?",
            time: new Date(),
          },
        ],
      });
      currentSessionIndex.value = chatSessions.value.length - 1;
    };

    // 格式化时间
    const formatTime = (date) => {
      return new Date(date).toLocaleTimeString([], {
        hour: "2-digit",
        minute: "2-digit",
      });
    };

    // 格式化日期
    const formatDate = (date) => {
      return new Date(date).toLocaleDateString([], {
        month: "short",
        day: "numeric",
        hour: "2-digit",
        minute: "2-digit",
      });
    };

    // 模拟连接AI服务
    const connectToAIService = async (message) => {
      // 这里可以替换为实际的AI API调用
      // 例如: const response = await fetch('your-ai-api-endpoint', {...});
      return new Promise((resolve) => {
        setTimeout(() => {
          const randomResponse =
            aiResponses[Math.floor(Math.random() * aiResponses.length)];
          resolve(randomResponse);
        }, 1000);
      });
    };

    // 组件挂载时初始化
    onMounted(() => {
      scrollToBottom();
      // 可以在这里添加初始化逻辑,比如从本地存储加载历史会话
      const savedSessions = localStorage.getItem("chatSessions");
      if (savedSessions) {
        chatSessions.value = JSON.parse(savedSessions);
      }
    });

    // 监视会话变化保存到本地存储
    watch(
      chatSessions,
      (newVal) => {
        localStorage.setItem("chatSessions", JSON.stringify(newVal));
      },
      { deep: true }
    );
    watch(
      () => currentSession.value.messages.length,
      () => {
        scrollToBottom();
      },
      { deep: true }
    );

    return {
      scrollToBottom,
      isOpen,
      inputMessage,
      showEmojiPicker,
      showHistory,
      previewImageUrl,
      unreadCount,
      fileInput,
      textInput,
      messagesContainer,
      currentSessionIndex,
      emojis,
      chatSessions,
      currentSession,
      toggleChat,
      sendMessage,
      toggleEmojiPicker,
      selectEmoji,
      triggerFileInput,
      handleImageUpload,
      previewImage,
      toggleHistory,
      loadSession,
      createNewSession,
      formatTime,
      formatDate,
    };
  },
};
</script>

<style scoped>
.chat-container {
  position: fixed;
  bottom: 20px;
  right: 20px;
  z-index: 1000;
  font-family: "Arial", sans-serif;
}

.chat-button {
  width: 60px;
  height: 60px;
  background-color: #1890ff;
  border-radius: 50%;
  display: flex;
  align-items: center;
  justify-content: center;
  cursor: pointer;
  box-shadow: 0 2px 10px rgba(0, 0, 0, 0.2);
  position: relative;
  transition: all 0.3s ease;
}

.chat-button:hover {
  background-color: #40a9ff;
  transform: scale(1.05);
}

.unread-badge {
  position: absolute;
  top: -5px;
  right: -5px;
  background-color: #f5222d;
  color: white;
  border-radius: 50%;
  width: 20px;
  height: 20px;
  display: flex;
  align-items: center;
  justify-content: center;
  font-size: 12px;
}

.chat-window {
  width: 350px;
  height: 500px;
  background-color: white;
  border-radius: 10px;
  box-shadow: 0 5px 15px rgba(0, 0, 0, 0.2);
  overflow: hidden;
  display: none;
  flex-direction: column;
  transform: translateY(20px);
  opacity: 0;
  transition: all 0.3s ease;
}

.chat-open .chat-window {
  display: flex;
  transform: translateY(0);
  opacity: 1;
}

.chat-header {
  padding: 15px;
  background-color: #1890ff;
  color: white;
  display: flex;
  justify-content: space-between;
  align-items: center;
}

.chat-header h3 {
  margin: 0;
  font-size: 16px;
}

.header-actions {
  display: flex;
  gap: 10px;
}

.history-btn,
.close-btn {
  background: none;
  border: none;
  color: white;
  cursor: pointer;
  font-size: 12px;
  padding: 5px;
}

.close-btn {
  font-size: 20px;
  line-height: 1;
}

.chat-body {
  flex: 1;
  display: flex;
  flex-direction: column;
  overflow: hidden;
}

.history-panel {
  flex: 1;
  overflow-y: auto;
  padding: 10px;
}

.history-list {
  display: flex;
  flex-direction: column;
  gap: 10px;
}

.history-item {
  padding: 10px;
  border-radius: 5px;
  background-color: #f5f5f5;
  cursor: pointer;
  transition: background-color 0.2s;
}

.history-item:hover {
  background-color: #e6f7ff;
}

.history-item.active {
  background-color: #1890ff;
  color: white;
}

.history-time {
  font-size: 12px;
  margin-bottom: 5px;
}

.history-preview {
  font-size: 14px;
  white-space: nowrap;
  overflow: hidden;
  text-overflow: ellipsis;
}

.message-panel {
  flex: 1;
  display: flex;
  flex-direction: column;
  overflow: hidden;
}

.messages {
  flex: 1;
  padding: 15px;
  overflow-y: auto;
  display: flex;
  flex-direction: column;
  gap: 15px;
}

.message {
  max-width: 80%;
  padding: 10px 15px;
  border-radius: 18px;
  position: relative;
  word-wrap: break-word;
}

.message.user {
  align-self: flex-end;
  background-color: #1890ff;
  color: white;
  border-bottom-right-radius: 5px;
}

.message.ai {
  align-self: flex-start;
  background-color: #f5f5f5;
  color: #333;
  border-bottom-left-radius: 5px;
}

.message.image {
  padding: 5px;
  background-color: transparent;
}

.message.emoji {
  font-size: 24px;
  background-color: transparent;
  padding: 5px;
}

.message-content img {
  max-width: 100%;
  max-height: 200px;
  border-radius: 10px;
  cursor: zoom-in;
}

.message-time {
  font-size: 10px;
  color: #999;
  margin-top: 5px;
  text-align: right;
}

.user .message-time {
  color: rgba(255, 255, 255, 0.7);
}

.input-area {
  padding: 10px;
  border-top: 1px solid #eee;
  position: relative;
}

.toolbar {
  display: flex;
  gap: 10px;
  margin-bottom: 5px;
}

.tool-btn {
  background: none;
  border: none;
  cursor: pointer;
  font-size: 18px;
  padding: 5px;
  color: #666;
}

.emoji-picker {
  position: absolute;
  bottom: 60px;
  left: 10px;
  background: white;
  border: 1px solid #eee;
  border-radius: 10px;
  padding: 10px;
  display: grid;
  grid-template-columns: repeat(5, 1fr);
  gap: 5px;
  max-height: 150px;
  overflow-y: auto;
  box-shadow: 0 2px 10px rgba(0, 0, 0, 0.1);
  z-index: 10;
}

.emoji-picker span {
  cursor: pointer;
  font-size: 20px;
  padding: 5px;
}

.emoji-picker span:hover {
  transform: scale(1.2);
}

textarea {
  width: 100%;
  border: 1px solid #ddd;
  border-radius: 20px;
  padding: 10px 15px;
  resize: none;
  min-height: 40px;
  max-height: 100px;
  outline: none;
  font-family: inherit;
}

.send-btn {
  position: absolute;
  right: 20px;
  bottom: 20px;
  background-color: #1890ff;
  color: white;
  border: none;
  border-radius: 50%;
  width: 30px;
  height: 30px;
  display: flex;
  align-items: center;
  justify-content: center;
  cursor: pointer;
}

.send-btn:hover {
  background-color: #40a9ff;
}

.image-preview-modal {
  position: fixed;
  top: 0;
  left: 0;
  right: 0;
  bottom: 0;
  background-color: rgba(0, 0, 0, 0.8);
  display: flex;
  align-items: center;
  justify-content: center;
  z-index: 2000;
}

.image-preview-modal img {
  max-width: 90%;
  max-height: 90%;
}
.messages-wrapper {
  flex: 1;
  overflow-y: auto;
  padding: 15px;
}

.messages {
  display: flex;
  flex-direction: column;
  gap: 15px;
  min-height: min-content;
}

.input-area {
  padding: 10px;
  border-top: 1px solid #eee;
  position: relative;
  flex-shrink: 0; /* 防止输入区域被压缩 */
  background: white; /* 确保输入区域覆盖在消息上 */
  z-index: 1; /* 确保输入区域在上层 */
}
</style>
相关推荐
奕辰杰3 小时前
关于npm前端项目编译时栈溢出 Maximum call stack size exceeded的处理方案
前端·npm·node.js
JiaLin_Denny5 小时前
如何在NPM上发布自己的React组件(包)
前端·react.js·npm·npm包·npm发布组件·npm发布包
路光.6 小时前
触发事件,按钮loading状态,封装hooks
前端·typescript·vue3hooks
我爱996!6 小时前
SpringMVC——响应
java·服务器·前端
咔咔一顿操作6 小时前
Vue 3 入门教程7 - 状态管理工具 Pinia
前端·javascript·vue.js·vue3
kk爱闹7 小时前
用el-table实现的可编辑的动态表格组件
前端·vue.js
漂流瓶jz7 小时前
JavaScript语法树简介:AST/CST/词法/语法分析/ESTree/生成工具
前端·javascript·编译原理
换日线°7 小时前
css 不错的按钮动画
前端·css·微信小程序
风象南8 小时前
前端渲染三国杀:SSR、SPA、SSG
前端
90后的晨仔8 小时前
表单输入绑定详解:Vue 中的 v-model 实践指南
前端·vue.js