uni-app 消息系统

核心知识点

1. 消息系统架构

一个完整的消息系统通常包括以下几个核心部分:

  • 消息发送模块:负责消息的创建和发送
  • 消息存储模块:负责消息的存储和管理
  • 消息接收模块:负责消息的接收和处理
  • 消息展示模块:负责消息的展示和交互
  • 消息状态管理:负责消息状态的更新和同步

2. 消息类型

在应用中,我们可以定义多种类型的消息:

  • 系统消息:由系统发送的通知,如活动通知、系统更新等
  • 用户消息:用户之间的私信或对话
  • 业务消息:与业务相关的消息,如订单状态更新、支付通知等
  • 通知消息:应用内的通知,如点赞、评论、关注等
  • 广告消息:推广或营销相关的消息

3. 消息状态

消息通常有以下几种状态:

  • 未读:消息已发送但未被阅读
  • 已读:消息已被阅读
  • 已删除:消息已被删除
  • 已撤回:消息已被发送者撤回
  • 已过期:消息超过了有效期

4. 消息发送机制

消息发送机制通常包括以下几种方式:

  • 即时发送:消息立即发送并传递给接收者
  • 延时发送:消息在指定时间后发送
  • 批量发送:一次性发送消息给多个接收者
  • 定时发送:按照预设的时间发送消息

5. 消息存储策略

消息存储策略通常包括以下几种:

  • 本地存储:将消息存储在本地数据库中
  • 服务器存储:将消息存储在服务器数据库中
  • 混合存储:结合本地存储和服务器存储的优势
  • 缓存策略:对消息进行缓存,提高读取速度
  • 清理策略:定期清理过期或无用的消息

实用案例

案例:实现一个完整的站内信系统

1. 项目结构

src/
  ├── components/
  │   ├── message-list.vue      # 消息列表组件
  │   ├── message-item.vue      # 消息项组件
  │   └── message-editor.vue    # 消息编辑器组件
  ├── pages/
  │   ├── message/
  │   │   ├── index.vue         # 消息中心页面
  │   │   ├── detail.vue        # 消息详情页面
  │   │   └── chat.vue          # 聊天页面
  │   └── admin/
  │       └── message-manage.vue # 消息管理后台
  ├── services/
  │   └── message-api.js        # 消息API服务
  ├── utils/
  │   └── message-utils.js      # 消息工具函数
  └── store/
      └── modules/
          └── message.js        # 消息状态管理

2. 消息数据模型

// 消息数据模型
const messageModel = {
  id: String,           // 消息ID
  senderId: String,     // 发送者ID
  senderName: String,   // 发送者名称
  receiverId: String,   // 接收者ID
  receiverName: String, // 接收者名称
  type: String,         // 消息类型:system, user, business, notification, ad
  content: String,      // 消息内容
  status: String,       // 消息状态:unread, read, deleted, recalled, expired
  createTime: Date,     // 创建时间
  readTime: Date,       // 阅读时间
  expireTime: Date,     // 过期时间
  relatedId: String,    // 相关业务ID,如订单ID、商品ID等
  relatedType: String,  // 相关业务类型
  attachments: Array,   // 附件列表
  isSystem: Boolean,    // 是否系统消息
  isBroadcast: Boolean, // 是否广播消息
};

3. 消息API服务

// services/message-api.js

// 获取消息列表
export const getMessageList = async (params) => {
  const { page = 1, pageSize = 20, type, status } = params;
  try {
    const res = await uni.request({
      url: 'https://api.example.com/message/list',
      method: 'GET',
      header: {
        'Authorization': `Bearer ${uni.getStorageSync('token')}`
      },
      data: {
        page,
        pageSize,
        type,
        status
      }
    });
    return res.data;
  } catch (error) {
    console.error('获取消息列表失败:', error);
    throw error;
  }
};

// 获取消息详情
export const getMessageDetail = async (messageId) => {
  try {
    const res = await uni.request({
      url: `https://api.example.com/message/${messageId}`,
      method: 'GET',
      header: {
        'Authorization': `Bearer ${uni.getStorageSync('token')}`
      }
    });
    return res.data;
  } catch (error) {
    console.error('获取消息详情失败:', error);
    throw error;
  }
};

// 发送消息
export const sendMessage = async (data) => {
  try {
    const res = await uni.request({
      url: 'https://api.example.com/message/send',
      method: 'POST',
      header: {
        'Authorization': `Bearer ${uni.getStorageSync('token')}`
      },
      data
    });
    return res.data;
  } catch (error) {
    console.error('发送消息失败:', error);
    throw error;
  }
};

// 标记消息为已读
export const markAsRead = async (messageId) => {
  try {
    const res = await uni.request({
      url: `https://api.example.com/message/${messageId}/read`,
      method: 'PUT',
      header: {
        'Authorization': `Bearer ${uni.getStorageSync('token')}`
      }
    });
    return res.data;
  } catch (error) {
    console.error('标记消息已读失败:', error);
    throw error;
  }
};

// 标记所有消息为已读
export const markAllAsRead = async (type) => {
  try {
    const res = await uni.request({
      url: 'https://api.example.com/message/read-all',
      method: 'PUT',
      header: {
        'Authorization': `Bearer ${uni.getStorageSync('token')}`
      },
      data: {
        type
      }
    });
    return res.data;
  } catch (error) {
    console.error('标记所有消息已读失败:', error);
    throw error;
  }
};

