更新记录

1.0.2(2026-04-30) 下载此版本

已弃坑UTS,为了方便技术研究,遂决定开源,不再积极维护

1.0.1(2026-04-14) 下载此版本

去掉中文说明,防止打包自定义基座失败

1.0.0(2026-04-14) 下载此版本

新增功能

  • ✨ 初始版本发布
  • ✨ 支持连接到 Centrifugo 服务器
  • ✨ 支持 JWT token 认证和匿名连接
  • ✨ 支持订阅和取消订阅频道
  • ✨ 支持发布消息到频道
  • ✨ 支持事件监听机制
  • ✨ 支持调试日志输出
  • ✨ 与 uni 事件总线集成
  • ✨ 提供完整的连接状态管理

技术特性

查看更多

平台兼容性

uni-app(4.41)

Vue2 Vue3 Vue3插件版本 Chrome Safari app-vue app-nvue Android Android插件版本 iOS 鸿蒙
- 1.0.1 - - - - 5.0 1.0.1 × ×
微信小程序 支付宝小程序 抖音小程序 百度小程序 快手小程序 京东小程序 鸿蒙元服务 QQ小程序 飞书小程序 小红书小程序 快应用-华为 快应用-联盟
- - - - - - - - - - - -

uni-app x(4.41)

Chrome Safari Android Android插件版本 iOS 鸿蒙 微信小程序
- - 5.0 1.0.1 × × -

Changbi Centrifugo 插件示例代码

目录

  1. 基础示例
  2. 聊天应用
  3. 实时通知
  4. 数据同步
  5. 多频道订阅

基础示例

最简单的连接和订阅

<script setup>
import { ref } from 'vue'
import * as centrifugo from '@/uni_modules/changbi-centrifugo'

const messages = ref([])

// 连接并订阅
async function start() {
  // 1. 连接
  await centrifugo.connect({
    url: 'ws://your-server.com:8000/connection/websocket?format=json'
  })

  // 2. 订阅
  await centrifugo.subscribe({
    channel: 'news',
    onMessage: (data) => {
      messages.value.push(data.data)
    }
  })
}

start()
</script>

<template>
  <view>
    <view v-for="(msg, i) in messages" :key="i">
      {{ msg }}
    </view>
  </view>
</template>

聊天应用

完整的聊天室实现

<script setup>
import { ref, onMounted, onBeforeUnmount } from 'vue'
import * as centrifugo from '@/uni_modules/changbi-centrifugo'

const roomId = ref('room1')
const currentUser = ref('User' + Date.now())
const messages = ref([])
const inputText = ref('')
const isConnected = ref(false)

// 连接到聊天服务器
async function connect() {
  try {
    await centrifugo.connect({
      url: 'ws://chat-server.com:8000/connection/websocket?format=json',
      debug: true
    })

    // 订阅聊天室
    await centrifugo.subscribe({
      channel: `chat:${roomId.value}`,
      onMessage: (data) => {
        messages.value.push({
          user: data.data.user,
          text: data.data.text,
          time: new Date(data.data.timestamp).toLocaleTimeString()
        })
      },
      onSubscribe: () => {
        console.log('加入聊天室成功')
      }
    })

    isConnected.value = true
  } catch (error) {
    console.error('连接失败:', error)
    uni.showToast({
      title: '连接失败',
      icon: 'none'
    })
  }
}

// 发送消息
async function sendMessage() {
  if (!inputText.value.trim()) {
    return
  }

  try {
    await centrifugo.publish(`chat:${roomId.value}`, {
      user: currentUser.value,
      text: inputText.value,
      timestamp: Date.now()
    })

    inputText.value = ''
  } catch (error) {
    console.error('发送失败:', error)
  }
}

// 离开聊天室
async function leave() {
  try {
    await centrifugo.unsubscribe(`chat:${roomId.value}`)
    await centrifugo.disconnect()
    isConnected.value = false
  } catch (error) {
    console.error('离开失败:', error)
  }
}

onMounted(() => {
  connect()
})

onBeforeUnmount(() => {
  leave()
})
</script>

<template>
  <view class="chat-container">
    <view class="header">
      <text>聊天室: {{ roomId }}</text>
      <text>{{ isConnected ? '在线' : '离线' }}</text>
    </view>

    <scroll-view class="messages" scroll-y>
      <view v-for="(msg, i) in messages" :key="i" class="message">
        <text class="user">{{ msg.user }}</text>
        <text class="time">{{ msg.time }}</text>
        <text class="text">{{ msg.text }}</text>
      </view>
    </scroll-view>

    <view class="input-area">
      <input v-model="inputText" placeholder="输入消息..." @confirm="sendMessage" />
      <button @click="sendMessage">发送</button>
    </view>
  </view>
