返回笔记首页

16.3 实时推送场景

主题配置

一、技术实现方案

1.1 应用场景架构

plain
实时推送系统
  ├── 订单模块
  │   ├── 订单状态更新
  │   ├── 物流信息推送
  │   └── 支付结果通知
  │
  ├── 数据看板
  │   ├── 实时销售数据
  │   ├── 用户活跃统计
  │   └── 系统性能监控
  │
  ├── 在线状态
  │   ├── 用户在线检测
  │   ├── 状态同步
  │   └── 离线通知
  │
  └── 通知中心
      ├── 系统通知
      ├── 业务通知
      └── 通知管理

1.2 技术选型

  • 实时通信: WebSocket
  • 状态管理: Vue 3 Reactive
  • 数据可视化: ECharts
  • 通知: Notification API + Toast
  • 持久化: IndexedDB

二、订单状态推送

2.1 订单推送管理器

order-push-manager.js

javascript
import { ref, reactive } from 'vue'

export class OrderPushManager {
  constructor() {
    this.orders = reactive(new Map())
    this.callbacks = {
      onCreate: [],
      onUpdate: [],
      onStatusChange: []
    }
  }

  // 订单状态映射
  statusMap = {
    'pending': { text: '待支付', color: '#e6a23c' },
    'paid': { text: '已支付', color: '#409eff' },
    'processing': { text: '处理中', color: '#409eff' },
    'shipped': { text: '已发货', color: '#67c23a' },
    'delivered': { text: '已送达', color: '#67c23a' },
    'completed': { text: '已完成', color: '#909399' },
    'cancelled': { text: '已取消', color: '#f56c6c' }
  }

  // 处理订单推送
  handleOrderPush(data) {
    const { type, order } = data

    switch (type) {
      case 'order_created':
        this.createOrder(order)
        break
      case 'order_updated':
        this.updateOrder(order)
        break
      case 'status_changed':
        this.changeOrderStatus(order.id, order.status, order.statusTime)
        break
      case 'logistics_updated':
        this.updateLogistics(order.id, order.logistics)
        break
    }
  }

  // 创建订单
  createOrder(order) {
    const newOrder = {
      id: order.id,
      orderNo: order.orderNo,
      customerId: order.customerId,
      customerName: order.customerName,
      amount: order.amount,
      status: order.status,
      createTime: order.createTime || Date.now(),
      statusHistory: [{
        status: order.status,
        time: order.createTime || Date.now()
      }],
      logistics: null
    }

    this.orders.set(order.id, newOrder)
    this.emit('onCreate', newOrder)
  }

  // 更新订单
  updateOrder(order) {
    const existOrder = this.orders.get(order.id)
    if (!existOrder) return

    Object.assign(existOrder, order)
    this.emit('onUpdate', existOrder)
  }

  // 改变订单状态
  changeOrderStatus(orderId, newStatus, statusTime) {
    const order = this.orders.get(orderId)
    if (!order) return

    const oldStatus = order.status
    order.status = newStatus

    // 添加状态历史
    order.statusHistory.push({
      status: newStatus,
      time: statusTime || Date.now()
    })

    this.emit('onStatusChange', {
      order,
      oldStatus,
      newStatus
    })
  }

  // 更新物流信息
  updateLogistics(orderId, logistics) {
    const order = this.orders.get(orderId)
    if (!order) return

    order.logistics = {
      company: logistics.company,
      trackingNo: logistics.trackingNo,
      status: logistics.status,
      location: logistics.location,
      updateTime: logistics.updateTime || Date.now(),
      history: logistics.history || []
    }

    this.emit('onUpdate', order)
  }

  // 获取订单
  getOrder(orderId) {
    return this.orders.get(orderId)
  }

  // 获取所有订单
  getAllOrders() {
    return Array.from(this.orders.values())
      .sort((a, b) => b.createTime - a.createTime)
  }

  // 获取订单状态信息
  getStatusInfo(status) {
    return this.statusMap[status] || { text: '未知', color: '#909399' }
  }

  // 订阅事件
  on(event, callback) {
    if (this.callbacks[event]) {
      this.callbacks[event].push(callback)
    }
  }

  // 触发事件
  emit(event, data) {
    if (this.callbacks[event]) {
      this.callbacks[event].forEach(callback => callback(data))
    }
  }
}

export const orderPushManager = new OrderPushManager()

2.2 订单推送组件

OrderPushView.vue

vue
<script setup>
import { ref, computed, onMounted } from 'vue'
import { orderPushManager } from './order-push-manager.js'

const ws = ref(null)
const isConnected = ref(false)
const orders = ref([])
const selectedOrderId = ref(null)
const showNotification = ref(true)

// 当前选中的订单
const selectedOrder = computed(() => {
  if (!selectedOrderId.value) return null
  return orderPushManager.getOrder(selectedOrderId.value)
})

// 初始化WebSocket
const initWebSocket = () => {
  ws.value = new WebSocket('ws://localhost:8080/order-push')

  ws.value.onopen = () => {
    isConnected.value = true
    console.log('订单推送连接成功')
  }

  ws.value.onmessage = (event) => {
    const data = JSON.parse(event.data)
    handlePushMessage(data)
  }

  ws.value.onclose = () => {
    isConnected.value = false
    console.log('订单推送连接关闭')
    setTimeout(initWebSocket, 3000)
  }

  ws.value.onerror = (error) => {
    console.error('WebSocket错误:', error)
  }
}

// 处理推送消息
const handlePushMessage = (data) => {
  orderPushManager.handleOrderPush(data)
  loadOrders()

  // 显示通知
  if (showNotification.value) {
    showOrderNotification(data)
  }
}

// 显示订单通知
const showOrderNotification = (data) => {
  const { type, order } = data
  let title = ''
  let body = ''

  switch (type) {
    case 'order_created':
      title = '新订单'
      body = `订单号: ${order.orderNo},金额: ¥${order.amount}`
      break
    case 'status_changed':
      const statusInfo = orderPushManager.getStatusInfo(order.status)
      title = '订单状态更新'
      body = `订单${order.orderNo}状态变更为: ${statusInfo.text}`
      break
    case 'logistics_updated':
      title = '物流更新'
      body = `订单${order.orderNo}: ${order.logistics.location}`
      break
  }

  if (title && 'Notification' in window && Notification.permission === 'granted') {
    new Notification(title, { body })
  }
}

// 加载订单列表
const loadOrders = () => {
  orders.value = orderPushManager.getAllOrders()
}

// 选择订单
const selectOrder = (orderId) => {
  selectedOrderId.value = orderId
}

// 格式化时间
const formatTime = (timestamp) => {
  return new Date(timestamp).toLocaleString('zh-CN')
}

// 格式化金额
const formatAmount = (amount) => {
  return `¥${amount.toFixed(2)}`
}

// 模拟订单推送
const simulateOrderPush = () => {
  const types = ['order_created', 'status_changed', 'logistics_updated']
  const type = types[Math.floor(Math.random() * types.length)]

  const orderId = `order_${Date.now()}`

  let data = {
    type: type,
    order: {
      id: orderId,
      orderNo: `ORD${Date.now()}`,
      customerId: 'cust_001',
      customerName: '张三',
      amount: Math.random() * 1000,
      status: 'pending',
      createTime: Date.now()
    }
  }

  if (type === 'status_changed') {
    const statuses = ['paid', 'processing', 'shipped', 'delivered']
    data.order.status = statuses[Math.floor(Math.random() * statuses.length)]
  }

  if (type === 'logistics_updated') {
    data.order.logistics = {
      company: '顺丰速运',
      trackingNo: 'SF' + Date.now(),
      location: '上海市浦东新区',
      status: '运输中'
    }
  }

  handlePushMessage(data)
}

// 请求通知权限
const requestNotificationPermission = () => {
  if ('Notification' in window && Notification.permission === 'default') {
    Notification.requestPermission()
  }
}