// 删除消息
export const deleteMessage = async (messageId) => {
  try {
    const res = await uni.request({
      url: `https://api.example.com/message/${messageId}`,
      method: 'DELETE',
      header: {
        'Authorization': `Bearer ${uni.getStorageSync('token')}`
      }
    });
    return res.data;
  } catch (error) {
    console.error('删除消息失败:', error);
    throw error;
  }
};

// 批量删除消息
export const deleteMessages = async (messageIds) => {
  try {
    const res = await uni.request({
      url: 'https://api.example.com/message/batch-delete',
      method: 'DELETE',
      header: {
        'Authorization': `Bearer ${uni.getStorageSync('token')}`
      },
      data: {
        messageIds
      }
    });
    return res.data;
  } catch (error) {
    console.error('批量删除消息失败:', error);
    throw error;
  }
};

// 撤回消息
export const recallMessage = async (messageId) => {
  try {
    const res = await uni.request({
      url: `https://api.example.com/message/${messageId}/recall`,
      method: 'PUT',
      header: {
        'Authorization': `Bearer ${uni.getStorageSync('token')}`
      }
    });
    return res.data;
  } catch (error) {
    console.error('撤回消息失败:', error);
    throw error;
  }
};

// 获取未读消息数量
export const getUnreadCount = async (type) => {
  try {
    const res = await uni.request({
      url: 'https://api.example.com/message/unread-count',
      method: 'GET',
      header: {
        'Authorization': `Bearer ${uni.getStorageSync('token')}`
      },
      data: {
        type
      }
    });
    return res.data;
  } catch (error) {
    console.error('获取未读消息数量失败:', error);
    throw error;
  }
};

// 发送系统消息(管理员权限)
export const sendSystemMessage = async (data) => {
  try {
    const res = await uni.request({
      url: 'https://api.example.com/message/system/send',
      method: 'POST',
      header: {
        'Authorization': `Bearer ${uni.getStorageSync('token')}`
      },
      data
    });
    return res.data;
  } catch (error) {
    console.error('发送系统消息失败:', error);
    throw error;
  }
};

// 发送广播消息(管理员权限)
export const sendBroadcastMessage = async (data) => {
  try {
    const res = await uni.request({
      url: 'https://api.example.com/message/broadcast/send',
      method: 'POST',
      header: {
        'Authorization': `Bearer ${uni.getStorageSync('token')}`
      },
      data
    });
    return res.data;
  } catch (error) {
    console.error('发送广播消息失败:', error);
    throw error;
  }
};

4. 消息工具函数

// utils/message-utils.js

// 格式化消息时间
export const formatMessageTime = (time) => {
  const now = new Date();
  const messageTime = new Date(time);
  const diff = now - messageTime;
  
  // 小于1分钟
  if (diff < 60 * 1000) {
    return '刚刚';
  }
  
  // 小于1小时
  if (diff < 60 * 60 * 1000) {
    return `${Math.floor(diff / (60 * 1000))}分钟前`;
  }
  
  // 小于24小时
  if (diff < 24 * 60 * 60 * 1000) {
    return `${Math.floor(diff / (60 * 60 * 1000))}小时前`;
  }
  
  // 小于7天
  if (diff < 7 * 24 * 60 * 60 * 1000) {
    return `${Math.floor(diff / (24 * 60 * 60 * 1000))}天前`;
  }
  
  // 其他时间
  return messageTime.toLocaleDateString('zh-CN', {
    year: 'numeric',
    month: '2-digit',
    day: '2-digit'
  });
};

// 获取消息类型图标
export const getMessageTypeIcon = (type) => {
  const iconMap = {
    system: '📢',
    user: '💬',
    business: '📋',
    notification: '🔔',
    ad: '📣'
  };
  
  return iconMap[type] || '📄';
};

// 获取消息类型名称
export const getMessageTypeName = (type) => {
  const typeMap = {
    system: '系统消息',
    user: '私信',
    business: '业务消息',
    notification: '通知',
    ad: '广告'
  };
  
  return typeMap[type] || '消息';
};

// 获取消息状态名称
export const getMessageStatusName = (status) => {
  const statusMap = {
    unread: '未读',
    read: '已读',
    deleted: '已删除',
    recalled: '已撤回',
    expired: '已过期'
  };
  
  return statusMap[status] || '未知';
};

// 检查消息是否为未读
export const isUnreadMessage = (status) => {
  return status === 'unread';
};

// 检查消息是否可以撤回
export const canRecallMessage = (message, currentUserId) => {
  // 只有发送者可以撤回消息,且消息发送时间不超过2分钟
  if (message.senderId !== currentUserId) {
    return false;
  }
  
  const now = new Date();
  const sendTime = new Date(message.createTime);
  const diff = now - sendTime;
  
  return diff < 2 * 60 * 1000; // 2分钟内可以撤回
};

// 生成消息预览
export const generateMessagePreview = (content, maxLength = 20) => {
  if (!content) return '';
  
  // 移除HTML标签
  const plainText = content.replace(/<[^>]+>/g, '');
  
  // 截取预览文本
  if (plainText.length <= maxLength) {
    return plainText;
  }
  
  return plainText.substring(0, maxLength) + '...';
};

