Vue3 + WebSocket 即时通信单对单系统

<template>
  <div class="chat-app">
    <!-- 登录界面 -->
    <div v-if="!currentUser" class="login-container">
      <div class="login-box">
        <h1>📱 WebSocket 即时通信</h1>
        <div class="input-group">
          <input
            v-model="username"
            type="text"
            placeholder="请输入用户名"
            @keyup.enter="login"
            :disabled="isConnecting"
          />
          <button @click="login" :disabled="!username.trim() || isConnecting">
            {{ isConnecting ? '连接中...' : '登录' }}
          </button>
        </div>
        <div class="user-list" v-if="onlineUsers.length > 0">
          <h3>在线用户:</h3>
          <div class="user-tags">
            <span v-for="user in onlineUsers" :key="user.id" class="user-tag">
              {{ user.username }}
            </span>
          </div>
        </div>
      </div>
    </div>

    <!-- 主聊天界面 -->
    <div v-else class="main-container">
      <!-- 侧边栏 -->
      <div class="sidebar">
        <div class="user-info">
          <div class="current-user">
            <span class="user-avatar">{{ currentUser.username.charAt(0) }}</span>
            <div>
              <h3>{{ currentUser.username }}</h3>
              <span class="user-status online">在线</span>
            </div>
          </div>
          <button @click="logout" class="logout-btn">退出</button>
        </div>

        <div class="search-box">
          <input
            v-model="searchKeyword"
            type="text"
            placeholder="搜索用户..."
          />
        </div>

        <div class="chat-list">
          <h4>在线用户</h4>
          <div
            v-for="user in filteredUsers"
            :key="user.id"
            class="chat-item"
            :class="{ active: activeChat?.id === user.id }"
            @click="selectChat(user)"
          >
            <div class="chat-avatar">{{ user.username.charAt(0) }}</div>
            <div class="chat-info">
              <div class="chat-name">{{ user.username }}</div>
              <div class="chat-last-msg" v-if="getLastMessage(user.id)">
                {{ getLastMessage(user.id).content }}
              </div>
            </div>
            <div class="chat-status" :class="{ online: user.online }"></div>
            <div class="unread-count" v-if="getUnreadCount(user.id)">
              {{ getUnreadCount(user.id) }}
            </div>
          </div>
        </div>

        <div class="connection-status">
          <div class="status-indicator" :class="connectionStatus"></div>
          <span>{{ connectionStatusText }}</span>
          <button 
            v-if="connectionStatus === 'disconnected'" 
            @click="reconnect"
            class="reconnect-btn"
          >
            重连
          </button>
        </div>
      </div>

      <!-- 聊天区域 -->
      <div class="chat-area">
        <div v-if="activeChat" class="chat-header">
          <div class="chat-header-info">
            <div class="chat-avatar">{{ activeChat.username.charAt(0) }}</div>
            <div>
              <h3>{{ activeChat.username }}</h3>
              <span class="chat-status-text">
                {{ activeChat.online ? '在线' : '离线' }}
              </span>
            </div>
          </div>
          <div class="chat-actions">
            <button @click="clearChat" title="清空聊天记录">🗑️</button>
          </div>
        </div>

        <div v-else class="no-chat-selected">
          <div class="welcome-message">
            <h2>👋 欢迎使用即时通信</h2>
            <p>从左侧选择用户开始聊天</p>
          </div>
        </div>

        <!-- 消息列表 -->
        <div v-if="activeChat" class="messages-container" ref="messagesContainer">
          <div
            v-for="message in getChatMessages(activeChat.id)"
            :key="message.id"
            class="message"
            :class="{
              'message-sent': message.senderId === currentUser.id,
              'message-received': message.senderId !== currentUser.id
            }"
          >
            <div class="message-content">
              <div class="message-text">{{ message.content }}</div>
              <div class="message-time">
                {{ formatTime(message.timestamp) }}
                <span v-if="message.status === 'sending'">🕐</span>
                <span v-if="message.status === 'sent'">✓</span>
                <span v-if="message.status === 'delivered'">✓✓</span>
                <span v-if="message.status === 'failed'" class="failed">✗</span>
              </div>
            </div>
          </div>
        </div>

        <!-- 消息输入框 -->
        <div v-if="activeChat" class="message-input">
          <textarea
            v-model="newMessage"
            @keydown.enter.exact.prevent="sendMessage"
            placeholder="输入消息... (按Enter发送,Shift+Enter换行)"
            :disabled="!isConnected"
            rows="2"
          ></textarea>
          <button
            @click="sendMessage"
            :disabled="!newMessage.trim() || !isConnected"
            class="send-btn"
          >
            {{ isConnected ? '发送' : '连接中...' }}
          </button>
        </div>
      </div>
    </div>

    <!-- 连接状态提示 -->
    <transition name="fade">
      <div v-if="showConnectionAlert" class="connection-alert" :class="connectionStatus">
        {{ connectionAlertText }}
      </div>
    </transition>
  </div>