onMounted(() => {
  initWebSocket()
  requestNotificationPermission()

  // 监听订单变化
  orderPushManager.on('onCreate', loadOrders)
  orderPushManager.on('onUpdate', loadOrders)
  orderPushManager.on('onStatusChange', (data) => {
    loadOrders()
    console.log('订单状态变化:', data)
  })
})
</script>

<template>
  <div class="order-push-view">
    <div class="header">
      <h2>订单实时推送</h2>
      <div class="header-actions">
        <button @click="simulateOrderPush" class="simulate-btn">
          模拟推送
        </button>
        <div class="connection-badge" :class="{ connected: isConnected }">
          {{ isConnected ? '已连接' : '未连接' }}
        </div>
      </div>
    </div>

    <div class="content">
      <!-- 左侧订单列表 -->
      <div class="order-list">
        <div class="list-header">
          <h3>订单列表 ({{ orders.length }})</h3>
          <label class="notification-toggle">
            <input type="checkbox" v-model="showNotification" />
            <span>开启通知</span>
          </label>
        </div>

        <div class="list-items">
          <div
            v-for="order in orders"
            :key="order.id"
            :class="['order-item', { active: order.id === selectedOrderId }]"
            @click="selectOrder(order.id)"
          >
            <div class="order-header">
              <span class="order-no">{{ order.orderNo }}</span>
              <span class="order-amount">{{ formatAmount(order.amount) }}</span>
            </div>
            <div class="order-info">
              <span class="customer-name">{{ order.customerName }}</span>
              <span
                class="order-status"
                :style="{ color: orderPushManager.getStatusInfo(order.status).color }"
              >
                {{ orderPushManager.getStatusInfo(order.status).text }}
              </span>
            </div>
            <div class="order-time">
              {{ formatTime(order.createTime) }}
            </div>
          </div>

          <div v-if="orders.length === 0" class="empty-list">
            <div class="empty-icon">📦</div>
            <div class="empty-text">暂无订单</div>
          </div>
        </div>
      </div>

      <!-- 右侧订单详情 -->
      <div class="order-detail">
        <div v-if="!selectedOrder" class="empty-detail">
          <div class="empty-icon">📋</div>
          <div class="empty-text">请选择一个订单查看详情</div>
        </div>

        <div v-else class="detail-content">
          <div class="detail-section">
            <h3>订单信息</h3>
            <div class="info-grid">
              <div class="info-item">
                <label>订单号:</label>
                <span>{{ selectedOrder.orderNo }}</span>
              </div>
              <div class="info-item">
                <label>客户:</label>
                <span>{{ selectedOrder.customerName }}</span>
              </div>
              <div class="info-item">
                <label>金额:</label>
                <span class="amount">{{ formatAmount(selectedOrder.amount) }}</span>
              </div>
              <div class="info-item">
                <label>当前状态:</label>
                <span
                  class="status-badge"
                  :style="{
                    background: orderPushManager.getStatusInfo(selectedOrder.status).color + '20',
                    color: orderPushManager.getStatusInfo(selectedOrder.status).color
                  }"
                >
                  {{ orderPushManager.getStatusInfo(selectedOrder.status).text }}
                </span>
              </div>
            </div>
          </div>

          <div class="detail-section">
            <h3>状态历史</h3>
            <div class="status-timeline">
              <div
                v-for="(item, index) in selectedOrder.statusHistory"
                :key="index"
                class="timeline-item"
              >
                <div class="timeline-dot"></div>
                <div class="timeline-content">
                  <div class="timeline-status">
                    {{ orderPushManager.getStatusInfo(item.status).text }}
                  </div>
                  <div class="timeline-time">
                    {{ formatTime(item.time) }}
                  </div>
                </div>
              </div>
            </div>
          </div>

          <div v-if="selectedOrder.logistics" class="detail-section">
            <h3>物流信息</h3>
            <div class="logistics-info">
              <div class="logistics-item">
                <label>物流公司:</label>
                <span>{{ selectedOrder.logistics.company }}</span>
              </div>
              <div class="logistics-item">
                <label>运单号:</label>
                <span>{{ selectedOrder.logistics.trackingNo }}</span>
              </div>
              <div class="logistics-item">
                <label>当前位置:</label>
                <span>{{ selectedOrder.logistics.location }}</span>
              </div>
              <div class="logistics-item">
                <label>状态:</label>
                <span>{{ selectedOrder.logistics.status }}</span>
              </div>
            </div>
          </div>
        </div>
      </div>
    </div>
  </div>
</template>

<style scoped>
.order-push-view {
  height: 100vh;
  display: flex;
  flex-direction: column;
  background: #f5f7fa;
}

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

.header h2 {
  margin: 0;
  font-size: 20px;
  color: #303133;
}

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

.simulate-btn {
  padding: 8px 20px;
  background: #409eff;
  color: white;
  border: none;
  border-radius: 4px;
  cursor: pointer;
  font-size: 14px;
}

.simulate-btn:hover {
  background: #66b1ff;
}

.connection-badge {
  padding: 6px 16px;
  border-radius: 20px;
  font-size: 13px;
  background: #f56c6c;
  color: white;
}

.connection-badge.connected {
  background: #67c23a;
}

.content {
  flex: 1;
  display: flex;
  overflow: hidden;
}

/* 订单列表 */
.order-list {
  width: 350px;
  background: white;
  border-right: 1px solid #e4e7ed;
  display: flex;
  flex-direction: column;
}

.list-header {
  padding: 16px;
  border-bottom: 1px solid #e4e7ed;
  display: flex;
  justify-content: space-between;
  align-items: center;
}

.list-header h3 {
  margin: 0;
  font-size: 16px;
  color: #303133;
}

.notification-toggle {
  display: flex;
  align-items: center;
  gap: 6px;
  font-size: 13px;
  color: #606266;
  cursor: pointer;
}

.list-items {
  flex: 1;
  overflow-y: auto;
}

.order-item {
  padding: 16px;
  border-bottom: 1px solid #f0f0f0;
  cursor: pointer;
  transition: background 0.2s;
}

.order-item:hover {
  background: #f5f7fa;
}

.order-item.active {
  background: #ecf5ff;
  border-left: 3px solid #409eff;
}

.order-header {
  display: flex;
  justify-content: space-between;
  align-items: center;
  margin-bottom: 8px;
}

.order-no {
  font-size: 14px;
  font-weight: 600;
  color: #303133;
}

.order-amount {
  font-size: 16px;
  font-weight: 700;
  color: #f56c6c;
}

.order-info {
  display: flex;
  justify-content: space-between;
  align-items: center;
  margin-bottom: 6px;
}

.customer-name {
  font-size: 13px;
  color: #606266;
}

.order-status {
  font-size: 12px;
  font-weight: 600;
}

.order-time {
  font-size: 12px;
  color: #c0c4cc;
}

.empty-list {
  display: flex;
  flex-direction: column;
  align-items: center;
  justify-content: center;
  height: 300px;
  color: #909399;
}

.empty-icon {
  font-size: 48px;
  margin-bottom: 12px;
}

.empty-text {
  font-size: 14px;
}

/* 订单详情 */
.order-detail {
  flex: 1;
  background: white;
  overflow-y: auto;
}

.empty-detail {
  display: flex;
  flex-direction: column;
  align-items: center;
  justify-content: center;
  height: 100%;
  color: #909399;
}

.detail-content {
  padding: 24px;
}

.detail-section {
  margin-bottom: 32px;
}

.detail-section:last-child {
  margin-bottom: 0;
}

.detail-section h3 {
  margin: 0 0 16px 0;
  font-size: 16px;
  color: #303133;
  padding-bottom: 12px;
  border-bottom: 2px solid #f0f0f0;
}

.info-grid {
  display: grid;
  grid-template-columns: repeat(2, 1fr);
  gap: 16px;
}

.info-item {
  display: flex;
  flex-direction: column;
  gap: 6px;
}

.info-item label {
  font-size: 13px;
  color: #909399;
}

.info-item span {
  font-size: 14px;
  color: #303133;
}

.info-item .amount {
  font-size: 20px;
  font-weight: 700;
  color: #f56c6c;
}