// 本地消息存储
export const saveMessageToLocal = (message) => {
  try {
    const messages = uni.getStorageSync('localMessages') || [];
    messages.unshift(message);
    
    // 限制本地存储的消息数量
    if (messages.length > 100) {
      messages.splice(100);
    }
    
    uni.setStorageSync('localMessages', messages);
    return true;
  } catch (error) {
    console.error('保存消息到本地失败:', error);
    return false;
  }
};

// 从本地获取消息
export const getMessagesFromLocal = () => {
  try {
    return uni.getStorageSync('localMessages') || [];
  } catch (error) {
    console.error('从本地获取消息失败:', error);
    return [];
  }
};

// 清空本地消息
export const clearLocalMessages = () => {
  try {
    uni.removeStorageSync('localMessages');
    return true;
  } catch (error) {
    console.error('清空本地消息失败:', error);
    return false;
  }
};

5. 消息状态管理

// store/modules/message.js
import { getMessageList, getUnreadCount, markAsRead, deleteMessage } from '@/services/message-api';

const state = {
  messages: [],
  unreadCount: 0,
  loading: false,
  error: null,
  currentPage: 1,
  hasMore: true,
  refreshing: false
};

const mutations = {
  SET_MESSAGES(state, messages) {
    state.messages = messages;
  },
  ADD_MESSAGES(state, messages) {
    state.messages = [...state.messages, ...messages];
  },
  UPDATE_MESSAGE(state, updatedMessage) {
    const index = state.messages.findIndex(msg => msg.id === updatedMessage.id);
    if (index !== -1) {
      state.messages.splice(index, 1, updatedMessage);
    }
  },
  REMOVE_MESSAGE(state, messageId) {
    state.messages = state.messages.filter(msg => msg.id !== messageId);
  },
  SET_UNREAD_COUNT(state, count) {
    state.unreadCount = count;
  },
  SET_LOADING(state, loading) {
    state.loading = loading;
  },
  SET_ERROR(state, error) {
    state.error = error;
  },
  SET_CURRENT_PAGE(state, page) {
    state.currentPage = page;
  },
  SET_HAS_MORE(state, hasMore) {
    state.hasMore = hasMore;
  },
  SET_REFRESHING(state, refreshing) {
    state.refreshing = refreshing;
  },
  RESET_STATE(state) {
    state.messages = [];
    state.currentPage = 1;
    state.hasMore = true;
  }
};

const actions = {
  async fetchMessages({ commit, state }, params) {
    commit('SET_LOADING', true);
    commit('SET_ERROR', null);
    
    try {
      const result = await getMessageList({
        page: state.currentPage,
        pageSize: 20,
        ...params
      });
      
      if (result.code === 0) {
        const messages = result.data.list || [];
        
        if (state.currentPage === 1) {
          commit('SET_MESSAGES', messages);
        } else {
          commit('ADD_MESSAGES', messages);
        }
        
        commit('SET_HAS_MORE', messages.length === 20);
        commit('SET_CURRENT_PAGE', state.currentPage + 1);
      } else {
        commit('SET_ERROR', result.message);
      }
    } catch (error) {
      commit('SET_ERROR', error.message);
    } finally {
      commit('SET_LOADING', false);
      commit('SET_REFRESHING', false);
    }
  },
  
  async refreshMessages({ commit, dispatch }, params) {
    commit('RESET_STATE');
    commit('SET_REFRESHING', true);
    await dispatch('fetchMessages', params);
  },
  
  async fetchUnreadCount({ commit }, type) {
    try {
      const result = await getUnreadCount(type);
      if (result.code === 0) {
        commit('SET_UNREAD_COUNT', result.data.count || 0);
      }
    } catch (error) {
      console.error('获取未读消息数量失败:', error);
    }
  },
  
  async markMessageAsRead({ commit }, messageId) {
    try {
      const result = await markAsRead(messageId);
      if (result.code === 0) {
        commit('UPDATE_MESSAGE', {
          id: messageId,
          status: 'read',
          readTime: new Date()
        });
        // 更新未读消息数量
        commit('SET_UNREAD_COUNT', state => state.unreadCount - 1);
      }
      return result;
    } catch (error) {
      console.error('标记消息已读失败:', error);
      throw error;
    }
  },
  
  async deleteMessage({ commit }, messageId) {
    try {
      const result = await deleteMessage(messageId);
      if (result.code === 0) {
        commit('REMOVE_MESSAGE', messageId);
      }
      return result;
    } catch (error) {
      console.error('删除消息失败:', error);
      throw error;
    }
  },
  
  addMessage({ commit }, message) {
    commit('ADD_MESSAGES', [message]);
    if (message.status === 'unread') {
      commit('SET_UNREAD_COUNT', state => state.unreadCount + 1);
    }
  }
};

const getters = {
  messages: state => state.messages,
  unreadCount: state => state.unreadCount,
  loading: state => state.loading,
  error: state => state.error,
  hasMore: state => state.hasMore,
  refreshing: state => state.refreshing,
  unreadMessages: state => state.messages.filter(msg => msg.status === 'unread'),
  systemMessages: state => state.messages.filter(msg => msg.type === 'system'),
  userMessages: state => state.messages.filter(msg => msg.type === 'user'),
  businessMessages: state => state.messages.filter(msg => msg.type === 'business'),
  notificationMessages: state => state.messages.filter(msg => msg.type === 'notification'),
  adMessages: state => state.messages.filter(msg => msg.type === 'ad')
};