</template>

<script setup>
import { ref, reactive, computed, onMounted, onUnmounted, watch, nextTick } from 'vue'
import { v4 as uuidv4 } from 'uuid'

// 用户相关状态
const username = ref('')
const currentUser = ref(null)
const onlineUsers = ref([])

// WebSocket 状态
const socket = ref(null)
const isConnected = ref(false)
const isConnecting = ref(false)
const reconnectAttempts = ref(0)
const maxReconnectAttempts = 5
const reconnectTimer = ref(null)

// 聊天相关状态
const activeChat = ref(null)
const newMessage = ref('')
const searchKeyword = ref('')
const messagesContainer = ref(null)

// 聊天记录存储
const chatMessages = reactive({})
const unreadMessages = reactive({})

// 连接状态提示
const showConnectionAlert = ref(false)
const connectionAlertText = ref('')

// 用户ID映射(模拟用户ID生成)
const generateUserId = () => uuidv4()

// 连接状态
const connectionStatus = computed(() => {
  if (!socket.value) return 'disconnected'
  if (socket.value.readyState === WebSocket.OPEN) return 'connected'
  if (socket.value.readyState === WebSocket.CONNECTING) return 'connecting'
  return 'disconnected'
})

const connectionStatusText = computed(() => {
  switch (connectionStatus.value) {
    case 'connected': return '已连接'
    case 'connecting': return '连接中...'
    case 'disconnected': return '已断开'
    default: return '未知状态'
  }
})

// 过滤用户列表
const filteredUsers = computed(() => {
  return onlineUsers.value.filter(user => 
    user.id !== currentUser.value?.id &&
    user.username.toLowerCase().includes(searchKeyword.value.toLowerCase())
  )
})

// 初始化 WebSocket 连接
const initWebSocket = () => {
  if (socket.value && socket.value.readyState === WebSocket.OPEN) {
    socket.value.close()
  }

  isConnecting.value = true
  
  // 在实际应用中,这里应该是你的 WebSocket 服务器地址
  // const wsUrl = 'ws://your-server.com/ws'
  const wsUrl = 'wss://echo.websocket.org' // 使用公共测试服务器
  
  try {
    socket.value = new WebSocket(wsUrl)
    
    socket.value.onopen = handleOpen
    socket.value.onmessage = handleMessage
    socket.value.onerror = handleError
    socket.value.onclose = handleClose
    
    // 设置连接超时
    setTimeout(() => {
      if (socket.value?.readyState === WebSocket.CONNECTING) {
        showAlert('连接超时,请重试', 'error')
        socket.value.close()
        isConnecting.value = false
      }
    }, 10000)
  } catch (error) {
    console.error('WebSocket 初始化失败:', error)
    showAlert('连接失败,请检查网络', 'error')
    isConnecting.value = false
    scheduleReconnect()
  }
}

