Skip to content

实时聊天应用

一个综合性的实时聊天应用,展示了 Pinia 的高级模式,包括 WebSocket 集成、消息管理、用户在线状态和实时通知。

功能特性

  • 💬 基于 WebSocket 的实时消息
  • 👥 用户在线状态和输入指示器
  • 🏠 多聊天室/频道支持
  • 📁 文件分享和媒体消息
  • 🔍 消息搜索和历史记录
  • 🔔 推送通知
  • 😀 表情反应和@提及
  • 🌙 在线/离线状态处理
  • 📱 响应式设计
  • 🔐 消息加密(可选)

类型定义

typescript
// types/chat.ts
export interface User {
  id: string
  username: string
  displayName: string
  avatar?: string
  status: 'online' | 'away' | 'busy' | 'offline'
  lastSeen: Date
}

export interface Message {
  id: string
  content: string
  type: 'text' | 'image' | 'file' | 'system'
  senderId: string
  roomId: string
  timestamp: Date
  edited?: boolean
  editedAt?: Date
  replyTo?: string
  reactions: Record<string, string[]> // emoji -> user IDs
  mentions: string[] // user IDs
  attachments?: Attachment[]
}

export interface Attachment {
  id: string
  name: string
  size: number
  type: string
  url: string
  thumbnail?: string
}

export interface Room {
  id: string
  name: string
  description?: string
  type: 'public' | 'private' | 'direct'
  members: string[] // user IDs
  admins: string[] // user IDs
  createdAt: Date
  lastActivity: Date
  unreadCount?: number
  lastMessage?: Message
}

export interface TypingIndicator {
  userId: string
  roomId: string
  timestamp: Date
}

export interface ChatState {
  currentUser: User | null
  rooms: Room[]
  messages: Record<string, Message[]> // roomId -> messages
  users: Record<string, User> // userId -> user
  typingIndicators: TypingIndicator[]
  onlineUsers: Set<string>
  currentRoomId: string | null
  isConnected: boolean
  connectionStatus: 'connecting' | 'connected' | 'disconnected' | 'error'
}

WebSocket 服务

typescript
// services/websocket.ts
export class WebSocketService {
  private ws: WebSocket | null = null
  private reconnectAttempts = 0
  private maxReconnectAttempts = 5
  private reconnectDelay = 1000
  private heartbeatInterval: number | null = null
  private messageQueue: any[] = []
  
  private eventHandlers = new Map<string, Function[]>()

  constructor(private url: string, private token: string) {}

  connect(): Promise<void> {
    return new Promise((resolve, reject) => {
      try {
        this.ws = new WebSocket(`${this.url}?token=${this.token}`)
        
        this.ws.onopen = () => {
          console.log('WebSocket 已连接')
          this.reconnectAttempts = 0
          this.startHeartbeat()
          this.flushMessageQueue()
          this.emit('connected')
          resolve()
        }
        
        this.ws.onmessage = (event) => {
          try {
            const data = JSON.parse(event.data)
            this.handleMessage(data)
          } catch (error) {
            console.error('解析 WebSocket 消息失败:', error)
          }
        }
        
        this.ws.onclose = (event) => {
          console.log('WebSocket 已断开:', event.code, event.reason)
          this.stopHeartbeat()
          this.emit('disconnected', { code: event.code, reason: event.reason })
          
          if (event.code !== 1000 && this.reconnectAttempts < this.maxReconnectAttempts) {
            this.scheduleReconnect()
          }
        }
        
        this.ws.onerror = (error) => {
          console.error('WebSocket 错误:', error)
          this.emit('error', error)
          reject(error)
        }
      } catch (error) {
        reject(error)
      }
    })
  }

  disconnect() {
    if (this.ws) {
      this.ws.close(1000, '客户端断开连接')
      this.ws = null
    }
    this.stopHeartbeat()
  }

  send(type: string, data: any) {
    const message = { type, data, timestamp: Date.now() }
    
    if (this.ws?.readyState === WebSocket.OPEN) {
      this.ws.send(JSON.stringify(message))
    } else {
      // 连接恢复时排队发送消息
      this.messageQueue.push(message)
    }
  }