</template>

<style scoped>
.chat-container {
  display: flex;
  flex-direction: column;
  height: 100vh;
}

.header {
  padding: 20rpx;
  background: #f5f5f5;
  display: flex;
  justify-content: space-between;
}

.messages {
  flex: 1;
  padding: 20rpx;
}

.message {
  margin-bottom: 20rpx;
  padding: 20rpx;
  background: white;
  border-radius: 10rpx;
}

.user {
  font-weight: bold;
  color: #1890ff;
}

.time {
  font-size: 24rpx;
  color: #999;
  margin-left: 20rpx;
}

.text {
  display: block;
  margin-top: 10rpx;
}

.input-area {
  display: flex;
  padding: 20rpx;
  background: white;
  border-top: 1px solid #eee;
}

input {
  flex: 1;
  padding: 20rpx;
  border: 1px solid #ddd;
  border-radius: 10rpx;
  margin-right: 20rpx;
}

button {
  padding: 20rpx 40rpx;
}
</style>

实时通知

用户通知系统

<script setup>
import { ref, onMounted, onBeforeUnmount } from 'vue'
import * as centrifugo from '@/uni_modules/changbi-centrifugo'

const userId = ref('user123')
const notifications = ref([])
const unreadCount = ref(0)

// 连接并订阅通知
async function setupNotifications() {
  try {
    // 连接
    await centrifugo.connect({
      url: 'ws://notify-server.com:8000/connection/websocket?format=json',
      token: getUserToken() // 获取用户 token
    })

    // 订阅用户通知频道
    await centrifugo.subscribe({
      channel: `user:${userId.value}:notifications`,
      onMessage: (data) => {
        // 添加通知
        notifications.value.unshift({
          id: data.data.id,
          title: data.data.title,
          message: data.data.message,
          time: new Date(data.data.timestamp).toLocaleString(),
          read: false
        })

        unreadCount.value++

        // 显示系统通知
        uni.showToast({
          title: data.data.title,
          icon: 'none',
          duration: 2000
        })

        // 播放提示音
        playNotificationSound()
      }
    })

    console.log('通知系统已启动')
  } catch (error) {
    console.error('通知系统启动失败:', error)
  }
}

// 标记为已读
function markAsRead(notificationId) {
  const notification = notifications.value.find(n => n.id === notificationId)
  if (notification && !notification.read) {
    notification.read = true
    unreadCount.value--
  }
}

// 清空所有通知
function clearAll() {
  notifications.value = []
  unreadCount.value = 0
}

// 获取用户 token(示例)
function getUserToken() {
  return uni.getStorageSync('userToken') || ''
}

// 播放提示音(示例)
function playNotificationSound() {
  // 实现播放提示音的逻辑
}

onMounted(() => {
  setupNotifications()
})

onBeforeUnmount(() => {
  centrifugo.disconnect()
})
</script>

<template>
  <view class="notification-container">
    <view class="header">
      <text>通知中心</text>
      <view class="badge" v-if="unreadCount > 0">
        {{ unreadCount }}
      </view>
      <button @click="clearAll">清空</button>
    </view>

    <scroll-view class="list" scroll-y>
      <view 
        v-for="notification in notifications" 
        :key="notification.id"
        :class="['notification-item', { unread: !notification.read }]"
        @click="markAsRead(notification.id)"
      >
        <view class="title">{{ notification.title }}</view>
        <view class="message">{{ notification.message }}</view>
        <view class="time">{{ notification.time }}</view>
      </view>

      <view v-if="notifications.length === 0" class="empty">
        暂无通知
      </view>
    </scroll-view>
  </view>
</template>

<style scoped>
.notification-container {
  height: 100vh;
  background: #f5f5f5;
}

.header {
  display: flex;
  align-items: center;
  padding: 20rpx;
  background: white;
  border-bottom: 1px solid #eee;
}

.badge {
  background: red;
  color: white;
  padding: 4rpx 12rpx;
  border-radius: 20rpx;
  font-size: 24rpx;
  margin-left: 10rpx;
}

.list {
  height: calc(100vh - 100rpx);
}

.notification-item {
  padding: 30rpx;
  background: white;
  margin-bottom: 20rpx;
  border-left: 4rpx solid transparent;
}

.notification-item.unread {
  border-left-color: #1890ff;
  background: #f0f9ff;
}

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