// WebSocket 事件处理
const handleOpen = () => {
  console.log('WebSocket 连接已建立')
  isConnected.value = true
  isConnecting.value = false
  reconnectAttempts.value = 0
  showAlert('连接成功', 'success')
  
  // 发送登录消息
  if (currentUser.value) {
    send({
      type: 'login',
      user: currentUser.value
    })
  }
}

const handleMessage = (event) => {
  try {
    const data = JSON.parse(event.data)
    console.log('收到消息:', data)
    
    switch (data.type) {
      case 'login_success':
        handleLoginSuccess(data)
        break
      case 'user_list':
        handleUserList(data)
        break
      case 'message':
        handleIncomingMessage(data)
        break
      case 'message_ack':
        handleMessageAck(data)
        break
      case 'user_status':
        handleUserStatus(data)
        break
      case 'ping':
        send({ type: 'pong' })
        break
      default:
        console.warn('未知消息类型:', data.type)
    }
  } catch (error) {
    console.error('消息解析失败:', error)
  }
}

const handleError = (error) => {
  console.error('WebSocket 错误:', error)
  showAlert('连接出现错误', 'error')
  isConnected.value = false
}

const handleClose = (event) => {
  console.log('WebSocket 连接关闭:', event.code, event.reason)
  isConnected.value = false
  
  if (!event.wasClean) {
    showAlert('连接已断开,正在尝试重连...', 'warning')
    scheduleReconnect()
  }
}

// 消息处理函数
const handleLoginSuccess = (data) => {
  currentUser.value = data.user
  onlineUsers.value = data.users || []
  showAlert('登录成功', 'success')
}

const handleUserList = (data) => {
  onlineUsers.value = data.users.filter(user => user.id !== currentUser.value?.id)
}

const handleIncomingMessage = (data) => {
  const { senderId, content, messageId, timestamp } = data
  
  // 添加到聊天记录
  if (!chatMessages[senderId]) {
    chatMessages[senderId] = []
  }
  
  const message = {
    id: messageId,
    senderId,
    content,
    timestamp: timestamp || Date.now(),
    status: 'delivered'
  }
  
  chatMessages[senderId].push(message)
  
  // 更新未读消息数
  if (activeChat.value?.id !== senderId) {
    if (!unreadMessages[senderId]) {
      unreadMessages[senderId] = 0
    }
    unreadMessages[senderId]++
  }
  
  // 发送消息回执
  send({
    type: 'message_ack',
    messageId,
    receiverId: senderId
  })
  
  // 自动滚动到底部
  scrollToBottom()
}

const handleMessageAck = (data) => {
  const { messageId, receiverId } = data
  const messages = chatMessages[receiverId]
  
  if (messages) {
    const message = messages.find(msg => msg.id === messageId)
    if (message) {
      message.status = 'delivered'
    }
  }
}

const handleUserStatus = (data) => {
  const { userId, online } = data
  const userIndex = onlineUsers.value.findIndex(user => user.id === userId)
  
  if (userIndex !== -1) {
    onlineUsers.value[userIndex].online = online
  }
}

// 发送消息
const send = (data) => {
  if (socket.value?.readyState === WebSocket.OPEN) {
    try {
      socket.value.send(JSON.stringify(data))
    } catch (error) {
      console.error('发送消息失败:', error)
      showAlert('发送失败,请检查连接', 'error')
    }
  } else {
    console.warn('WebSocket 未连接,无法发送消息')
    showAlert('连接已断开,请重新连接', 'error')
  }
}

// 登录
const login = () => {
  if (!username.value.trim()) {
    showAlert('请输入用户名', 'error')
    return
  }
  
  const user = {
    id: generateUserId(),
    username: username.value.trim(),
    online: true
  }
  
  currentUser.value = user
  
  // 初始化用户的聊天记录存储
  chatMessages[user.id] = []
  
  initWebSocket()
}