  on(event: string, handler: Function) {
    if (!this.eventHandlers.has(event)) {
      this.eventHandlers.set(event, [])
    }
    this.eventHandlers.get(event)!.push(handler)
  }

  off(event: string, handler: Function) {
    const handlers = this.eventHandlers.get(event)
    if (handlers) {
      const index = handlers.indexOf(handler)
      if (index !== -1) {
        handlers.splice(index, 1)
      }
    }
  }

  private emit(event: string, data?: any) {
    const handlers = this.eventHandlers.get(event)
    if (handlers) {
      handlers.forEach(handler => handler(data))
    }
  }

  private handleMessage(message: any) {
    this.emit(message.type, message.data)
  }

  private startHeartbeat() {
    this.heartbeatInterval = window.setInterval(() => {
      if (this.ws?.readyState === WebSocket.OPEN) {
        this.send('ping', {})
      }
    }, 30000) // 30 秒
  }

  private stopHeartbeat() {
    if (this.heartbeatInterval) {
      clearInterval(this.heartbeatInterval)
      this.heartbeatInterval = null
    }
  }

  private scheduleReconnect() {
    this.reconnectAttempts++
    const delay = this.reconnectDelay * Math.pow(2, this.reconnectAttempts - 1)
    
    setTimeout(() => {
      console.log(`尝试重新连接 (${this.reconnectAttempts}/${this.maxReconnectAttempts})...`)
      this.connect().catch(() => {
        // 重连失败,如果还有尝试次数会继续重试
      })
    }, delay)
  }

  private flushMessageQueue() {
    while (this.messageQueue.length > 0) {
      const message = this.messageQueue.shift()
      if (this.ws?.readyState === WebSocket.OPEN) {
        this.ws.send(JSON.stringify(message))
      } else {
        // 连接又断开了,把消息放回队列
        this.messageQueue.unshift(message)
        break
      }
    }
  }
}

聊天 Store 实现

typescript
// stores/chat.ts
import { defineStore } from 'pinia'
import { WebSocketService } from '@/services/websocket'
import { useAuthStore } from './auth'
import { useNotificationStore } from './notifications'