.message {
  font-size: 28rpx;
  color: #666;
  margin-bottom: 10rpx;
}

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

.empty {
  text-align: center;
  padding: 100rpx;
  color: #999;
}
</style>

数据同步

实时数据更新

<script setup>
import { ref, onMounted, onBeforeUnmount } from 'vue'
import * as centrifugo from '@/uni_modules/changbi-centrifugo'

const dataList = ref([])
const lastUpdate = ref(null)

// 连接并订阅数据更新
async function setupDataSync() {
  try {
    await centrifugo.connect({
      url: 'ws://data-server.com:8000/connection/websocket?format=json'
    })

    // 订阅数据更新频道
    await centrifugo.subscribe({
      channel: 'data:updates',
      onMessage: (data) => {
        handleDataUpdate(data.data)
      }
    })

    // 订阅数据删除频道
    await centrifugo.subscribe({
      channel: 'data:deletes',
      onMessage: (data) => {
        handleDataDelete(data.data)
      }
    })

    console.log('数据同步已启动')
  } catch (error) {
    console.error('数据同步启动失败:', error)
  }
}

// 处理数据更新
function handleDataUpdate(update) {
  const index = dataList.value.findIndex(item => item.id === update.id)

  if (index >= 0) {
    // 更新现有数据
    dataList.value[index] = update
  } else {
    // 添加新数据
    dataList.value.push(update)
  }

  lastUpdate.value = new Date().toLocaleTimeString()

  uni.showToast({
    title: '数据已更新',
    icon: 'success',
    duration: 1000
  })
}

// 处理数据删除
function handleDataDelete(deleteInfo) {
  dataList.value = dataList.value.filter(item => item.id !== deleteInfo.id)

  lastUpdate.value = new Date().toLocaleTimeString()

  uni.showToast({
    title: '数据已删除',
    icon: 'success',
    duration: 1000
  })
}

// 手动刷新数据
async function refreshData() {
  try {
    // 从服务器获取最新数据
    const response = await uni.request({
      url: 'https://api.example.com/data',
      method: 'GET'
    })

    dataList.value = response.data
    lastUpdate.value = new Date().toLocaleTimeString()

    uni.showToast({
      title: '刷新成功',
      icon: 'success'
    })
  } catch (error) {
    console.error('刷新失败:', error)
  }
}

onMounted(() => {
  setupDataSync()
  refreshData()
})

onBeforeUnmount(() => {
  centrifugo.disconnect()
})
</script>

<template>
  <view class="data-container">
    <view class="header">
      <text>实时数据</text>
      <text v-if="lastUpdate" class="update-time">
        最后更新: {{ lastUpdate }}
      </text>
      <button @click="refreshData">刷新</button>
    </view>

    <scroll-view class="list" scroll-y>
      <view v-for="item in dataList" :key="item.id" class="data-item">
        <text class="name">{{ item.name }}</text>
        <text class="value">{{ item.value }}</text>
      </view>

      <view v-if="dataList.length === 0" class="empty">
        暂无数据
      </view>
    </scroll-view>
  </view>
</template>

<style scoped>
.data-container {
  height: 100vh;
  background: #f5f5f5;
}

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

.update-time {
  font-size: 24rpx;
  color: #999;
}

.list {
  height: calc(100vh - 100rpx);
}

.data-item {
  display: flex;
  justify-content: space-between;
  padding: 30rpx;
  background: white;
  margin-bottom: 20rpx;
}

.name {
  font-size: 32rpx;
  color: #333;
}

.value {
  font-size: 32rpx;
  color: #1890ff;
  font-weight: bold;
}

.empty {
  text-align: center;
  padding: 100rpx;
  color: #999;
}
</style>

多频道订阅

订阅多个频道

<script setup>
import { ref, onMounted, onBeforeUnmount } from 'vue'
import * as centrifugo from '@/uni_modules/changbi-centrifugo'

const channels = ref([
  { name: 'news', subscribed: false, messages: [] },
  { name: 'sports', subscribed: false, messages: [] },
  { name: 'tech', subscribed: false, messages: [] }
])

// 连接
async function connect() {
  try {
    await centrifugo.connect({
      url: 'ws://your-server.com:8000/connection/websocket?format=json',
      debug: true
    })

    console.log('连接成功')
  } catch (error) {
    console.error('连接失败:', error)
  }
}