// 登出
const logout = () => {
  if (socket.value) {
    send({ type: 'logout', userId: currentUser.value.id })
    socket.value.close()
  }
  
  currentUser.value = null
  username.value = ''
  activeChat.value = null
  Object.keys(chatMessages).forEach(key => delete chatMessages[key])
  Object.keys(unreadMessages).forEach(key => delete unreadMessages[key])
}

// 选择聊天
const selectChat = (user) => {
  activeChat.value = user
  
  // 清空该用户的未读消息
  if (unreadMessages[user.id]) {
    unreadMessages[user.id] = 0
  }
  
  // 确保聊天记录存在
  if (!chatMessages[user.id]) {
    chatMessages[user.id] = []
  }
  
  // 滚动到底部
  nextTick(() => scrollToBottom())
}

// 发送消息
const sendMessage = () => {
  if (!newMessage.value.trim() || !activeChat.value || !isConnected.value) return
  
  const messageId = uuidv4()
  const timestamp = Date.now()
  
  const message = {
    id: messageId,
    senderId: currentUser.value.id,
    content: newMessage.value.trim(),
    timestamp,
    status: 'sending'
  }
  
  // 添加到本地聊天记录
  if (!chatMessages[activeChat.value.id]) {
    chatMessages[activeChat.value.id] = []
  }
  chatMessages[activeChat.value.id].push(message)
  
  // 发送到服务器
  send({
    type: 'message',
    messageId,
    receiverId: activeChat.value.id,
    content: newMessage.value.trim(),
    timestamp
  })
  
  // 清空输入框
  newMessage.value = ''
  
  // 滚动到底部
  nextTick(() => scrollToBottom())
  
  // 模拟消息发送状态更新(在实际应用中由服务器回执控制)
  setTimeout(() => {
    const messages = chatMessages[activeChat.value.id]
    const sentMessage = messages.find(msg => msg.id === messageId)
    if (sentMessage) {
      sentMessage.status = isConnected.value ? 'sent' : 'failed'
    }
  }, 1000)
}

// 清空聊天
const clearChat = () => {
  if (activeChat.value && chatMessages[activeChat.value.id]) {
    if (confirm('确定要清空与 ' + activeChat.value.username + ' 的聊天记录吗?')) {
      chatMessages[activeChat.value.id] = []
    }
  }
}

// 重连机制
const scheduleReconnect = () => {
  if (reconnectAttempts.value >= maxReconnectAttempts) {
    showAlert('重连次数超限,请刷新页面', 'error')
    return
  }
  
  if (reconnectTimer.value) {
    clearTimeout(reconnectTimer.value)
  }
  
  reconnectAttempts.value++
  const delay = Math.min(3000 * reconnectAttempts.value, 30000) // 指数退避
  
  reconnectTimer.value = setTimeout(() => {
    if (currentUser.value) {
      console.log(`尝试第 ${reconnectAttempts.value} 次重连...`)
      initWebSocket()
    }
  }, delay)
}

const reconnect = () => {
  if (currentUser.value) {
    reconnectAttempts.value = 0
    initWebSocket()
  }
}

// 工具函数
const scrollToBottom = () => {
  if (messagesContainer.value) {
    messagesContainer.value.scrollTop = messagesContainer.value.scrollHeight
  }
}

const formatTime = (timestamp) => {
  return new Date(timestamp).toLocaleTimeString([], { 
    hour: '2-digit', 
    minute: '2-digit' 
  })
}

const getChatMessages = (userId) => {
  return chatMessages[userId] || []
}

const getLastMessage = (userId) => {
  const messages = chatMessages[userId]
  return messages && messages.length > 0 ? messages[messages.length - 1] : null
}

const getUnreadCount = (userId) => {
  return unreadMessages[userId] || 0
}

const showAlert = (text, type = 'info') => {
  connectionAlertText.value = text
  showConnectionAlert.value = true
  
  setTimeout(() => {
    showConnectionAlert.value = false
  }, 3000)
}