export default {
  namespaced: true,
  state,
  mutations,
  actions,
  getters
};

6. 消息列表组件

<!-- components/message-list.vue -->
<template>
  <view class="message-list">
    <message-item 
      v-for="message in messages" 
      :key="message.id" 
      :message="message"
      @click="handleMessageClick(message)"
      @mark-read="handleMarkRead(message.id)"
      @delete="handleDeleteMessage(message.id)"
    />
    
    <view v-if="loading && messages.length === 0" class="loading">
      加载中...
    </view>
    
    <view v-if="!loading && messages.length === 0" class="empty">
      暂无消息
    </view>
    
    <view v-if="loading && messages.length > 0" class="loading-more">
      加载更多...
    </view>
    
    <view v-if="!loading && !hasMore && messages.length > 0" class="no-more">
      没有更多消息了
    </view>
  </view>
</template>

<script>
import MessageItem from './message-item';

export default {
  name: 'MessageList',
  components: {
    MessageItem
  },
  props: {
    messages: {
      type: Array,
      default: () => []
    },
    loading: {
      type: Boolean,
      default: false
    },
    hasMore: {
      type: Boolean,
      default: true
    }
  },
  methods: {
    handleMessageClick(message) {
      this.$emit('message-click', message);
    },
    
    handleMarkRead(messageId) {
      this.$emit('mark-read', messageId);
    },
    
    handleDeleteMessage(messageId) {
      this.$emit('delete', messageId);
    }
  }
};
</script>

<style scoped>
.message-list {
  padding: 16rpx;
}

.loading,
.empty,
.loading-more,
.no-more {
  text-align: center;
  padding: 32rpx 0;
  color: #999;
  font-size: 24rpx;
}
</style>

7. 消息项组件

<!-- components/message-item.vue -->
<template>
  <view class="message-item" :class="{ 'unread': isUnread }">
    <view class="message-icon">
      {{ messageTypeIcon }}
    </view>
    
    <view class="message-content">
      <view class="message-header">
        <text class="message-sender">{{ message.senderName || '系统' }}</text>
        <text class="message-time">{{ formattedTime }}</text>
        <view v-if="isUnread" class="unread-badge"></view>
      </view>
      
      <text class="message-text">{{ messagePreview }}</text>
      
      <view v-if="message.type === 'user'" class="message-footer">
        <text class="message-type">{{ messageType }}</text>
      </view>
    </view>
    
    <view class="message-actions">
      <text 
        v-if="isUnread" 
        class="action-btn read-btn" 
        @click.stop="handleMarkRead"
      >
        标为已读
      </text>
      <text class="action-btn delete-btn" @click.stop="handleDelete">
        删除
      </text>
    </view>
  </view>
</template>

<script>
import {
  formatMessageTime,
  getMessageTypeIcon,
  getMessageTypeName,
  isUnreadMessage,
  generateMessagePreview
} from '@/utils/message-utils';

export default {
  name: 'MessageItem',
  props: {
    message: {
      type: Object,
      required: true
    }
  },
  computed: {
    formattedTime() {
      return formatMessageTime(this.message.createTime);
    },
    
    messageTypeIcon() {
      return getMessageTypeIcon(this.message.type);
    },
    
    messageType() {
      return getMessageTypeName(this.message.type);
    },
    
    isUnread() {
      return isUnreadMessage(this.message.status);
    },
    
    messagePreview() {
      return generateMessagePreview(this.message.content);
    }
  },
  methods: {
    handleMarkRead() {
      this.$emit('mark-read', this.message.id);
    },
    
    handleDelete() {
      uni.showModal({
        title: '确认删除',
        content: '确定要删除这条消息吗?',
        success: (res) => {
          if (res.confirm) {
            this.$emit('delete', this.message.id);
          }
        }
      });
    }
  }
};
</script>

<style scoped>
.message-item {
  display: flex;
  padding: 16rpx;
  background-color: white;
  border-radius: 8rpx;
  margin-bottom: 16rpx;
  box-shadow: 0 2rpx 4rpx rgba(0, 0, 0, 0.1);
}

.message-item.unread {
  background-color: #f5f9ff;
}

.message-icon {
  width: 64rpx;
  height: 64rpx;
  border-radius: 50%;
  background-color: #f0f0f0;
  display: flex;
  align-items: center;
  justify-content: center;
  font-size: 32rpx;
  margin-right: 16rpx;
  flex-shrink: 0;
}

.message-content {
  flex: 1;
  min-width: 0;
}

.message-header {
  display: flex;
  align-items: center;
  margin-bottom: 8rpx;
  position: relative;
}

.message-sender {
  font-size: 28rpx;
  font-weight: bold;
  margin-right: 16rpx;
  flex-shrink: 0;
}

.message-time {
  font-size: 22rpx;
  color: #999;
  flex-shrink: 0;
}

.unread-badge {
  position: absolute;
  right: 0;
  top: 0;
  width: 12rpx;
  height: 12rpx;
  border-radius: 50%;
  background-color: #ff3b30;
}

.message-text {
  font-size: 24rpx;
  color: #666;
  line-height: 1.4;
  margin-bottom: 8rpx;
  overflow: hidden;
  text-overflow: ellipsis;
  display: -webkit-box;
  -webkit-line-clamp: 2;
  -webkit-box-orient: vertical;
}

.message-footer {
  display: flex;
  align-items: center;
}