export const useChatStore = defineStore('chat', () => {
  const authStore = useAuthStore()
  const notificationStore = useNotificationStore()
  
  // 状态
  const currentUser = ref<User | null>(null)
  const rooms = ref<Room[]>([])
  const messages = ref<Record<string, Message[]>>({})
  const users = ref<Record<string, User>>({})
  const typingIndicators = ref<TypingIndicator[]>([])
  const onlineUsers = ref<Set<string>>(new Set())
  const currentRoomId = ref<string | null>(null)
  const isConnected = ref(false)
  const connectionStatus = ref<ChatState['connectionStatus']>('disconnected')
  const searchQuery = ref('')
  const isLoading = ref(false)
  const error = ref<string | null>(null)
  
  // WebSocket 服务
  let wsService: WebSocketService | null = null
  
  // 计算属性
  const currentRoom = computed(() => {
    return currentRoomId.value ? rooms.value.find(r => r.id === currentRoomId.value) : null
  })
  
  const currentMessages = computed(() => {
    return currentRoomId.value ? messages.value[currentRoomId.value] || [] : []
  })
  
  const filteredMessages = computed(() => {
    if (!searchQuery.value) return currentMessages.value
    
    const query = searchQuery.value.toLowerCase()
    return currentMessages.value.filter(message => 
      message.content.toLowerCase().includes(query) ||
      users.value[message.senderId]?.displayName.toLowerCase().includes(query)
    )
  })
  
  const roomsWithUnread = computed(() => {
    return rooms.value.map(room => ({
      ...room,
      unreadCount: getUnreadCount(room.id)
    }))
  })
  
  const currentTypingUsers = computed(() => {
    if (!currentRoomId.value) return []
    
    const now = Date.now()
    return typingIndicators.value
      .filter(indicator => 
        indicator.roomId === currentRoomId.value &&
        indicator.userId !== currentUser.value?.id &&
        now - indicator.timestamp.getTime() < 3000 // 3 秒超时
      )
      .map(indicator => users.value[indicator.userId])
      .filter(Boolean)
  })
  
  const totalUnreadCount = computed(() => {
    return rooms.value.reduce((total, room) => total + getUnreadCount(room.id), 0)
  })

  // 操作方法
  async function initialize() {
    if (!authStore.token) {
      throw new Error('需要身份验证')
    }
    
    connectionStatus.value = 'connecting'
    
    try {
      // 初始化 WebSocket 连接
      wsService = new WebSocketService(
        import.meta.env.VITE_WS_URL || 'ws://localhost:3001',
        authStore.token
      )
      
      setupWebSocketHandlers()
      await wsService.connect()
      
      // 获取初始数据
      await Promise.all([
        fetchRooms(),
        fetchUsers()
      ])
      
      connectionStatus.value = 'connected'
      isConnected.value = true
    } catch (error) {
      connectionStatus.value = 'error'
      throw error
    }
  }
  
  function setupWebSocketHandlers() {
    if (!wsService) return
    
    wsService.on('connected', () => {
      isConnected.value = true
      connectionStatus.value = 'connected'
      error.value = null
    })
    
    wsService.on('disconnected', () => {
      isConnected.value = false
      connectionStatus.value = 'disconnected'
      onlineUsers.value.clear()
    })
    
    wsService.on('error', (err) => {
      error.value = '连接错误'
      connectionStatus.value = 'error'
    })
    
    wsService.on('message', handleNewMessage)
    wsService.on('message_updated', handleMessageUpdate)
    wsService.on('message_deleted', handleMessageDelete)
    wsService.on('user_joined', handleUserJoined)
    wsService.on('user_left', handleUserLeft)
    wsService.on('user_typing', handleUserTyping)
    wsService.on('user_status', handleUserStatus)
    wsService.on('room_updated', handleRoomUpdate)
  }
  
  async function fetchRooms() {
    try {
      const response = await fetch('/api/chat/rooms', {
        headers: {
          'Authorization': `Bearer ${authStore.token}`
        }
      })
      
      if (!response.ok) {
        throw new Error('获取房间列表失败')
      }
      
      const data = await response.json()
      rooms.value = data.map(room => ({
        ...room,
        createdAt: new Date(room.createdAt),
        lastActivity: new Date(room.lastActivity)
      }))
    } catch (err) {
      error.value = err instanceof Error ? err.message : '未知错误'
      throw err
    }
  }
  
  async function fetchUsers() {
    try {
      const response = await fetch('/api/chat/users', {
        headers: {
          'Authorization': `Bearer ${authStore.token}`
        }
      })
      
      if (!response.ok) {
        throw new Error('获取用户列表失败')
      }
      
      const data = await response.json()
      const usersMap = {}
      data.forEach(user => {
        usersMap[user.id] = {
          ...user,
          lastSeen: new Date(user.lastSeen)
        }
      })
      users.value = usersMap
      
      // 设置当前用户
      currentUser.value = users.value[authStore.user?.id] || null
    } catch (err) {
      error.value = err instanceof Error ? err.message : '未知错误'
      throw err
    }
  }
  
  async function fetchMessages(roomId: string, limit = 50, before?: string) {
    try {
      const params = new URLSearchParams({
        limit: limit.toString(),
        ...(before && { before })
      })
      
      const response = await fetch(`/api/chat/rooms/${roomId}/messages?${params}`, {
        headers: {
          'Authorization': `Bearer ${authStore.token}`
        }
      })
      
      if (!response.ok) {
        throw new Error('获取消息失败')
      }
      
      const data = await response.json()
      const roomMessages = data.map(msg => ({
        ...msg,
        timestamp: new Date(msg.timestamp),
        editedAt: msg.editedAt ? new Date(msg.editedAt) : undefined
      }))
      
      if (before) {
        // 在前面添加更早的消息
        messages.value[roomId] = [...roomMessages, ...(messages.value[roomId] || [])]
      } else {
        // 替换为新消息
        messages.value[roomId] = roomMessages
      }
    } catch (err) {
      error.value = err instanceof Error ? err.message : '未知错误'
      throw err
    }
  }
  
  async function joinRoom(roomId: string) {
    currentRoomId.value = roomId
    
    // 如果还没有加载消息,则获取消息
    if (!messages.value[roomId]) {
      await fetchMessages(roomId)
    }
    
    // 标记房间为已读
    markRoomAsRead(roomId)
    
    // 通知服务器
    wsService?.send('join_room', { roomId })
  }
  
  function leaveRoom() {
    if (currentRoomId.value) {
      wsService?.send('leave_room', { roomId: currentRoomId.value })
      currentRoomId.value = null
    }
  }
  
  async function sendMessage(content: string, type: Message['type'] = 'text', replyTo?: string) {
    if (!currentRoomId.value || !currentUser.value) return
    
    const tempId = `temp-${Date.now()}`
    const optimisticMessage: Message = {
      id: tempId,
      content,
      type,
      senderId: currentUser.value.id,
      roomId: currentRoomId.value,
      timestamp: new Date(),
      replyTo,
      reactions: {},
      mentions: extractMentions(content)
    }
    
    // 乐观更新
    if (!messages.value[currentRoomId.value]) {
      messages.value[currentRoomId.value] = []
    }
    messages.value[currentRoomId.value].push(optimisticMessage)
    
    try {
      wsService?.send('send_message', {
        content,
        type,
        roomId: currentRoomId.value,
        replyTo,
        tempId
      })
    } catch (err) {
      // 出错时移除乐观消息
      const roomMessages = messages.value[currentRoomId.value]
      const index = roomMessages.findIndex(m => m.id === tempId)
      if (index !== -1) {
        roomMessages.splice(index, 1)
      }
      throw err
    }
  }
  
  async function editMessage(messageId: string, newContent: string) {
    if (!currentRoomId.value) return
    
    const roomMessages = messages.value[currentRoomId.value]
    const messageIndex = roomMessages.findIndex(m => m.id === messageId)
    
    if (messageIndex === -1) return
    
    const originalMessage = { ...roomMessages[messageIndex] }
    
    // 乐观更新
    roomMessages[messageIndex] = {
      ...originalMessage,
      content: newContent,
      edited: true,
      editedAt: new Date()
    }
    
    try {
      wsService?.send('edit_message', {
        messageId,
        content: newContent,
        roomId: currentRoomId.value
      })
    } catch (err) {
      // 出错时回滚
      roomMessages[messageIndex] = originalMessage
      throw err
    }
  }
  
  async function deleteMessage(messageId: string) {
    if (!currentRoomId.value) return
    
    const roomMessages = messages.value[currentRoomId.value]
    const messageIndex = roomMessages.findIndex(m => m.id === messageId)
    
    if (messageIndex === -1) return
    
    const deletedMessage = roomMessages[messageIndex]
    
    // 乐观更新
    roomMessages.splice(messageIndex, 1)
    
    try {
      wsService?.send('delete_message', {
        messageId,
        roomId: currentRoomId.value
      })
    } catch (err) {
      // 出错时回滚
      roomMessages.splice(messageIndex, 0, deletedMessage)
      throw err
    }
  }
  
  function addReaction(messageId: string, emoji: string) {
    if (!currentRoomId.value || !currentUser.value) return
    
    const roomMessages = messages.value[currentRoomId.value]
    const message = roomMessages.find(m => m.id === messageId)
    
    if (!message) return
    
    if (!message.reactions[emoji]) {
      message.reactions[emoji] = []
    }
    
    const userId = currentUser.value.id
    if (!message.reactions[emoji].includes(userId)) {
      message.reactions[emoji].push(userId)
      
      wsService?.send('add_reaction', {
        messageId,
        emoji,
        roomId: currentRoomId.value
      })
    }
  }
  
  function removeReaction(messageId: string, emoji: string) {
    if (!currentRoomId.value || !currentUser.value) return
    
    const roomMessages = messages.value[currentRoomId.value]
    const message = roomMessages.find(m => m.id === messageId)
    
    if (!message || !message.reactions[emoji]) return
    
    const userId = currentUser.value.id
    const index = message.reactions[emoji].indexOf(userId)
    
    if (index !== -1) {
      message.reactions[emoji].splice(index, 1)
      
      if (message.reactions[emoji].length === 0) {
        delete message.reactions[emoji]
      }
      
      wsService?.send('remove_reaction', {
        messageId,
        emoji,
        roomId: currentRoomId.value
      })
    }
  }
  
  function startTyping() {
    if (!currentRoomId.value) return
    
    wsService?.send('typing_start', {
      roomId: currentRoomId.value
    })
  }
  
  function stopTyping() {
    if (!currentRoomId.value) return
    
    wsService?.send('typing_stop', {
      roomId: currentRoomId.value
    })
  }
  
  function markRoomAsRead(roomId: string) {
    const room = rooms.value.find(r => r.id === roomId)
    if (room) {
      room.unreadCount = 0
    }
    
    wsService?.send('mark_read', { roomId })
  }
  
  function getUnreadCount(roomId: string): number {
    const room = rooms.value.find(r => r.id === roomId)
    return room?.unreadCount || 0
  }
  
  function extractMentions(content: string): string[] {
    const mentionRegex = /@(\w+)/g
    const mentions = []
    let match
    
    while ((match = mentionRegex.exec(content)) !== null) {
      const username = match[1]
      const user = Object.values(users.value).find(u => u.username === username)
      if (user) {
        mentions.push(user.id)
      }
    }
    
    return mentions
  }
  
  // WebSocket 事件处理器
  function handleNewMessage(data: any) {
    const message: Message = {
      ...data,
      timestamp: new Date(data.timestamp)
    }
    
    // 如果存在临时消息,则替换它
    if (data.tempId) {
      const roomMessages = messages.value[message.roomId]
      if (roomMessages) {
        const tempIndex = roomMessages.findIndex(m => m.id === data.tempId)
        if (tempIndex !== -1) {
          roomMessages[tempIndex] = message
          return
        }
      }
    }
    
    // 添加新消息
    if (!messages.value[message.roomId]) {
      messages.value[message.roomId] = []
    }
    messages.value[message.roomId].push(message)
    
    // 更新房间的最后活动时间
    const room = rooms.value.find(r => r.id === message.roomId)
    if (room) {
      room.lastActivity = message.timestamp
      room.lastMessage = message
      
      // 如果不是当前房间且不是自己发送的消息,增加未读计数
      if (message.roomId !== currentRoomId.value && message.senderId !== currentUser.value?.id) {
        room.unreadCount = (room.unreadCount || 0) + 1
      }
    }
    
    // 如果不是当前房间,显示通知
    if (message.roomId !== currentRoomId.value && message.senderId !== currentUser.value?.id) {
      const sender = users.value[message.senderId]
      notificationStore.addNotification({
        title: `${sender?.displayName || '某人'} 在 ${room?.name || '聊天'}`,
        message: message.content,
        type: 'info',
        action: () => joinRoom(message.roomId)
      })
    }
  }
  
  function handleMessageUpdate(data: any) {
    const roomMessages = messages.value[data.roomId]
    if (roomMessages) {
      const messageIndex = roomMessages.findIndex(m => m.id === data.id)
      if (messageIndex !== -1) {
        roomMessages[messageIndex] = {
          ...roomMessages[messageIndex],
          ...data,
          timestamp: new Date(data.timestamp),
          editedAt: data.editedAt ? new Date(data.editedAt) : undefined
        }
      }
    }
  }
  
  function handleMessageDelete(data: any) {
    const roomMessages = messages.value[data.roomId]
    if (roomMessages) {
      const messageIndex = roomMessages.findIndex(m => m.id === data.messageId)
      if (messageIndex !== -1) {
        roomMessages.splice(messageIndex, 1)
      }
    }
  }
  
  function handleUserJoined(data: any) {
    onlineUsers.value.add(data.userId)
    if (users.value[data.userId]) {
      users.value[data.userId].status = 'online'
    }
  }
  
  function handleUserLeft(data: any) {
    onlineUsers.value.delete(data.userId)
    if (users.value[data.userId]) {
      users.value[data.userId].status = 'offline'
      users.value[data.userId].lastSeen = new Date()
    }
  }
  
  function handleUserTyping(data: any) {
    const existingIndex = typingIndicators.value.findIndex(
      indicator => indicator.userId === data.userId && indicator.roomId === data.roomId
    )
    
    const typingIndicator: TypingIndicator = {
      userId: data.userId,
      roomId: data.roomId,
      timestamp: new Date()
    }
    
    if (existingIndex !== -1) {
      typingIndicators.value[existingIndex] = typingIndicator
    } else {
      typingIndicators.value.push(typingIndicator)
    }
    
    // 超时后移除输入指示器
    setTimeout(() => {
      const index = typingIndicators.value.findIndex(
        indicator => indicator.userId === data.userId && indicator.roomId === data.roomId
      )
      if (index !== -1) {
        typingIndicators.value.splice(index, 1)
      }
    }, 3000)
  }
  
  function handleUserStatus(data: any) {
    if (users.value[data.userId]) {
      users.value[data.userId].status = data.status
      if (data.status === 'offline') {
        users.value[data.userId].lastSeen = new Date()
        onlineUsers.value.delete(data.userId)
      } else {
        onlineUsers.value.add(data.userId)
      }
    }
  }
  
  function handleRoomUpdate(data: any) {
    const roomIndex = rooms.value.findIndex(r => r.id === data.id)
    if (roomIndex !== -1) {
      rooms.value[roomIndex] = {
        ...rooms.value[roomIndex],
        ...data,
        createdAt: new Date(data.createdAt),
        lastActivity: new Date(data.lastActivity)
      }
    }
  }
  
  function disconnect() {
    wsService?.disconnect()
    wsService = null
    isConnected.value = false
    connectionStatus.value = 'disconnected'
    onlineUsers.value.clear()
    typingIndicators.value = []
  }
  
  // 组件卸载时清理
  onUnmounted(() => {
    disconnect()
  })
  
  return {
    // 状态
    currentUser: readonly(currentUser),
    rooms: readonly(rooms),
    messages: readonly(messages),
    users: readonly(users),
    typingIndicators: readonly(typingIndicators),
    onlineUsers: readonly(onlineUsers),
    currentRoomId: readonly(currentRoomId),
    isConnected: readonly(isConnected),
    connectionStatus: readonly(connectionStatus),
    searchQuery,
    isLoading: readonly(isLoading),
    error: readonly(error),
    
    // 计算属性
    currentRoom,
    currentMessages,
    filteredMessages,
    roomsWithUnread,
    currentTypingUsers,
    totalUnreadCount,
    
    // 操作方法
    initialize,
    joinRoom,
    leaveRoom,
    sendMessage,
    editMessage,
    deleteMessage,
    addReaction,
    removeReaction,
    startTyping,
    stopTyping,
    markRoomAsRead,
    fetchMessages,
    disconnect
  }
})