// 订阅频道
async function subscribeChannel(channelName) {
  try {
    await centrifugo.subscribe({
      channel: channelName,
      onMessage: (data) => {
        const channel = channels.value.find(c => c.name === channelName)
        if (channel) {
          channel.messages.push({
            data: data.data,
            time: new Date().toLocaleTimeString()
          })
        }
      },
      onSubscribe: () => {
        const channel = channels.value.find(c => c.name === channelName)
        if (channel) {
          channel.subscribed = true
        }
      }
    })

    uni.showToast({
      title: `订阅 ${channelName} 成功`,
      icon: 'success'
    })
  } catch (error) {
    console.error('订阅失败:', error)
  }
}

// 取消订阅
async function unsubscribeChannel(channelName) {
  try {
    await centrifugo.unsubscribe(channelName)

    const channel = channels.value.find(c => c.name === channelName)
    if (channel) {
      channel.subscribed = false
      channel.messages = []
    }

    uni.showToast({
      title: `取消订阅 ${channelName}`,
      icon: 'success'
    })
  } catch (error) {
    console.error('取消订阅失败:', error)
  }
}

// 切换订阅状态
function toggleSubscription(channelName) {
  const channel = channels.value.find(c => c.name === channelName)
  if (channel) {
    if (channel.subscribed) {
      unsubscribeChannel(channelName)
    } else {
      subscribeChannel(channelName)
    }
  }
}

onMounted(() => {
  connect()
})

onBeforeUnmount(() => {
  centrifugo.disconnect()
})
</script>

<template>
  <view class="container">
    <view class="channels">
      <view 
        v-for="channel in channels" 
        :key="channel.name"
        class="channel-card"
      >
        <view class="channel-header">
          <text class="channel-name">{{ channel.name }}</text>
          <button 
            :class="['subscribe-btn', { subscribed: channel.subscribed }]"
            @click="toggleSubscription(channel.name)"
          >
            {{ channel.subscribed ? '取消订阅' : '订阅' }}
          </button>
        </view>

        <scroll-view class="messages" scroll-y>
          <view 
            v-for="(msg, i) in channel.messages" 
            :key="i"
            class="message"
          >
            <text class="time">{{ msg.time }}</text>
            <text class="content">{{ JSON.stringify(msg.data) }}</text>
          </view>

          <view v-if="channel.messages.length === 0" class="empty">
            {{ channel.subscribed ? '暂无消息' : '未订阅' }}
          </view>
        </scroll-view>
      </view>
    </view>
  </view>
</template>

<style scoped>
.container {
  padding: 20rpx;
  background: #f5f5f5;
  min-height: 100vh;
}

.channels {
  display: flex;
  flex-direction: column;
  gap: 20rpx;
}

.channel-card {
  background: white;
  border-radius: 16rpx;
  padding: 30rpx;
  box-shadow: 0 4rpx 12rpx rgba(0, 0, 0, 0.05);
}

.channel-header {
  display: flex;
  justify-content: space-between;
  align-items: center;
  margin-bottom: 20rpx;
}

.channel-name {
  font-size: 36rpx;
  font-weight: bold;
  color: #333;
}

.subscribe-btn {
  padding: 10rpx 30rpx;
  font-size: 28rpx;
  border-radius: 8rpx;
  background: #1890ff;
  color: white;
}

.subscribe-btn.subscribed {
  background: #f5f5f5;
  color: #999;
}

.messages {
  max-height: 400rpx;
}

.message {
  padding: 20rpx;
  background: #f5f5f5;
  border-radius: 8rpx;
  margin-bottom: 10rpx;
}

.time {
  display: block;
  font-size: 24rpx;
  color: #999;
  margin-bottom: 10rpx;
}

.content {
  font-size: 28rpx;
  color: #333;
}

.empty {
  text-align: center;
  padding: 50rpx;
  color: #999;
  font-size: 28rpx;
}
</style>

总结

这些示例展示了 Changbi Centrifugo 插件的各种使用场景:

  1. 基础示例 - 最简单的连接和订阅
  2. 聊天应用 - 实时聊天室实现
  3. 实时通知 - 用户通知系统
  4. 数据同步 - 实时数据更新
  5. 多频道订阅 - 同时订阅多个频道

你可以根据自己的需求修改和扩展这些示例。更多信息请参考:

隐私、权限声明

1. 本插件需要申请的系统权限列表:

android.permission.INTERNET,android.permission.ACCESS_NETWORK_STATE

2. 本插件采集的数据、发送的服务器地址、以及数据用途说明:

插件不采集任何数据

3. 本插件是否包含广告,如包含需详细说明广告表达方式、展示频率:

许可协议

MIT License

Copyright (c) 2024 Changbi Centrifugo Plugin

Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions:

The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software.

THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.