.message-type {
  font-size: 20rpx;
  color: #999;
  background-color: #f0f0f0;
  padding: 2rpx 12rpx;
  border-radius: 12rpx;
}

.message-actions {
  display: flex;
  flex-direction: column;
  align-items: flex-end;
  margin-left: 16rpx;
  flex-shrink: 0;
}

.action-btn {
  font-size: 20rpx;
  padding: 4rpx 12rpx;
  border-radius: 12rpx;
  margin-bottom: 8rpx;
}

.read-btn {
  background-color: #e3f2fd;
  color: #2196f3;
}

.delete-btn {
  background-color: #ffebee;
  color: #f44336;
}
</style>

8. 消息中心页面

<!-- pages/message/index.vue -->
<template>
  <view class="message-center">
    <view class="header">
      <text class="title">消息中心</text>
      <view class="header-actions">
        <text class="action-btn" @click="handleMarkAllRead">全部已读</text>
        <text class="action-btn" @click="handleDeleteAll">全部删除</text>
      </view>
    </view>
    
    <view class="tab-bar">
      <view 
        v-for="tab in tabs" 
        :key="tab.type"
        class="tab-item"
        :class="{ 'active': activeTab === tab.type }"
        @click="switchTab(tab.type)"
      >
        <text class="tab-text">{{ tab.name }}</text>
        <view 
          v-if="tab.badge && getUnreadCount(tab.type) > 0" 
          class="tab-badge"
        >
          {{ getUnreadCount(tab.type) > 99 ? '99+' : getUnreadCount(tab.type) }}
        </view>
      </view>
    </view>
    
    <scroll-view 
      class="message-scroll" 
      scroll-y 
      @scrolltolower="handleLoadMore"
      refresher-enabled 
      @refresherpulling="handleRefresh"
      @refresherrefresh="handleRefresh"
    >
      <message-list 
        :messages="filteredMessages"
        :loading="loading"
        :has-more="hasMore"
        @message-click="handleMessageClick"
        @mark-read="handleMarkRead"
        @delete="handleDeleteMessage"
      />
    </scroll-view>
  </view>
</template>

<script>
import MessageList from '@/components/message-list';
import { mapState, mapGetters, mapActions } from 'vuex';
import { markAllAsRead, deleteMessages } from '@/services/message-api';

export default {
  name: 'MessageCenter',
  components: {
    MessageList
  },
  data() {
    return {
      activeTab: 'all',
      tabs: [
        { type: 'all', name: '全部', badge: true },
        { type: 'system', name: '系统', badge: true },
        { type: 'user', name: '私信', badge: true },
        { type: 'business', name: '业务', badge: true },
        { type: 'notification', name: '通知', badge: true }
      ]
    };
  },
  computed: {
    ...mapState('message', ['loading', 'hasMore']),
    ...mapGetters('message', [
      'messages',
      'unreadMessages',
      'systemMessages',
      'userMessages',
      'businessMessages',
      'notificationMessages'
    ]),
    
    filteredMessages() {
      switch (this.activeTab) {
        case 'system':
          return this.systemMessages;
        case 'user':
          return this.userMessages;
        case 'business':
          return this.businessMessages;
        case 'notification':
          return this.notificationMessages;
        default:
          return this.messages;
      }
    }
  },
  onLoad() {
    this.fetchMessages();
    this.fetchUnreadCount();
  },
  methods: {
    ...mapActions('message', ['fetchMessages', 'refreshMessages', 'markMessageAsRead', 'deleteMessage', 'fetchUnreadCount']),
    
    switchTab(type) {
      this.activeTab = type;
      this.refreshMessages({ type: type === 'all' ? '' : type });
    },
    
    handleRefresh() {
      this.refreshMessages({ type: this.activeTab === 'all' ? '' : this.activeTab });
    },
    
    handleLoadMore() {
      if (this.hasMore && !this.loading) {
        this.fetchMessages({ type: this.activeTab === 'all' ? '' : this.activeTab });
      }
    },
    
    handleMessageClick(message) {
      // 标记消息为已读
      if (message.status === 'unread') {
        this.markMessageAsRead(message.id);
      }
      
      // 根据消息类型跳转到不同页面
      if (message.type === 'user') {
        uni.navigateTo({
          url: `/pages/message/chat?userId=${message.senderId}&userName=${message.senderName}`
        });
      } else {
        uni.navigateTo({
          url: `/pages/message/detail?id=${message.id}`
        });
      }
    },
    
    handleMarkRead(messageId) {
      this.markMessageAsRead(messageId);
    },
    
    handleDeleteMessage(messageId) {
      this.deleteMessage(messageId);
    },
    
    async handleMarkAllRead() {
      try {
        await markAllAsRead(this.activeTab === 'all' ? '' : this.activeTab);
        uni.showToast({ title: '已全部标记为已读', icon: 'success' });
        // 刷新消息列表
        this.refreshMessages({ type: this.activeTab === 'all' ? '' : this.activeTab });
      } catch (error) {
        uni.showToast({ title: '操作失败', icon: 'none' });
      }
    },
    
    async handleDeleteAll() {
      uni.showModal({
        title: '确认删除',
        content: '确定要删除所有消息吗?',
        success: async (res) => {
          if (res.confirm) {
            try {
              const messageIds = this.filteredMessages.map(msg => msg.id);
              if (messageIds.length > 0) {
                await deleteMessages(messageIds);
                uni.showToast({ title: '已全部删除', icon: 'success' });
                // 刷新消息列表
                this.refreshMessages({ type: this.activeTab === 'all' ? '' : this.activeTab });
              } else {
                uni.showToast({ title: '没有消息可删除', icon: 'none' });
              }
            } catch (error) {
              uni.showToast({ title: '操作失败', icon: 'none' });
            }
          }
        }
      });
    },
    
    getUnreadCount(type) {
      switch (type) {
        case 'system':
          return this.systemMessages.filter(msg => msg.status === 'unread').length;
        case 'user':
          return this.userMessages.filter(msg => msg.status === 'unread').length;
        case 'business':
          return this.businessMessages.filter(msg => msg.status === 'unread').length;
        case 'notification':
          return this.notificationMessages.filter(msg => msg.status === 'unread').length;
        default:
          return this.unreadMessages.length;
      }
    }
  }
};
</script>