组件使用

聊天室组件

vue
<!-- components/ChatRoom.vue -->
<template>
  <div class="chat-room">
    <!-- 头部 -->
    <div class="chat-header">
      <div class="room-info">
        <h3>{{ chatStore.currentRoom?.name }}</h3>
        <span class="member-count">
          {{ chatStore.currentRoom?.members.length }} 成员
        </span>
      </div>
      
      <div class="room-actions">
        <button @click="showSearch = !showSearch">
          🔍 搜索
        </button>
        <button @click="showMembers = !showMembers">
          👥 成员
        </button>
      </div>
    </div>

    <!-- 搜索 -->
    <div v-if="showSearch" class="search-bar">
      <input 
        v-model="chatStore.searchQuery"
        placeholder="搜索消息..."
        class="search-input"
      />
    </div>

    <!-- 消息 -->
    <div 
      ref="messagesContainer"
      class="messages-container"
      @scroll="handleScroll"
    >
      <div 
        v-for="message in chatStore.filteredMessages"
        :key="message.id"
        class="message"
        :class="{
          'own-message': message.senderId === chatStore.currentUser?.id,
          'system-message': message.type === 'system'
        }"
      >
        <MessageItem
          :message="message"
          :user="chatStore.users[message.senderId]"
          @edit="handleEditMessage"
          @delete="handleDeleteMessage"
          @react="handleAddReaction"
          @reply="handleReplyToMessage"
        />
      </div>
      
      <!-- 输入指示器 -->
      <div v-if="chatStore.currentTypingUsers.length > 0" class="typing-indicators">
        <div class="typing-indicator">
          {{ formatTypingUsers(chatStore.currentTypingUsers) }} 
          正在输入...
        </div>
      </div>
    </div>

    <!-- 消息输入 -->
    <div class="message-input-container">
      <div v-if="replyingTo" class="reply-preview">
        <span>回复 {{ chatStore.users[replyingTo.senderId]?.displayName }}</span>
        <button @click="replyingTo = null">✕</button>
      </div>
      
      <div class="message-input">
        <input
          v-model="messageText"
          placeholder="输入消息..."
          @keydown="handleKeyDown"
          @input="handleTyping"
          @blur="handleStopTyping"
        />
        
        <div class="input-actions">
          <button @click="showEmojiPicker = !showEmojiPicker">
            😀
          </button>
          <button @click="handleFileUpload">
            📎
          </button>
          <button 
            @click="handleSendMessage"
            :disabled="!messageText.trim()"
          >
            发送
          </button>
        </div>
      </div>
      
      <!-- 表情选择器 -->
      <EmojiPicker 
        v-if="showEmojiPicker"
        @select="handleEmojiSelect"
        @close="showEmojiPicker = false"
      />
    </div>

    <!-- 成员侧边栏 -->
    <MembersList 
      v-if="showMembers"
      :members="roomMembers"
      @close="showMembers = false"
    />
  </div>