// 心跳检测
let heartbeatInterval = null
const startHeartbeat = () => {
  heartbeatInterval = setInterval(() => {
    if (socket.value?.readyState === WebSocket.OPEN) {
      send({ type: 'ping', timestamp: Date.now() })
    }
  }, 30000) // 30秒一次心跳
}

// 生命周期钩子
onMounted(() => {
  // 恢复上次登录的用户(如果有)
  const savedUser = localStorage.getItem('ws_chat_user')
  if (savedUser) {
    try {
      const user = JSON.parse(savedUser)
      username.value = user.username
    } catch (e) {
      localStorage.removeItem('ws_chat_user')
    }
  }
})

onUnmounted(() => {
  // 清理
  if (socket.value) {
    socket.value.close()
  }
  
  if (reconnectTimer.value) {
    clearTimeout(reconnectTimer.value)
  }
  
  if (heartbeatInterval) {
    clearInterval(heartbeatInterval)
  }
  
  // 保存当前用户
  if (currentUser.value) {
    localStorage.setItem('ws_chat_user', JSON.stringify({
      username: currentUser.value.username
    }))
  }
})

// 监听连接状态变化
watch(connectionStatus, (status) => {
  if (status === 'connected') {
    startHeartbeat()
  } else if (heartbeatInterval) {
    clearInterval(heartbeatInterval)
    heartbeatInterval = null
  }
})
</script>

<style scoped>
.chat-app {
  width: 100%;
  height: 100vh;
  background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
  display: flex;
  align-items: center;
  justify-content: center;
  padding: 20px;
}

.login-container {
  width: 100%;
  max-width: 400px;
}

.login-box {
  background: white;
  padding: 40px;
  border-radius: 20px;
  box-shadow: 0 20px 60px rgba(0,0,0,0.3);
  text-align: center;
}

.login-box h1 {
  margin-bottom: 30px;
  color: #333;
  font-size: 24px;
}

.input-group {
  display: flex;
  gap: 10px;
  margin-bottom: 20px;
}

.input-group input {
  flex: 1;
  padding: 12px 15px;
  border: 2px solid #e0e0e0;
  border-radius: 10px;
  font-size: 16px;
  outline: none;
  transition: border-color 0.3s;
}

.input-group input:focus {
  border-color: #667eea;
}

.input-group input:disabled {
  background: #f5f5f5;
  cursor: not-allowed;
}

.input-group button {
  padding: 12px 25px;
  background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
  color: white;
  border: none;
  border-radius: 10px;
  font-size: 16px;
  font-weight: 600;
  cursor: pointer;
  transition: transform 0.3s;
}

.input-group button:hover:not(:disabled) {
  transform: translateY(-2px);
}

.input-group button:disabled {
  opacity: 0.5;
  cursor: not-allowed;
}

.user-list {
  margin-top: 20px;
  text-align: left;
}

.user-list h3 {
  color: #666;
  margin-bottom: 10px;
  font-size: 14px;
}

.user-tags {
  display: flex;
  flex-wrap: wrap;
  gap: 8px;
}

.user-tag {
  background: #f0f0f0;
  padding: 5px 10px;
  border-radius: 15px;
  font-size: 12px;
  color: #666;
}

.main-container {
  width: 100%;
  height: 90vh;
  max-width: 1200px;
  background: white;
  border-radius: 20px;
  overflow: hidden;
  box-shadow: 0 20px 60px rgba(0,0,0,0.3);
  display: flex;
}

.sidebar {
  width: 300px;
  background: #f8f9fa;
  border-right: 1px solid #e0e0e0;
  display: flex;
  flex-direction: column;
}

.user-info {
  padding: 20px;
  display: flex;
  justify-content: space-between;
  align-items: center;
  border-bottom: 1px solid #e0e0e0;
}

.current-user {
  display: flex;
  align-items: center;
  gap: 10px;
}

.user-avatar {
  width: 40px;
  height: 40px;
  background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
  color: white;
  border-radius: 50%;
  display: flex;
  align-items: center;
  justify-content: center;
  font-weight: bold;
}