<style scoped>
.message-center {
  min-height: 100vh;
  background-color: #f5f5f5;
}

.header {
  display: flex;
  justify-content: space-between;
  align-items: center;
  padding: 16rpx 32rpx;
  background-color: white;
  box-shadow: 0 2rpx 4rpx rgba(0, 0, 0, 0.1);
}

.title {
  font-size: 32rpx;
  font-weight: bold;
}

.header-actions {
  display: flex;
}

.action-btn {
  font-size: 24rpx;
  color: #007AFF;
  margin-left: 24rpx;
}

.tab-bar {
  display: flex;
  background-color: white;
  margin-bottom: 16rpx;
  box-shadow: 0 2rpx 4rpx rgba(0, 0, 0, 0.1);
  overflow-x: auto;
}

.tab-item {
  flex: 1;
  display: flex;
  align-items: center;
  justify-content: center;
  padding: 20rpx 0;
  position: relative;
  min-width: 120rpx;
}

.tab-item.active {
  color: #007AFF;
  border-bottom: 4rpx solid #007AFF;
}

.tab-text {
  font-size: 24rpx;
}

.tab-badge {
  position: absolute;
  top: 12rpx;
  right: 20rpx;
  background-color: #ff3b30;
  color: white;
  font-size: 16rpx;
  padding: 2rpx 8rpx;
  border-radius: 12rpx;
  min-width: 24rpx;
  text-align: center;
}

.message-scroll {
  flex: 1;
  height: calc(100vh - 200rpx);
}
</style>

9. 消息详情页面

<!-- pages/message/detail.vue -->
<template>
  <view class="message-detail">
    <view class="header">
      <text class="back-btn" @click="handleBack">←</text>
      <text class="title">消息详情</text>
    </view>
    
    <view class="message-content" v-if="message">
      <view class="message-header">
        <view class="sender-info">
          <view class="sender-icon">{{ messageTypeIcon }}</view>
          <view class="sender-details">
            <text class="sender-name">{{ message.senderName || '系统' }}</text>
            <text class="send-time">{{ formatDateTime(message.createTime) }}</text>
          </view>
        </view>
        <view class="message-type-tag">{{ messageType }}</view>
      </view>
      
      <view class="message-body">
        <rich-text :nodes="message.content"></rich-text>
      </view>
      
      <view v-if="message.relatedId" class="message-footer">
        <view class="related-info">
          <text class="related-label">相关内容:</text>
          <text class="related-value">{{ message.relatedType }}</text>
        </view>
        <button class="related-btn" @click="handleRelatedClick">查看详情</button>
      </view>
    </view>
    
    <view v-else class="loading">加载中...</view>
  </view>
</template>

<script>
import { getMessageDetail } from '@/services/message-api';
import {
  getMessageTypeIcon,
  getMessageTypeName
} from '@/utils/message-utils';

export default {
  name: 'MessageDetail',
  data() {
    return {
      message: null,
      loading: true
    };
  },
  onLoad(options) {
    this.messageId = options.id;
    this.fetchMessageDetail();
  },
  computed: {
    messageTypeIcon() {
      if (!this.message) return '';
      return getMessageTypeIcon(this.message.type);
    },
    
    messageType() {
      if (!this.message) return '';
      return getMessageTypeName(this.message.type);
    }
  },
  methods: {
    async fetchMessageDetail() {
      this.loading = true;
      try {
        const result = await getMessageDetail(this.messageId);
        if (result.code === 0) {
          this.message = result.data;
        } else {
          uni.showToast({ title: '获取消息详情失败', icon: 'none' });
        }
      } catch (error) {
        uni.showToast({ title: '获取消息详情失败', icon: 'none' });
      } finally {
        this.loading = false;
      }
    },
    
    handleBack() {
      uni.navigateBack();
    },
    
    handleRelatedClick() {
      // 根据相关类型跳转到不同页面
      if (this.message.relatedType === 'order') {
        uni.navigateTo({
          url: `/pages/order/detail?id=${this.message.relatedId}`
        });
      } else if (this.message.relatedType === 'product') {
        uni.navigateTo({
          url: `/pages/product/detail?id=${this.message.relatedId}`
        });
      } else if (this.message.relatedType === 'activity') {
        uni.navigateTo({
          url: `/pages/activity/detail?id=${this.message.relatedId}`
        });
      }
    },
    
    formatDateTime(time) {
      const date = new Date(time);
      return date.toLocaleString('zh-CN', {
        year: 'numeric',
        month: '2-digit',
        day: '2-digit',
        hour: '2-digit',
        minute: '2-digit'
      });
    }
  }
};
</script>