</template>

<script setup lang="ts">
import { ref, computed, nextTick, onMounted, onUnmounted } from 'vue'
import { useChatStore } from '@/stores/chat'
import MessageItem from './MessageItem.vue'
import EmojiPicker from './EmojiPicker.vue'
import MembersList from './MembersList.vue'

const chatStore = useChatStore()

const messageText = ref('')
const replyingTo = ref<Message | null>(null)
const showSearch = ref(false)
const showMembers = ref(false)
const showEmojiPicker = ref(false)
const messagesContainer = ref<HTMLElement>()
const typingTimeout = ref<number | null>(null)
const isTyping = ref(false)

const roomMembers = computed(() => {
  if (!chatStore.currentRoom) return []
  return chatStore.currentRoom.members.map(id => chatStore.users[id]).filter(Boolean)
})

function handleSendMessage() {
  if (!messageText.value.trim()) return
  
  chatStore.sendMessage(
    messageText.value,
    'text',
    replyingTo.value?.id
  )
  
  messageText.value = ''
  replyingTo.value = null
  handleStopTyping()
}

function handleKeyDown(event: KeyboardEvent) {
  if (event.key === 'Enter' && !event.shiftKey) {
    event.preventDefault()
    handleSendMessage()
  }
}

function handleTyping() {
  if (!isTyping.value) {
    isTyping.value = true
    chatStore.startTyping()
  }
  
  // 重置输入超时
  if (typingTimeout.value) {
    clearTimeout(typingTimeout.value)
  }
  
  typingTimeout.value = setTimeout(() => {
    handleStopTyping()
  }, 1000)
}