.status-badge {
  display: inline-block;
  padding: 4px 12px;
  border-radius: 12px;
  font-size: 13px;
  font-weight: 600;
}

/* 状态时间线 */
.status-timeline {
  position: relative;
  padding-left: 24px;
}

.timeline-item {
  position: relative;
  padding-bottom: 24px;
}

.timeline-item:last-child {
  padding-bottom: 0;
}

.timeline-item::before {
  content: '';
  position: absolute;
  left: -18px;
  top: 8px;
  bottom: -16px;
  width: 2px;
  background: #e4e7ed;
}

.timeline-item:last-child::before {
  display: none;
}

.timeline-dot {
  position: absolute;
  left: -24px;
  top: 0;
  width: 12px;
  height: 12px;
  border-radius: 50%;
  background: #409eff;
  border: 2px solid white;
  box-shadow: 0 0 0 2px #409eff;
}

.timeline-status {
  font-size: 14px;
  font-weight: 600;
  color: #303133;
  margin-bottom: 4px;
}

.timeline-time {
  font-size: 12px;
  color: #909399;
}

/* 物流信息 */
.logistics-info {
  display: grid;
  grid-template-columns: repeat(2, 1fr);
  gap: 16px;
}

.logistics-item {
  display: flex;
  flex-direction: column;
  gap: 6px;
}

.logistics-item label {
  font-size: 13px;
  color: #909399;
}

.logistics-item span {
  font-size: 14px;
  color: #303133;
}
</style>

三、实时数据看板

3.1 数据看板管理器

dashboard-manager.js

javascript
import { ref, reactive } from 'vue'

export class DashboardManager {
  constructor() {
    this.metrics = reactive({
      sales: {
        today: 0,
        yesterday: 0,
        trend: []
      },
      orders: {
        total: 0,
        pending: 0,
        completed: 0,
        trend: []
      },
      users: {
        online: 0,
        total: 0,
        newToday: 0,
        trend: []
      },
      performance: {
        cpu: 0,
        memory: 0,
        qps: 0,
        responseTime: 0
      }
    })

    this.updateQueue = []
    this.isProcessing = false
  }

  // 处理数据推送
  handleDataPush(data) {
    const { type, value, timestamp } = data

    switch (type) {
      case 'sales':
        this.updateSales(value)
        break
      case 'order':
        this.updateOrders(value)
        break
      case 'user':
        this.updateUsers(value)
        break
      case 'performance':
        this.updatePerformance(value)
        break
    }
  }

  // 更新销售数据
  updateSales(data) {
    this.metrics.sales.today = data.today || this.metrics.sales.today
    this.metrics.sales.yesterday = data.yesterday || this.metrics.sales.yesterday

    if (data.amount) {
      this.metrics.sales.trend.push({
        time: new Date().toLocaleTimeString(),
        value: data.amount
      })

      // 保留最近30个点
      if (this.metrics.sales.trend.length > 30) {
        this.metrics.sales.trend.shift()
      }
    }
  }

  // 更新订单数据
  updateOrders(data) {
    this.metrics.orders.total = data.total || this.metrics.orders.total
    this.metrics.orders.pending = data.pending || this.metrics.orders.pending
    this.metrics.orders.completed = data.completed || this.metrics.orders.completed

    if (data.count) {
      this.metrics.orders.trend.push({
        time: new Date().toLocaleTimeString(),
        value: data.count
      })

      if (this.metrics.orders.trend.length > 30) {
        this.metrics.orders.trend.shift()
      }
    }
  }

  // 更新用户数据
  updateUsers(data) {
    this.metrics.users.online = data.online || this.metrics.users.online
    this.metrics.users.total = data.total || this.metrics.users.total
    this.metrics.users.newToday = data.newToday || this.metrics.users.newToday

    if (data.onlineCount !== undefined) {
      this.metrics.users.trend.push({
        time: new Date().toLocaleTimeString(),
        value: data.onlineCount
      })

      if (this.metrics.users.trend.length > 30) {
        this.metrics.users.trend.shift()
      }
    }
  }

  // 更新性能数据
  updatePerformance(data) {
    this.metrics.performance.cpu = data.cpu || this.metrics.performance.cpu
    this.metrics.performance.memory = data.memory || this.metrics.performance.memory
    this.metrics.performance.qps = data.qps || this.metrics.performance.qps
    this.metrics.performance.responseTime = data.responseTime || this.metrics.performance.responseTime
  }

  // 批量更新(优化性能)
  batchUpdate(updates) {
    this.updateQueue.push(...updates)

    if (!this.isProcessing) {
      this.processQueue()
    }
  }

  // 处理更新队列
  async processQueue() {
    this.isProcessing = true

    while (this.updateQueue.length > 0) {
      const batch = this.updateQueue.splice(0, 10)

      batch.forEach(update => {
        this.handleDataPush(update)
      })

      // 等待下一帧
      await new Promise(resolve => requestAnimationFrame(resolve))
    }

    this.isProcessing = false
  }

  // 获取指标数据
  getMetrics() {
    return this.metrics
  }
}

export const dashboardManager = new DashboardManager()

3.2 实时数据看板组件

RealtimeDashboard.vue

vue
<script setup>
import { ref, computed, onMounted, onUnmounted } from 'vue'
import { dashboardManager } from './dashboard-manager.js'
import * as echarts from 'echarts'

const ws = ref(null)
const isConnected = ref(false)
const metrics = computed(() => dashboardManager.getMetrics())

// 图表实例
const salesChart = ref(null)
const ordersChart = ref(null)
const usersChart = ref(null)

// 初始化WebSocket
const initWebSocket = () => {
  ws.value = new WebSocket('ws://localhost:8080/dashboard')

  ws.value.onopen = () => {
    isConnected.value = true
    console.log('数据看板连接成功')
  }

  ws.value.onmessage = (event) => {
    const data = JSON.parse(event.data)
    dashboardManager.handleDataPush(data)
    updateCharts()
  }

  ws.value.onclose = () => {
    isConnected.value = false
    setTimeout(initWebSocket, 3000)
  }
}

// 初始化图表
const initCharts = () => {
  // 销售趋势图
  const salesChartDom = document.getElementById('salesChart')
  salesChart.value = echarts.init(salesChartDom)
  salesChart.value.setOption({
    title: { text: '销售趋势', left: 'center' },
    tooltip: { trigger: 'axis' },
    xAxis: { type: 'category', data: [] },
    yAxis: { type: 'value' },
    series: [{
      data: [],
      type: 'line',
      smooth: true,
      areaStyle: {
        color: new echarts.graphic.LinearGradient(0, 0, 0, 1, [
          { offset: 0, color: 'rgba(64, 158, 255, 0.5)' },
          { offset: 1, color: 'rgba(64, 158, 255, 0.1)' }
        ])
      }
    }]
  })

  // 订单趋势图
  const ordersChartDom = document.getElementById('ordersChart')
  ordersChart.value = echarts.init(ordersChartDom)
  ordersChart.value.setOption({
    title: { text: '订单趋势', left: 'center' },
    tooltip: { trigger: 'axis' },
    xAxis: { type: 'category', data: [] },
    yAxis: { type: 'value' },
    series: [{
      data: [],
      type: 'line',
      smooth: true,
      areaStyle: {
        color: new echarts.graphic.LinearGradient(0, 0, 0, 1, [
          { offset: 0, color: 'rgba(103, 194, 58, 0.5)' },
          { offset: 1, color: 'rgba(103, 194, 58, 0.1)' }
        ])
      }
    }]
  })

  // 在线用户图
  const usersChartDom = document.getElementById('usersChart')
  usersChart.value = echarts.init(usersChartDom)
  usersChart.value.setOption({
    title: { text: '在线用户', left: 'center' },
    tooltip: { trigger: 'axis' },
    xAxis: { type: 'category', data: [] },
    yAxis: { type: 'value' },
    series: [{
      data: [],
      type: 'line',
      smooth: true,
      areaStyle: {
        color: new echarts.graphic.LinearGradient(0, 0, 0, 1, [
          { offset: 0, color: 'rgba(230, 162, 60, 0.5)' },
          { offset: 1, color: 'rgba(230, 162, 60, 0.1)' }
        ])
      }
    }]
  })
}