.current-user h3 {
  margin: 0;
  font-size: 16px;
  color: #333;
}

.user-status {
  font-size: 12px;
  color: #666;
}

.user-status.online {
  color: #4caf50;
}

.logout-btn {
  padding: 6px 12px;
  background: #ff4757;
  color: white;
  border: none;
  border-radius: 6px;
  font-size: 12px;
  cursor: pointer;
  transition: opacity 0.3s;
}

.logout-btn:hover {
  opacity: 0.9;
}

.search-box {
  padding: 15px 20px;
  border-bottom: 1px solid #e0e0e0;
}

.search-box input {
  width: 100%;
  padding: 10px 15px;
  border: 1px solid #e0e0e0;
  border-radius: 10px;
  font-size: 14px;
  outline: none;
}

.search-box input:focus {
  border-color: #667eea;
}

.chat-list {
  flex: 1;
  overflow-y: auto;
  padding: 20px;
}

.chat-list h4 {
  margin: 0 0 15px 0;
  color: #666;
  font-size: 12px;
  text-transform: uppercase;
  letter-spacing: 1px;
}

.chat-item {
  display: flex;
  align-items: center;
  padding: 12px;
  border-radius: 10px;
  cursor: pointer;
  transition: background 0.3s;
  position: relative;
  margin-bottom: 8px;
}

.chat-item:hover {
  background: #e9ecef;
}

.chat-item.active {
  background: #e3f2fd;
}

.chat-avatar {
  width: 36px;
  height: 36px;
  background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
  color: white;
  border-radius: 50%;
  display: flex;
  align-items: center;
  justify-content: center;
  font-weight: bold;
  margin-right: 12px;
}

.chat-info {
  flex: 1;
  min-width: 0;
}

.chat-name {
  font-weight: 600;
  color: #333;
  margin-bottom: 4px;
  white-space: nowrap;
  overflow: hidden;
  text-overflow: ellipsis;
}

.chat-last-msg {
  font-size: 12px;
  color: #666;
  white-space: nowrap;
  overflow: hidden;
  text-overflow: ellipsis;
}

.chat-status {
  width: 8px;
  height: 8px;
  border-radius: 50%;
  background: #ddd;
  margin-left: 10px;
}

.chat-status.online {
  background: #4caf50;
}

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

.connection-status {
  padding: 15px 20px;
  border-top: 1px solid #e0e0e0;
  display: flex;
  align-items: center;
  gap: 10px;
  font-size: 12px;
  color: #666;
}

.status-indicator {
  width: 8px;
  height: 8px;
  border-radius: 50%;
}

.status-indicator.connected {
  background: #4caf50;
  animation: pulse 2s infinite;
}

.status-indicator.connecting {
  background: #ff9800;
}

.status-indicator.disconnected {
  background: #ff4757;
}

.reconnect-btn {
  margin-left: auto;
  padding: 4px 8px;
  background: #667eea;
  color: white;
  border: none;
  border-radius: 4px;
  font-size: 11px;
  cursor: pointer;
}

.chat-area {
  flex: 1;
  display: flex;
  flex-direction: column;
}

.chat-header {
  padding: 20px;
  border-bottom: 1px solid #e0e0e0;
  display: flex;
  justify-content: space-between;
  align-items: center;
}

.chat-header-info {
  display: flex;
  align-items: center;
  gap: 12px;
}

.chat-status-text {
  font-size: 12px;
  color: #666;
}

.chat-actions button {
  background: none;
  border: none;
  font-size: 18px;
  cursor: pointer;
  opacity: 0.6;
  transition: opacity 0.3s;
}

.chat-actions button:hover {
  opacity: 1;
}

.no-chat-selected {
  flex: 1;
  display: flex;
  align-items: center;
  justify-content: center;
  background: #fafafa;
}

.welcome-message {
  text-align: center;
  color: #666;
}