function handleStopTyping() {
  if (isTyping.value) {
    isTyping.value = false
    chatStore.stopTyping()
  }
  
  if (typingTimeout.value) {
    clearTimeout(typingTimeout.value)
    typingTimeout.value = null
  }
}

function handleEditMessage(messageId: string, newContent: string) {
  chatStore.editMessage(messageId, newContent)
}

function handleDeleteMessage(messageId: string) {
  if (confirm('删除这条消息?')) {
    chatStore.deleteMessage(messageId)
  }
}

function handleAddReaction(messageId: string, emoji: string) {
  chatStore.addReaction(messageId, emoji)
}

function handleReplyToMessage(message: Message) {
  replyingTo.value = message
}

function handleEmojiSelect(emoji: string) {
  messageText.value += emoji
  showEmojiPicker.value = false
}

function handleFileUpload() {
  // 实现文件上传逻辑
  console.log('文件上传功能未实现')
}

function handleScroll() {
  const container = messagesContainer.value
  if (!container) return
  
  // 滚动到顶部时加载更多消息
  if (container.scrollTop === 0 && chatStore.currentMessages.length > 0) {
    const oldestMessage = chatStore.currentMessages[0]
    chatStore.fetchMessages(chatStore.currentRoomId!, 50, oldestMessage.id)
  }
}