// 更新图表
const updateCharts = () => {
  // 更新销售图表
  if (salesChart.value && metrics.value.sales.trend.length > 0) {
    salesChart.value.setOption({
      xAxis: {
        data: metrics.value.sales.trend.map(item => item.time)
      },
      series: [{
        data: metrics.value.sales.trend.map(item => item.value)
      }]
    })
  }

  // 更新订单图表
  if (ordersChart.value && metrics.value.orders.trend.length > 0) {
    ordersChart.value.setOption({
      xAxis: {
        data: metrics.value.orders.trend.map(item => item.time)
      },
      series: [{
        data: metrics.value.orders.trend.map(item => item.value)
      }]
    })
  }

  // 更新用户图表
  if (usersChart.value && metrics.value.users.trend.length > 0) {
    usersChart.value.setOption({
      xAxis: {
        data: metrics.value.users.trend.map(item => item.time)
      },
      series: [{
        data: metrics.value.users.trend.map(item => item.value)
      }]
    })
  }
}

// 模拟数据推送
const simulateDataPush = () => {
  const types = ['sales', 'order', 'user', 'performance']
  const type = types[Math.floor(Math.random() * types.length)]

  let data = { type, timestamp: Date.now() }

  switch (type) {
    case 'sales':
      data.value = {
        today: Math.floor(Math.random() * 100000),
        amount: Math.floor(Math.random() * 10000)
      }
      break
    case 'order':
      data.value = {
        total: Math.floor(Math.random() * 1000),
        pending: Math.floor(Math.random() * 100),
        completed: Math.floor(Math.random() * 900),
        count: Math.floor(Math.random() * 50)
      }
      break
    case 'user':
      data.value = {
        online: Math.floor(Math.random() * 1000),
        total: Math.floor(Math.random() * 10000),
        newToday: Math.floor(Math.random() * 100),
        onlineCount: Math.floor(Math.random() * 1000)
      }
      break
    case 'performance':
      data.value = {
        cpu: Math.random() * 100,
        memory: Math.random() * 100,
        qps: Math.floor(Math.random() * 1000),
        responseTime: Math.floor(Math.random() * 500)
      }
      break
  }

  dashboardManager.handleDataPush(data)
  updateCharts()
}

// 开始自动推送
let pushInterval = null
const startAutoPush = () => {
  pushInterval = setInterval(simulateDataPush, 2000)
}

const stopAutoPush = () => {
  if (pushInterval) {
    clearInterval(pushInterval)
    pushInterval = null
  }
}

onMounted(() => {
  initWebSocket()
  initCharts()
  startAutoPush()

  // 监听窗口大小变化
  window.addEventListener('resize', () => {
    salesChart.value?.resize()
    ordersChart.value?.resize()
    usersChart.value?.resize()
  })
})

onUnmounted(() => {
  stopAutoPush()
  ws.value?.close()
  salesChart.value?.dispose()
  ordersChart.value?.dispose()
  usersChart.value?.dispose()
})
</script>

<template>
  <div class="realtime-dashboard">
    <div class="dashboard-header">
      <h2>实时数据看板</h2>
      <div class="header-status">
        <div class="status-badge" :class="{ connected: isConnected }">
          {{ isConnected ? '实时更新中' : '连接断开' }}
        </div>
      </div>
    </div>

    <!-- 指标卡片 -->
    <div class="metrics-grid">
      <div class="metric-card sales">
        <div class="metric-icon">💰</div>
        <div class="metric-content">
          <div class="metric-label">今日销售额</div>
          <div class="metric-value">¥{{ metrics.sales.today.toLocaleString() }}</div>
          <div class="metric-sub">
            昨日: ¥{{ metrics.sales.yesterday.toLocaleString() }}
          </div>
        </div>
      </div>

      <div class="metric-card orders">
        <div class="metric-icon">📦</div>
        <div class="metric-content">
          <div class="metric-label">订单统计</div>
          <div class="metric-value">{{ metrics.orders.total }}</div>
          <div class="metric-sub">
            待处理: {{ metrics.orders.pending }} | 已完成: {{ metrics.orders.completed }}
          </div>
        </div>
      </div>

      <div class="metric-card users">
        <div class="metric-icon">👥</div>
        <div class="metric-content">
          <div class="metric-label">在线用户</div>
          <div class="metric-value">{{ metrics.users.online }}</div>
          <div class="metric-sub">
            总用户: {{ metrics.users.total }} | 今日新增: {{ metrics.users.newToday }}
          </div>
        </div>
      </div>

      <div class="metric-card performance">
        <div class="metric-icon">⚡</div>
        <div class="metric-content">
          <div class="metric-label">系统性能</div>
          <div class="metric-value">{{ metrics.performance.qps }} QPS</div>
          <div class="metric-sub">
            CPU: {{ metrics.performance.cpu.toFixed(1) }}% |
            内存: {{ metrics.performance.memory.toFixed(1) }}%
          </div>
        </div>
      </div>
    </div>

    <!-- 图表区域 -->
    <div class="charts-grid">
      <div class="chart-container">
        <div id="salesChart" class="chart"></div>
      </div>
      <div class="chart-container">
        <div id="ordersChart" class="chart"></div>
      </div>
      <div class="chart-container">
        <div id="usersChart" class="chart"></div>
      </div>
    </div>
  </div>
</template>

<style scoped>
.realtime-dashboard {
  height: 100vh;
  background: #f5f7fa;
  padding: 20px;
  overflow-y: auto;
}

.dashboard-header {
  display: flex;
  justify-content: space-between;
  align-items: center;
  margin-bottom: 24px;
}

.dashboard-header h2 {
  margin: 0;
  font-size: 24px;
  color: #303133;
}

.status-badge {
  padding: 6px 16px;
  border-radius: 20px;
  font-size: 13px;
  background: #f56c6c;
  color: white;
  animation: pulse 2s infinite;
}

.status-badge.connected {
  background: #67c23a;
}

@keyframes pulse {
  0%, 100% { opacity: 1; }
  50% { opacity: 0.7; }
}

/* 指标卡片 */
.metrics-grid {
  display: grid;
  grid-template-columns: repeat(auto-fit, minmax(280px, 1fr));
  gap: 20px;
  margin-bottom: 24px;
}

.metric-card {
  background: white;
  border-radius: 8px;
  padding: 24px;
  display: flex;
  gap: 16px;
  box-shadow: 0 2px 8px rgba(0, 0, 0, 0.05);
  transition: transform 0.3s, box-shadow 0.3s;
}

.metric-card:hover {
  transform: translateY(-4px);
  box-shadow: 0 4px 16px rgba(0, 0, 0, 0.1);
}

.metric-icon {
  width: 56px;
  height: 56px;
  border-radius: 12px;
  display: flex;
  align-items: center;
  justify-content: center;
  font-size: 28px;
  flex-shrink: 0;
}

.metric-card.sales .metric-icon {
  background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
}