.welcome-message h2 {
  margin-bottom: 10px;
}

.messages-container {
  flex: 1;
  overflow-y: auto;
  padding: 20px;
  background: #fafafa;
  display: flex;
  flex-direction: column;
  gap: 10px;
}

.message {
  max-width: 70%;
  animation: messageAppear 0.3s ease-out;
}

@keyframes messageAppear {
  from {
    opacity: 0;
    transform: translateY(10px);
  }
  to {
    opacity: 1;
    transform: translateY(0);
  }
}

.message-sent {
  align-self: flex-end;
}

.message-received {
  align-self: flex-start;
}

.message-content {
  padding: 12px 16px;
  border-radius: 18px;
  position: relative;
}

.message-sent .message-content {
  background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
  color: white;
  border-bottom-right-radius: 4px;
}

.message-received .message-content {
  background: white;
  color: #333;
  border-bottom-left-radius: 4px;
  box-shadow: 0 2px 4px rgba(0,0,0,0.1);
}

.message-text {
  word-break: break-word;
  line-height: 1.4;
}

.message-time {
  font-size: 11px;
  opacity: 0.8;
  margin-top: 4px;
  display: flex;
  align-items: center;
  gap: 4px;
}

.message-sent .message-time {
  justify-content: flex-end;
}

.failed {
  color: #ff4757;
}

.message-input {
  padding: 20px;
  border-top: 1px solid #e0e0e0;
  display: flex;
  gap: 10px;
  align-items: flex-end;
}

.message-input textarea {
  flex: 1;
  padding: 12px 15px;
  border: 2px solid #e0e0e0;
  border-radius: 10px;
  font-size: 14px;
  outline: none;
  resize: none;
  font-family: inherit;
  transition: border-color 0.3s;
}

.message-input textarea:focus {
  border-color: #667eea;
}

.message-input textarea:disabled {
  background: #f5f5f5;
  cursor: not-allowed;
}

.send-btn {
  padding: 12px 25px;
  background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
  color: white;
  border: none;
  border-radius: 10px;
  font-size: 14px;
  font-weight: 600;
  cursor: pointer;
  transition: transform 0.3s;
  height: 44px;
}

.send-btn:hover:not(:disabled) {
  transform: translateY(-2px);
}

.send-btn:disabled {
  opacity: 0.5;
  cursor: not-allowed;
}

.connection-alert {
  position: fixed;
  top: 20px;
  left: 50%;
  transform: translateX(-50%);
  padding: 12px 24px;
  border-radius: 10px;
  color: white;
  font-weight: 500;
  z-index: 1000;
  box-shadow: 0 4px 12px rgba(0,0,0,0.15);
}

.connection-alert.connected {
  background: linear-gradient(135deg, #4caf50 0%, #2e7d32 100%);
}

.connection-alert.error {
  background: linear-gradient(135deg, #ff4757 0%, #c44569 100%);
}

.connection-alert.warning {
  background: linear-gradient(135deg, #ff9800 0%, #f57c00 100%);
}

.fade-enter-active,
.fade-leave-active {
  transition: opacity 0.3s, transform 0.3s;
}

.fade-enter-from,
.fade-leave-to {
  opacity: 0;
  transform: translateX(-50%) translateY(-20px);
}

@keyframes pulse {
  0% {
    box-shadow: 0 0 0 0 rgba(76, 175, 80, 0.4);
  }
  70% {
    box-shadow: 0 0 0 6px rgba(76, 175, 80, 0);
  }
  100% {
    box-shadow: 0 0 0 0 rgba(76, 175, 80, 0);
  }
}

@media (max-width: 768px) {
  .main-container {
    height: 100vh;
    border-radius: 0;
  }
  
  .sidebar {
    width: 100%;
  }
  
  .message {
    max-width: 85%;
  }
}
</style>

  

posted @ 2025-12-30 15:42  热心市民~菜先生  阅读(3)  评论(0)    收藏  举报