function formatTypingUsers(users: User[]): string {
  if (users.length === 1) {
    return users[0].displayName
  } else if (users.length === 2) {
    return `${users[0].displayName} 和 ${users[1].displayName}`
  } else {
    return `${users[0].displayName} 和其他 ${users.length - 1} 人`
  }
}

function scrollToBottom() {
  nextTick(() => {
    if (messagesContainer.value) {
      messagesContainer.value.scrollTop = messagesContainer.value.scrollHeight
    }
  })
}

// 新消息到达时自动滚动到底部
watch(() => chatStore.currentMessages.length, scrollToBottom)

onMounted(() => {
  scrollToBottom()
})

onUnmounted(() => {
  handleStopTyping()
})
</script>

测试

typescript
// tests/stores/chat.test.ts
import { describe, it, expect, beforeEach, vi } from 'vitest'
import { setActivePinia, createPinia } from 'pinia'
import { useChatStore } from '@/stores/chat'
import { WebSocketService } from '@/services/websocket'

// 模拟 WebSocket
vi.mock('@/services/websocket')

describe('聊天 Store', () => {
  beforeEach(() => {
    setActivePinia(createPinia())
    vi.clearAllMocks()
  })

  it('应该正确处理新消息', () => {
    const store = useChatStore()
    
    const message = {
      id: '1',
      content: 'Hello world',
      senderId: 'user1',
      roomId: 'room1',
      timestamp: new Date().toISOString()
    }
    
    store.handleNewMessage(message)
    
    expect(store.messages['room1']).toHaveLength(1)
    expect(store.messages['room1'][0].content).toBe('Hello world')
  })

  it('应该处理乐观消息更新', async () => {
    const store = useChatStore()
    store.currentRoomId = 'room1'
    store.currentUser = { id: 'user1', username: 'test' }
    
    // 模拟 WebSocket 服务
    const mockWs = {
      send: vi.fn()
    }
    store.wsService = mockWs
    
    await store.sendMessage('测试消息')
    
    expect(store.messages['room1']).toHaveLength(1)
    expect(store.messages['room1'][0].content).toBe('测试消息')
    expect(store.messages['room1'][0].id).toMatch(/^temp-/)
    expect(mockWs.send).toHaveBeenCalledWith('send_message', expect.any(Object))
  })

  it('应该正确提取@提及', () => {
    const store = useChatStore()
    
    store.users = {
      'user1': { id: 'user1', username: 'john' },
      'user2': { id: 'user2', username: 'jane' }
    }
    
    const mentions = store.extractMentions('你好 @john 和 @jane!')
    
    expect(mentions).toEqual(['user1', 'user2'])
  })

  it('应该处理输入指示器', () => {
    const store = useChatStore()
    
    store.handleUserTyping({
      userId: 'user1',
      roomId: 'room1'
    })
    
    expect(store.typingIndicators).toHaveLength(1)
    expect(store.typingIndicators[0].userId).toBe('user1')
  })
})

核心功能

1. 实时通信

  • 带自动重连的 WebSocket 连接
  • 实时消息传递
  • 输入指示器和用户在线状态
  • 连接状态监控

2. 消息管理

  • 乐观更新提供即时反馈
  • 消息编辑和删除
  • 表情反应和@提及
  • 文件附件支持

3. 用户体验

  • 未读消息计数
  • 推送通知
  • 消息搜索和历史记录
  • 响应式设计

4. 性能

  • 消息分页和懒加载
  • 高效的状态管理
  • 大型聊天历史记录的内存优化

5. 可靠性

  • 离线消息队列
  • 错误处理和恢复
  • 消息传递确认

这个实时聊天应用展示了如何使用 Pinia 构建复杂的交互式应用,同时处理实时数据、WebSocket 连接,并提供流畅的用户体验。

Released under the MIT License.