.metric-card.orders .metric-icon {
  background: linear-gradient(135deg, #f093fb 0%, #f5576c 100%);
}

.metric-card.users .metric-icon {
  background: linear-gradient(135deg, #4facfe 0%, #00f2fe 100%);
}

.metric-card.performance .metric-icon {
  background: linear-gradient(135deg, #43e97b 0%, #38f9d7 100%);
}

.metric-content {
  flex: 1;
}

.metric-label {
  font-size: 14px;
  color: #909399;
  margin-bottom: 8px;
}

.metric-value {
  font-size: 28px;
  font-weight: 700;
  color: #303133;
  margin-bottom: 8px;
}

.metric-sub {
  font-size: 12px;
  color: #c0c4cc;
}

/* 图表区域 */
.charts-grid {
  display: grid;
  grid-template-columns: repeat(auto-fit, minmax(400px, 1fr));
  gap: 20px;
}

.chart-container {
  background: white;
  border-radius: 8px;
  padding: 20px;
  box-shadow: 0 2px 8px rgba(0, 0, 0, 0.05);
}

.chart {
  width: 100%;
  height: 300px;
}
</style>

四、在线状态显示

4.1 在线状态管理器

online-status-manager.js

javascript
import { ref, reactive } from 'vue'

export class OnlineStatusManager {
  constructor() {
    this.users = reactive(new Map())
    this.heartbeatInterval = 30000 // 30秒心跳
    this.offlineTimeout = 60000 // 60秒无心跳则离线
    this.checkTimer = null
  }

  // 用户上线
  userOnline(userId, userInfo = {}) {
    const user = {
      id: userId,
      name: userInfo.name || `用户${userId}`,
      avatar: userInfo.avatar,
      status: 'online', // online, away, offline
      lastActiveTime: Date.now(),
      device: userInfo.device || 'web',
      ...userInfo
    }

    this.users.set(userId, user)
    this.startHeartbeatCheck()

    return user
  }

  // 用户离线
  userOffline(userId) {
    const user = this.users.get(userId)
    if (user) {
      user.status = 'offline'
      user.offlineTime = Date.now()
    }
  }

  // 更新心跳
  updateHeartbeat(userId) {
    const user = this.users.get(userId)
    if (user) {
      user.lastActiveTime = Date.now()
      if (user.status === 'offline') {
        user.status = 'online'
      }
    }
  }

  // 设置用户状态
  setUserStatus(userId, status) {
    const user = this.users.get(userId)
    if (user) {
      user.status = status
      user.statusTime = Date.now()
    }
  }

  // 获取用户状态
  getUserStatus(userId) {
    const user = this.users.get(userId)
    return user ? user.status : 'offline'
  }

  // 获取所有在线用户
  getOnlineUsers() {
    return Array.from(this.users.values())
      .filter(user => user.status === 'online')
  }

  // 获取用户数统计
  getStats() {
    const all = Array.from(this.users.values())
    return {
      total: all.length,
      online: all.filter(u => u.status === 'online').length,
      away: all.filter(u => u.status === 'away').length,
      offline: all.filter(u => u.status === 'offline').length
    }
  }

  // 启动心跳检查
  startHeartbeatCheck() {
    if (this.checkTimer) return

    this.checkTimer = setInterval(() => {
      const now = Date.now()

      for (const [userId, user] of this.users) {
        if (user.status === 'online') {
          const timeSinceActive = now - user.lastActiveTime

          // 超时未活跃,标记为离线
          if (timeSinceActive > this.offlineTimeout) {
            this.userOffline(userId)
          }
        }
      }
    }, 10000) // 每10秒检查一次
  }

  // 停止心跳检查
  stopHeartbeatCheck() {
    if (this.checkTimer) {
      clearInterval(this.checkTimer)
      this.checkTimer = null
    }
  }

  // 清空所有用户
  clear() {
    this.users.clear()
    this.stopHeartbeatCheck()
  }
}

export const onlineStatusManager = new OnlineStatusManager()

4.2 在线状态组件

OnlineStatusView.vue

vue
<script setup>
import { ref, computed, onMounted, onUnmounted } from 'vue'
import { onlineStatusManager } from './online-status-manager.js'

const ws = ref(null)
const isConnected = ref(false)
const currentUserId = ref('user_' + Date.now())
const users = ref([])
const filterStatus = ref('all') // all, online, away, offline

// 统计数据
const stats = computed(() => onlineStatusManager.getStats())

// 过滤后的用户列表
const filteredUsers = computed(() => {
  if (filterStatus.value === 'all') {
    return users.value
  }
  return users.value.filter(user => user.status === filterStatus.value)
})

// 初始化WebSocket
const initWebSocket = () => {
  ws.value = new WebSocket('ws://localhost:8080/online-status')

  ws.value.onopen = () => {
    isConnected.value = true
    // 发送上线消息
    ws.value.send(JSON.stringify({
      type: 'online',
      userId: currentUserId.value,
      userInfo: {
        name: '当前用户',
        device: 'web'
      }
    }))

    startHeartbeat()
  }

  ws.value.onmessage = (event) => {
    const data = JSON.parse(event.data)
    handleStatusMessage(data)
  }

  ws.value.onclose = () => {
    isConnected.value = false
    stopHeartbeat()
    setTimeout(initWebSocket, 3000)
  }
}

// 处理状态消息
const handleStatusMessage = (data) => {
  switch (data.type) {
    case 'user_online':
      onlineStatusManager.userOnline(data.userId, data.userInfo)
      break
    case 'user_offline':
      onlineStatusManager.userOffline(data.userId)
      break
    case 'status_change':
      onlineStatusManager.setUserStatus(data.userId, data.status)
      break
    case 'heartbeat_ack':
      onlineStatusManager.updateHeartbeat(currentUserId.value)
      break
    case 'users_list':
      data.users.forEach(user => {
        onlineStatusManager.userOnline(user.id, user)
      })
      break
  }

  loadUsers()
}

// 加载用户列表
const loadUsers = () => {
  users.value = Array.from(onlineStatusManager.users.values())
    .sort((a, b) => {
      // 在线优先
      if (a.status === 'online' && b.status !== 'online') return -1
      if (a.status !== 'online' && b.status === 'online') return 1
      // 按最后活跃时间排序
      return b.lastActiveTime - a.lastActiveTime
    })
}

// 心跳定时器
let heartbeatTimer = null

const startHeartbeat = () => {
  heartbeatTimer = setInterval(() => {
    if (ws.value && ws.value.readyState === WebSocket.OPEN) {
      ws.value.send(JSON.stringify({
        type: 'heartbeat',
        userId: currentUserId.value,
        timestamp: Date.now()
      }))
    }
  }, 30000)
}

const stopHeartbeat = () => {
  if (heartbeatTimer) {
    clearInterval(heartbeatTimer)
    heartbeatTimer = null
  }
}

// 模拟用户上线/离线
const simulateUserStatus = () => {
  const actions = ['online', 'offline', 'away']
  const action = actions[Math.floor(Math.random() * actions.length)]
  const userId = 'user_' + Math.floor(Math.random() * 100)

  handleStatusMessage({
    type: action === 'away' ? 'status_change' : `user_${action}`,
    userId: userId,
    status: action,
    userInfo: {
      name: `用户${userId.substr(-2)}`,
      device: 'web'
    }
  })
}

// 获取状态样式
const getStatusStyle = (status) => {
  const styles = {
    online: { color: '#67c23a', text: '在线' },
    away: { color: '#e6a23c', text: '离开' },
    offline: { color: '#909399', text: '离线' }
  }
  return styles[status] || styles.offline
}

// 格式化时间
const formatTime = (timestamp) => {
  const now = Date.now()
  const diff = now - timestamp

  if (diff < 60000) return '刚刚'
  if (diff < 3600000) return `${Math.floor(diff / 60000)}分钟前`
  if (diff < 86400000) return `${Math.floor(diff / 3600000)}小时前`
  return `${Math.floor(diff / 86400000)}天前`
}

onMounted(() => {
  initWebSocket()
})

onUnmounted(() => {
  stopHeartbeat()
  ws.value?.close()
  onlineStatusManager.clear()
})
</script>

<template>
  <div class="online-status-view">
    <div class="header">
      <h2>在线状态监控</h2>
      <div class="header-actions">
        <button @click="simulateUserStatus" class="simulate-btn">
          模拟状态变化
        </button>
        <div class="connection-badge" :class="{ connected: isConnected }">
          {{ isConnected ? '已连接' : '未连接' }}
        </div>
      </div>
    </div>

    <!-- 统计卡片 -->
    <div class="stats-grid">
      <div class="stat-card total">
        <div class="stat-icon">👥</div>
        <div class="stat-content">
          <div class="stat-value">{{ stats.total }}</div>
          <div class="stat-label">总用户数</div>
        </div>
      </div>

      <div class="stat-card online">
        <div class="stat-icon">🟢</div>
        <div class="stat-content">
          <div class="stat-value">{{ stats.online }}</div>
          <div class="stat-label">在线</div>
        </div>
      </div>

      <div class="stat-card away">
        <div class="stat-icon">🟡</div>
        <div class="stat-content">
          <div class="stat-value">{{ stats.away }}</div>
          <div class="stat-label">离开</div>
        </div>
      </div>

      <div class="stat-card offline">
        <div class="stat-icon">⚫</div>
        <div class="stat-content">
          <div class="stat-value">{{ stats.offline }}</div>
          <div class="stat-label">离线</div>
        </div>
      </div>
    </div>

    <!-- 过滤标签 -->
    <div class="filter-tabs">
      <button
        :class="{ active: filterStatus === 'all' }"
        @click="filterStatus = 'all'"
      >
        全部 ({{ stats.total }})
      </button>
      <button
        :class="{ active: filterStatus === 'online' }"
        @click="filterStatus = 'online'"
      >
        在线 ({{ stats.online }})
      </button>
      <button
        :class="{ active: filterStatus === 'away' }"
        @click="filterStatus = 'away'"
      >
        离开 ({{ stats.away }})
      </button>
      <button
        :class="{ active: filterStatus === 'offline' }"
        @click="filterStatus = 'offline'"
      >
        离线 ({{ stats.offline }})
      </button>
    </div>

    <!-- 用户列表 -->
    <div class="user-list">
      <div
        v-for="user in filteredUsers"
        :key="user.id"
        class="user-item"
      >
        <div class="user-avatar">
          {{ user.name.charAt(0) }}
          <span
            class="status-dot"
            :style="{ background: getStatusStyle(user.status).color }"
          ></span>
        </div>

        <div class="user-info">
          <div class="user-name">{{ user.name }}</div>
          <div class="user-device">{{ user.device }}</div>
        </div>

        <div class="user-status">
          <span
            class="status-badge"
            :style="{ color: getStatusStyle(user.status).color }"
          >
            {{ getStatusStyle(user.status).text }}
          </span>
          <span class="last-active">
            {{ formatTime(user.lastActiveTime) }}
          </span>
        </div>
      </div>

      <div v-if="filteredUsers.length === 0" class="empty-list">
        <div class="empty-icon">👤</div>
        <div class="empty-text">暂无用户</div>
      </div>
    </div>
  </div>
</template>

<style scoped>
.online-status-view {
  height: 100vh;
  background: #f5f7fa;
  padding: 20px;
  overflow-y: auto;
}

.header {
  display: flex;
  justify-content: space-between;
  align-items: center;
  margin-bottom: 24px;
}

.header h2 {
  margin: 0;
  font-size: 24px;
  color: #303133;
}

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

.simulate-btn {
  padding: 8px 20px;
  background: #409eff;
  color: white;
  border: none;
  border-radius: 4px;
  cursor: pointer;
  font-size: 14px;
}

.simulate-btn:hover {
  background: #66b1ff;
}

.connection-badge {
  padding: 6px 16px;
  border-radius: 20px;
  font-size: 13px;
  background: #f56c6c;
  color: white;
}

.connection-badge.connected {
  background: #67c23a;
}

/* 统计卡片 */
.stats-grid {
  display: grid;
  grid-template-columns: repeat(auto-fit, minmax(200px, 1fr));
  gap: 16px;
  margin-bottom: 24px;
}

.stat-card {
  background: white;
  border-radius: 8px;
  padding: 20px;
  display: flex;
  align-items: center;
  gap: 16px;
  box-shadow: 0 2px 8px rgba(0, 0, 0, 0.05);
}

.stat-icon {
  font-size: 36px;
}

.stat-value {
  font-size: 32px;
  font-weight: 700;
  color: #303133;
  margin-bottom: 4px;
}

.stat-label {
  font-size: 14px;
  color: #909399;
}

/* 过滤标签 */
.filter-tabs {
  display: flex;
  gap: 12px;
  margin-bottom: 20px;
}

.filter-tabs button {
  padding: 8px 20px;
  background: white;
  border: 1px solid #dcdfe6;
  border-radius: 4px;
  cursor: pointer;
  font-size: 14px;
  color: #606266;
  transition: all 0.3s;
}

.filter-tabs button.active {
  background: #409eff;
  color: white;
  border-color: #409eff;
}

/* 用户列表 */
.user-list {
  background: white;
  border-radius: 8px;
  padding: 20px;
  box-shadow: 0 2px 8px rgba(0, 0, 0, 0.05);
}

.user-item {
  display: flex;
  align-items: center;
  gap: 16px;
  padding: 16px;
  border-bottom: 1px solid #f0f0f0;
}

.user-item:last-child {
  border-bottom: none;
}

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

.status-dot {
  position: absolute;
  bottom: 2px;
  right: 2px;
  width: 12px;
  height: 12px;
  border-radius: 50%;
  border: 2px solid white;
}

.user-info {
  flex: 1;
}

.user-name {
  font-size: 16px;
  font-weight: 600;
  color: #303133;
  margin-bottom: 4px;
}

.user-device {
  font-size: 13px;
  color: #909399;
}

.user-status {
  text-align: right;
}

.status-badge {
  display: block;
  font-size: 14px;
  font-weight: 600;
  margin-bottom: 4px;
}

.last-active {
  font-size: 12px;
  color: #c0c4cc;
}

.empty-list {
  display: flex;
  flex-direction: column;
  align-items: center;
  justify-content: center;
  padding: 60px 20px;
  color: #909399;
}

.empty-icon {
  font-size: 64px;
  margin-bottom: 16px;
}

.empty-text {
  font-size: 14px;
}
</style>

五、消息通知中心

5.1 通知中心管理器

notification-center-manager.js

javascript
import { ref, reactive } from 'vue'

export class NotificationCenterManager {
  constructor() {
    this.notifications = reactive([])
    this.unreadCount = ref(0)
    this.maxNotifications = 100
  }

  // 通知类型配置
  typeConfig = {
    system: {
      icon: '⚙️',
      color: '#909399',
      title: '系统通知'
    },
    order: {
      icon: '📦',
      color: '#409eff',
      title: '订单通知'
    },
    message: {
      icon: '💬',
      color: '#67c23a',
      title: '消息通知'
    },
    alert: {
      icon: '⚠️',
      color: '#e6a23c',
      title: '告警通知'
    },
    error: {
      icon: '❌',
      color: '#f56c6c',
      title: '错误通知'
    }
  }

  // 添加通知
  addNotification(notification) {
    const newNotification = {
      id: notification.id || this.generateId(),
      type: notification.type || 'system',
      title: notification.title,
      content: notification.content,
      data: notification.data,
      timestamp: notification.timestamp || Date.now(),
      read: false,
      priority: notification.priority || 'normal' // low, normal, high
    }

    this.notifications.unshift(newNotification)
    this.unreadCount.value++

    // 限制通知数量
    if (this.notifications.length > this.maxNotifications) {
      this.notifications.pop()
    }

    return newNotification
  }

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

  // 标记全部为已读
  markAllAsRead() {
    this.notifications.forEach(n => {
      n.read = true
    })
    this.unreadCount.value = 0
  }

  // 删除通知
  deleteNotification(notificationId) {
    const index = this.notifications.findIndex(n => n.id === notificationId)
    if (index > -1) {
      const notification = this.notifications[index]
      if (!notification.read) {
        this.unreadCount.value = Math.max(0, this.unreadCount.value - 1)
      }
      this.notifications.splice(index, 1)
    }
  }

  // 清空所有通知
  clearAll() {
    this.notifications.length = 0
    this.unreadCount.value = 0
  }

  // 获取未读通知
  getUnreadNotifications() {
    return this.notifications.filter(n => !n.read)
  }

  // 获取通知配置
  getTypeConfig(type) {
    return this.typeConfig[type] || this.typeConfig.system
  }

  // 生成ID
  generateId() {
    return `notif_${Date.now()}_${Math.random().toString(36).substr(2, 9)}`
  }
}

export const notificationCenterManager = new NotificationCenterManager()

5.2 通知中心组件

NotificationCenter.vue

vue
<script setup>
import { ref, computed, onMounted } from 'vue'
import { notificationCenterManager } from './notification-center-manager.js'

const ws = ref(null)
const isConnected = ref(false)
const isOpen = ref(false)
const filterType = ref('all')
const notifications = computed(() => notificationCenterManager.notifications)
const unreadCount = computed(() => notificationCenterManager.unreadCount.value)

// 过滤后的通知
const filteredNotifications = computed(() => {
  if (filterType.value === 'all') {
    return notifications.value
  }
  if (filterType.value === 'unread') {
    return notifications.value.filter(n => !n.read)
  }
  return notifications.value.filter(n => n.type === filterType.value)
})

// 初始化WebSocket
const initWebSocket = () => {
  ws.value = new WebSocket('ws://localhost:8080/notifications')

  ws.value.onopen = () => {
    isConnected.value = true
  }

  ws.value.onmessage = (event) => {
    const data = JSON.parse(event.data)
    handleNotification(data)
  }

  ws.value.onclose = () => {
    isConnected.value = false
    setTimeout(initWebSocket, 3000)
  }
}

// 处理通知
const handleNotification = (data) => {
  notificationCenterManager.addNotification(data)

  // 播放提示音
  playNotificationSound()

  // 显示桌面通知
  if ('Notification' in window && Notification.permission === 'granted') {
    new Notification(data.title, {
      body: data.content,
      icon: '/notification-icon.png'
    })
  }
}

// 播放提示音
const playNotificationSound = () => {
  const audio = new Audio('/notification.mp3')
  audio.play().catch(err => console.log('播放失败:', err))
}

// 打开/关闭通知中心
const toggleNotificationCenter = () => {
  isOpen.value = !isOpen.value
}

// 标记为已读
const markAsRead = (notification) => {
  notificationCenterManager.markAsRead(notification.id)
}

// 标记全部已读
const markAllAsRead = () => {
  notificationCenterManager.markAllAsRead()
}

// 删除通知
const deleteNotification = (notification, event) => {
  event.stopPropagation()
  notificationCenterManager.deleteNotification(notification.id)
}

// 清空所有
const clearAll = () => {
  if (confirm('确定要清空所有通知吗?')) {
    notificationCenterManager.clearAll()
  }
}

// 格式化时间
const formatTime = (timestamp) => {
  const now = Date.now()
  const diff = now - timestamp

  if (diff < 60000) return '刚刚'
  if (diff < 3600000) return `${Math.floor(diff / 60000)}分钟前`
  if (diff < 86400000) return `${Math.floor(diff / 3600000)}小时前`
  return new Date(timestamp).toLocaleDateString()
}

// 模拟通知
const simulateNotification = () => {
  const types = ['system', 'order', 'message', 'alert', 'error']
  const type = types[Math.floor(Math.random() * types.length)]
  const config = notificationCenterManager.getTypeConfig(type)

  handleNotification({
    type: type,
    title: config.title,
    content: `这是一条${config.title}的内容`,
    priority: Math.random() > 0.7 ? 'high' : 'normal'
  })
}

onMounted(() => {
  initWebSocket()

  // 请求通知权限
  if ('Notification' in window && Notification.permission === 'default') {
    Notification.requestPermission()
  }
})
</script>

<template>
  <div class="notification-center">
    <!-- 通知按钮 -->
    <div class="notification-trigger" @click="toggleNotificationCenter">
      <div class="notification-icon">🔔</div>
      <div v-if="unreadCount > 0" class="badge">
        {{ unreadCount > 99 ? '99+' : unreadCount }}
      </div>
    </div>

    <!-- 通知面板 -->
    <transition name="slide">
      <div v-show="isOpen" class="notification-panel">
        <div class="panel-header">
          <h3>通知中心</h3>
          <button @click="simulateNotification" class="simulate-btn">
            模拟
          </button>
          <button @click="toggleNotificationCenter" class="close-btn">
            ✕
          </button>
        </div>

        <div class="panel-filters">
          <button
            :class="{ active: filterType === 'all' }"
            @click="filterType = 'all'"
          >
            全部 ({{ notifications.length }})
          </button>
          <button
            :class="{ active: filterType === 'unread' }"
            @click="filterType = 'unread'"
          >
            未读 ({{ unreadCount }})
          </button>
          <button
            :class="{ active: filterType === 'system' }"
            @click="filterType = 'system'"
          >
            系统
          </button>
          <button
            :class="{ active: filterType === 'order' }"
            @click="filterType = 'order'"
          >
            订单
          </button>
          <button
            :class="{ active: filterType === 'message' }"
            @click="filterType = 'message'"
          >
            消息
          </button>
        </div>

        <div class="panel-actions">
          <button @click="markAllAsRead" :disabled="unreadCount === 0">
            全部已读
          </button>
          <button @click="clearAll" :disabled="notifications.length === 0">
            清空
          </button>
        </div>

        <div class="notification-list">
          <div
            v-for="notification in filteredNotifications"
            :key="notification.id"
            :class="['notification-item', { unread: !notification.read }]"
            @click="markAsRead(notification)"
          >
            <div class="notification-icon-wrapper">
              <span
                class="type-icon"
                :style="{ color: notificationCenterManager.getTypeConfig(notification.type).color }"
              >
                {{ notificationCenterManager.getTypeConfig(notification.type).icon }}
              </span>
            </div>

            <div class="notification-content">
              <div class="notification-title">
                {{ notification.title }}
                <span v-if="notification.priority === 'high'" class="priority-badge">
                  重要
                </span>
              </div>
              <div class="notification-text">
                {{ notification.content }}
              </div>
              <div class="notification-time">
                {{ formatTime(notification.timestamp) }}
              </div>
            </div>

            <button
              class="delete-btn"
              @click="deleteNotification(notification, $event)"
            >
              ✕
            </button>
          </div>

          <div v-if="filteredNotifications.length === 0" class="empty-notifications">
            <div class="empty-icon">📭</div>
            <div class="empty-text">暂无通知</div>
          </div>
        </div>
      </div>
    </transition>

    <!-- 遮罩层 -->
    <transition name="fade">
      <div v-show="isOpen" class="overlay" @click="toggleNotificationCenter"></div>
    </transition>
  </div>
</template>

<style scoped>
.notification-center {
  position: fixed;
  top: 20px;
  right: 20px;
  z-index: 1000;
}

.notification-trigger {
  position: relative;
  width: 48px;
  height: 48px;
  background: white;
  border-radius: 50%;
  box-shadow: 0 4px 12px rgba(0, 0, 0, 0.15);
  display: flex;
  align-items: center;
  justify-content: center;
  cursor: pointer;
  transition: transform 0.3s;
}

.notification-trigger:hover {
  transform: scale(1.1);
}

.notification-icon {
  font-size: 24px;
}

.badge {
  position: absolute;
  top: -4px;
  right: -4px;
  min-width: 20px;
  height: 20px;
  background: #f56c6c;
  color: white;
  border-radius: 10px;
  display: flex;
  align-items: center;
  justify-content: center;
  font-size: 11px;
  font-weight: 600;
  padding: 0 6px;
  box-shadow: 0 2px 4px rgba(245, 108, 108, 0.4);
}

.notification-panel {
  position: fixed;
  top: 80px;
  right: 20px;
  width: 400px;
  max-height: 600px;
  background: white;
  border-radius: 12px;
  box-shadow: 0 8px 24px rgba(0, 0, 0, 0.2);
  display: flex;
  flex-direction: column;
  overflow: hidden;
}

.panel-header {
  padding: 16px 20px;
  border-bottom: 1px solid #e4e7ed;
  display: flex;
  align-items: center;
  gap: 12px;
}

.panel-header h3 {
  flex: 1;
  margin: 0;
  font-size: 18px;
  color: #303133;
}

.simulate-btn,
.close-btn {
  width: 32px;
  height: 32px;
  border: none;
  background: #f5f7fa;
  border-radius: 4px;
  cursor: pointer;
  font-size: 14px;
  color: #606266;
}

.simulate-btn:hover,
.close-btn:hover {
  background: #e4e7ed;
}

.panel-filters {
  padding: 12px 20px;
  display: flex;
  gap: 8px;
  overflow-x: auto;
  border-bottom: 1px solid #e4e7ed;
}

.panel-filters button {
  padding: 4px 12px;
  background: #f5f7fa;
  border: none;
  border-radius: 12px;
  cursor: pointer;
  font-size: 13px;
  color: #606266;
  white-space: nowrap;
  transition: all 0.3s;
}

.panel-filters button.active {
  background: #409eff;
  color: white;
}

.panel-actions {
  padding: 12px 20px;
  display: flex;
  gap: 12px;
  border-bottom: 1px solid #e4e7ed;
}

.panel-actions button {
  padding: 6px 16px;
  background: #ecf5ff;
  color: #409eff;
  border: 1px solid #d9ecff;
  border-radius: 4px;
  cursor: pointer;
  font-size: 13px;
}

.panel-actions button:hover:not(:disabled) {
  background: #d9ecff;
}

.panel-actions button:disabled {
  opacity: 0.5;
  cursor: not-allowed;
}

.notification-list {
  flex: 1;
  overflow-y: auto;
}

.notification-item {
  display: flex;
  gap: 12px;
  padding: 16px 20px;
  border-bottom: 1px solid #f0f0f0;
  cursor: pointer;
  transition: background 0.2s;
  position: relative;
}

.notification-item:hover {
  background: #f5f7fa;
}

.notification-item.unread {
  background: #ecf5ff;
}

.notification-item.unread::before {
  content: '';
  position: absolute;
  left: 0;
  top: 0;
  bottom: 0;
  width: 3px;
  background: #409eff;
}

.notification-icon-wrapper {
  width: 40px;
  height: 40px;
  border-radius: 50%;
  background: #f5f7fa;
  display: flex;
  align-items: center;
  justify-content: center;
  flex-shrink: 0;
}

.type-icon {
  font-size: 20px;
}

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

.notification-title {
  font-size: 14px;
  font-weight: 600;
  color: #303133;
  margin-bottom: 4px;
  display: flex;
  align-items: center;
  gap: 8px;
}

.priority-badge {
  padding: 2px 6px;
  background: #f56c6c;
  color: white;
  border-radius: 8px;
  font-size: 11px;
  font-weight: 600;
}

.notification-text {
  font-size: 13px;
  color: #606266;
  margin-bottom: 4px;
  white-space: nowrap;
  overflow: hidden;
  text-overflow: ellipsis;
}

.notification-time {
  font-size: 12px;
  color: #c0c4cc;
}

.delete-btn {
  width: 24px;
  height: 24px;
  border: none;
  background: transparent;
  cursor: pointer;
  font-size: 16px;
  color: #c0c4cc;
  flex-shrink: 0;
  opacity: 0;
  transition: opacity 0.2s;
}

.notification-item:hover .delete-btn {
  opacity: 1;
}

.delete-btn:hover {
  color: #f56c6c;
}

.empty-notifications {
  display: flex;
  flex-direction: column;
  align-items: center;
  justify-content: center;
  padding: 60px 20px;
  color: #909399;
}

.empty-icon {
  font-size: 64px;
  margin-bottom: 16px;
}

.empty-text {
  font-size: 14px;
}

.overlay {
  position: fixed;
  top: 0;
  left: 0;
  right: 0;
  bottom: 0;
  background: rgba(0, 0, 0, 0.3);
  z-index: 999;
}

/* 动画 */
.slide-enter-active,
.slide-leave-active {
  transition: all 0.3s;
}

.slide-enter-from {
  opacity: 0;
  transform: translateY(-20px);
}

.slide-leave-to {
  opacity: 0;
  transform: translateY(-20px);
}

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

.fade-enter-from,
.fade-leave-to {
  opacity: 0;
}
</style>

六、简历描述模板

实时推送系统开发 (2023.09 - 2024.01)

负责公司多个业务场景的实时推送功能开发,包括订单状态、数据看板、在线状态和通知中心,日处理推送消息10万+条。

核心职责

  • 开发订单状态实时推送系统,支持订单创建、状态变更、物流更新等多种事件推送
  • 实现基于ECharts的实时数据看板,支持销售、订单、用户等多维度数据可视化
  • 开发用户在线状态监控系统,实现30秒心跳检测和自动离线判定
  • 构建统一的消息通知中心,支持系统、业务、告警等多类型通知管理
技术实现
  • 使用WebSocket实现服务端主动推送,推送延迟控制在100ms以内
  • 通过reactive响应式数据管理,实现UI的自动更新
  • 使用requestAnimationFrame优化批量数据更新,避免页面卡顿
  • 集成Notification API和提示音,提升消息触达率至95%
项目成果
  • 订单状态推送准确率99.9%
  • 数据看板支持最高1000点/秒的数据更新
  • 在线状态监控误差率低于1%
  • 通知中心日均处理通知10万+条,用户满意度提升30%

七、SOP标准回答

面试问题: 介绍一下实时数据看板的实现

标准回答

"我开发的实时数据看板主要用于展示销售、订单、用户等业务数据,特点是高频更新、低延迟。

技术架构上,前端通过WebSocket建立长连接,后端采用发布订阅模式,有新数据就主动推送。我设计了一个DashboardManager统一管理所有指标数据,使用Vue 3的reactive API实现响应式更新。

性能优化是重点。首先是批量更新,我维护了一个更新队列,用requestAnimationFrame分帧处理,每帧最多更新10条数据,避免一次性更新太多导致卡顿。其次是数据精简,服务端只推送变化的数据,不是完整数据集,减少网络传输。

图表渲染用的ECharts。我做了两个优化:一是数据点控制,每个图表最多保留30个数据点,超出就删除最早的;二是延迟渲染,收到推送后不是立即更新图表,而是等到下一帧再批量更新所有图表。

指标展示分为卡片和图表两部分。卡片显示当前值和对比值,比如今日销售额vs昨日。图表显示趋势,用面积图更直观。所有数据都是实时更新的,用户能看到数据的变化动画。

实际效果是,支持每秒1000个数据点的更新,CPU占用率控制在30%以内,用户体验很流畅。"


八、难点与亮点分析

难点1: 如何优化高频数据更新性能?

问题: 数据看板每秒可能收到上百条更新,直接更新会导致页面卡顿。

解决方案

  1. 更新队列 + 分帧处理
javascript
async processQueue() {
  while (this.updateQueue.length > 0) {
    const batch = this.updateQueue.splice(0, 10)

    batch.forEach(update => {
      this.handleDataPush(update)
    })

    // 等待下一帧
    await new Promise(resolve => requestAnimationFrame(resolve))
  }
}
  1. 数据点限制
javascript
if (this.metrics.sales.trend.length > 30) {
  this.metrics.sales.trend.shift()
}
  1. 节流更新
javascript
const throttledUpdate = throttle(() => {
  updateCharts()
}, 1000)

亮点1: 智能通知优先级

创新点

  • 根据通知类型自动分级
  • 高优先级通知置顶显示
  • 智能合并相似通知
实现
javascript
addNotification(notification) {
  // 检查是否有相似通知
  const similar = this.findSimilarNotification(notification)

  if (similar) {
    similar.count = (similar.count || 1) + 1
    similar.timestamp = Date.now()
    return similar
  }

  // 根据优先级插入
  const insertIndex = this.notifications.findIndex(
    n => n.priority < notification.priority
  )

  if (insertIndex >= 0) {
    this.notifications.splice(insertIndex, 0, notification)
  } else {
    this.notifications.push(notification)
  }
}

亮点2: 断线自动恢复机制

创新点

  • WebSocket断线后自动重连
  • 重连成功后拉取遗漏数据
  • 用户无感知的数据补偿
实现
javascript
ws.onclose = () => {
  const lastTimestamp = this.getLastDataTimestamp()

  setTimeout(() => {
    this.reconnect()

    // 重连成功后拉取遗漏数据
    this.fetchMissedData(lastTimestamp)
  }, 3000)
}