<style scoped>
.message-detail {
  min-height: 100vh;
  background-color: #f5f5f5;
}

.header {
  display: flex;
  align-items: center;
  padding: 16rpx 32rpx;
  background-color: white;
  box-shadow: 0 2rpx 4rpx rgba(0, 0, 0, 0.1);
}

.back-btn {
  font-size: 32rpx;
  margin-right: 24rpx;
}

.title {
  font-size: 32rpx;
  font-weight: bold;
}

.message-content {
  background-color: white;
  margin: 16rpx;
  border-radius: 8rpx;
  padding: 24rpx;
  box-shadow: 0 2rpx 4rpx rgba(0, 0, 0, 0.1);
}

.message-header {
  display: flex;
  justify-content: space-between;
  align-items: flex-start;
  margin-bottom: 24rpx;
  padding-bottom: 24rpx;
  border-bottom: 1rpx solid #f0f0f0;
}

.sender-info {
  display: flex;
  align-items: center;
}

.sender-icon {
  width: 64rpx;
  height: 64rpx;
  border-radius: 50%;
  background-color: #f0f0f0;
  display: flex;
  align-items: center;
  justify-content: center;
  font-size: 32rpx;
  margin-right: 16rpx;
}

.sender-details {
  flex: 1;
}

.sender-name {
  display: block;
  font-size: 28rpx;
  font-weight: bold;
  margin-bottom: 8rpx;
}

.send-time {
  font-size: 20rpx;
  color: #999;
}

.message-type-tag {
  background-color: #f0f0f0;
  padding: 4rpx 16rpx;
  border-radius: 16rpx;
  font-size: 20rpx;
  color: #666;
}

.message-body {
  margin-bottom: 24rpx;
  line-height: 1.6;
}

.message-body :deep() {
  font-size: 28rpx;
  line-height: 1.6;
}

.message-body :deep(img) {
  max-width: 100%;
  height: auto;
  margin: 16rpx 0;
  border-radius: 8rpx;
}

.message-footer {
  padding-top: 24rpx;
  border-top: 1rpx solid #f0f0f0;
}

.related-info {
  margin-bottom: 16rpx;
}

.related-label {
  font-size: 24rpx;
  color: #666;
}

.related-value {
  font-size: 24rpx;
  color: #333;
  font-weight: bold;
}

.related-btn {
  width: 100%;
  padding: 16rpx;
  background-color: #007AFF;
  color: white;
  border-radius: 8rpx;
  font-size: 24rpx;
}

.loading {
  text-align: center;
  padding: 64rpx 0;
  color: #999;
}
</style>

10. 聊天页面

<!-- pages/message/chat.vue -->
<template>
  <view class="chat-page">
    <view class="header">
      <text class="back-btn" @click="handleBack">←</text>
      <text class="title">{{ userName }}</text>
      <view class="header-actions">
        <text class="action-btn">更多</text>
      </view>
    </view>
    
    <scroll-view 
      class="message-list" 
      scroll-y 
      :scroll-top="scrollTop"
      @scrolltolower="handleLoadMore"
      @scroll="handleScroll"
    >
      <view 
        v-for="(message, index) in messages" 
        :key="message.id"
        class="message-wrapper"
        :class="{ 'own': message.senderId === currentUserId }"
      >
        <view class="message-content">
          <view class="message-bubble" :class="{ 'own': message.senderId === currentUserId }">
            <text class="message-text">{{ message.content }}</text>
          </view>
          <text class="message-time">{{ formatMessageTime(message.createTime) }}</text>
        </view>
      </view>
      
      <view v-if="loading" class="loading">加载中...</view>
    </scroll-view>
    
    <view class="input-area">
      <input 
        v-model="inputContent" 
        class="input" 
        placeholder="输入消息..."
        @keyup.enter="handleSend"
      />
      <button class="send-btn" @click="handleSend">发送</button>
    </view>
  </view>
</template>

<script>
import { sendMessage, getMessageList } from '@/services/message-api';
import { formatMessageTime } from '@/utils/message-utils';
import { getCurrentUser } from '@/utils/auth';

export default {
  name: 'ChatPage',
  data() {
    return {
      userId: '',
      userName: '',
      currentUserId: '',
      messages: [],
      inputContent: '',
      loading: false,
      scrollTop: 0,
      hasMore: true,
      currentPage: 1
    };
  },
  onLoad(options) {
    this.userId = options.userId;
    this.userName = options.userName || '用户';
    const currentUser = getCurrentUser();
    this.currentUserId = currentUser?.id || '';
    this.fetchMessages();
  },
  methods: {
    async fetchMessages() {
      if (!this.hasMore || this.loading) return;
      
      this.loading = true;
      try {
        const result = await getMessageList({
          page: this.currentPage,
          pageSize: 20,
          type: 'user',
          receiverId: this.userId
        });
        
        if (result.code === 0) {
          const newMessages = result.data.list || [];
          this.messages = [...newMessages, ...this.messages];
          this.hasMore = newMessages.length === 20;
          this.currentPage++;
        }
      } catch (error) {
        console.error('获取消息失败:', error);
      } finally {
        this.loading = false;
      }
    },
    
    async handleSend() {
      if (!this.inputContent.trim()) return;
      
      const content = this.inputContent.trim();
      this.inputContent = '';
      
      // 临时添加消息到列表
      const tempMessage = {
        id: `temp_${Date.now()}`,
        senderId: this.currentUserId,
        senderName: getCurrentUser()?.nickname || getCurrentUser()?.username || '我',
        receiverId: this.userId,
        receiverName: this.userName,
        type: 'user',
        content,
        status: 'unread',
        createTime: new Date().toISOString()
      };
      
      this.messages.push(tempMessage);
      this.scrollToBottom();
      
      // 发送消息
      try {
        const result = await sendMessage({
          receiverId: this.userId,
          receiverName: this.userName,
          type: 'user',
          content
        });
        
        if (result.code === 0) {
          // 替换临时消息
          const index = this.messages.findIndex(msg => msg.id === tempMessage.id);
          if (index !== -1) {
            this.messages.splice(index, 1, result.data);
          }
        } else {
          uni.showToast({ title: '发送失败', icon: 'none' });
          // 移除临时消息
          const index = this.messages.findIndex(msg => msg.id === tempMessage.id);
          if (index !== -1) {
            this.messages.splice(index, 1);
          }
        }
      } catch (error) {
        uni.showToast({ title: '发送失败', icon: 'none' });
        // 移除临时消息
        const index = this.messages.findIndex(msg => msg.id === tempMessage.id);
        if (index !== -1) {
          this.messages.splice(index, 1);
        }
      }
    },
    
    handleLoadMore() {
      this.fetchMessages();
    },
    
    handleScroll(e) {
      this.scrollTop = e.detail.scrollTop;
    },
    
    scrollToBottom() {
      setTimeout(() => {
        uni.createSelectorQuery().select('.message-list').boundingClientRect(rect => {
          this.scrollTop = rect.height;
        }).exec();
      }, 100);
    },
    
    formatMessageTime(time) {
      return formatMessageTime(time);
    },
    
    handleBack() {
      uni.navigateBack();
    }
  }
};
</script>

<style scoped>
.chat-page {
  min-height: 100vh;
  background-color: #f5f5f5;
}

.header {
  display: flex;
  align-items: center;
  padding: 16rpx 32rpx;
  background-color: white;
  box-shadow: 0 2rpx 4rpx rgba(0, 0, 0, 0.1);
}

.back-btn {
  font-size: 32rpx;
  margin-right: 24rpx;
}

.title {
  flex: 1;
  font-size: 32rpx;
  font-weight: bold;
  text-align: center;
}

.header-actions {
  margin-left: 24rpx;
}

.action-btn {
  font-size: 24rpx;
  color: #007AFF;
}

.message-list {
  flex: 1;
  height: calc(100vh - 200rpx);
  padding: 16rpx;
}

.message-wrapper {
  display: flex;
  margin-bottom: 24rpx;
}

.message-wrapper.own {
  justify-content: flex-end;
}

.message-content {
  max-width: 70%;
}

.message-bubble {
  padding: 16rpx;
  border-radius: 16rpx;
  margin-bottom: 8rpx;
  background-color: white;
  box-shadow: 0 2rpx 4rpx rgba(0, 0, 0, 0.1);
}

.message-bubble.own {
  background-color: #007AFF;
  color: white;
}

.message-text {
  font-size: 24rpx;
  line-height: 1.4;
}

.message-bubble.own .message-text {
  color: white;
}

.message-time {
  font-size: 18rpx;
  color: #999;
  text-align: center;
}

.input-area {
  display: flex;
  padding: 16rpx;
  background-color: white;
  box-shadow: 0 -2rpx 4rpx rgba(0, 0, 0, 0.1);
}

.input {
  flex: 1;
  padding: 16rpx;
  border: 1rpx solid #ddd;
  border-radius: 24rpx;
  font-size: 24rpx;
  margin-right: 16rpx;
}

.send-btn {
  padding: 0 32rpx;
  background-color: #007AFF;
  color: white;
  border-radius: 24rpx;
  font-size: 24rpx;
  display: flex;
  align-items: center;
  justify-content: center;
}

.loading {
  text-align: center;
  padding: 16rpx;
  color: #999;
  font-size: 20rpx;
}
</style>

学习目标

通过本教程的学习,你应该能够:

  1. 理解消息系统的基本架构和核心概念

    • 掌握消息系统的分层架构
    • 了解不同类型的消息和状态
    • 熟悉消息发送和存储机制
  2. 掌握 uni-app 中消息系统的实现方法

    • 学会设计消息数据模型
    • 掌握消息API的设计和实现
    • 学会开发消息列表、详情等核心组件
    • 了解消息状态管理的实现方法
  3. 实现完整的消息系统功能

    • 掌握消息的发送和接收功能
    • 了解消息的存储和管理
    • 学会实现消息的已读、删除等操作
    • 掌握聊天功能的实现方法
  4. 应用消息系统最佳实践

    • 掌握消息推送和通知的实现
    • 了解消息的缓存和清理策略
    • 学会优化消息系统的性能
  5. 构建安全可靠的消息系统

    • 掌握消息内容的安全处理
    • 了解用户隐私的保护措施
    • 学会防止消息系统的滥用

通过本教程的学习,你将能够在 uni-app 中构建功能完善、性能优化的消息系统,为应用提供完整的站内信和聊天功能。

« 上一篇 uni-app 用户管理 下一篇 » uni-app 通知系统