返回笔记首页

大屏交互设计

主题配置

简历描述模板

设计并实现了大屏可视化系统的交互体系,包括点击钻取、数据联动、全屏切换等核心功能。通过事件总线和状态管理实现了跨组件的数据联动,支持地图点击下钻、图表联动筛选等复杂交互场景。开发了语音播报功能,配合科大讯飞API实现关键数据的自动语音提醒。针对触摸屏场景优化了交互逻辑,支持手势操作和多点触控,提升了用户体验。交互功能上线后,用户操作效率提升40%,数据查询准确率提升至95%。

SOP 标准回答

面试官:大屏的交互是怎么设计的?有哪些特色功能?

我来详细说说我们大屏的交互设计。

大屏的交互和普通网页不太一样,因为它主要是展示用的,不能做得太复杂,但也不能完全没有交互。我们设计的核心理念是"点到为止,看到想要",就是用户点一下就能看到想要的详细数据,但又不影响整体的展示效果。

第一个核心功能是点击钻取。比如我们有个中国地图,显示各省的销售数据。用户点击某个省,地图就会放大到那个省,同时显示这个省的城市数据。再点击某个城市,就能看到这个城市的详细报表。这个功能的实现用了状态管理和动画过渡,点击时先记录当前层级,然后加载下一层数据,用动画过渡让视觉上更流畅。

第二个是数据联动。大屏上有很多图表,它们的数据是关联的。比如点击左边的地区筛选,右边的所有图表都会自动更新成这个地区的数据。这个功能用事件总线实现,一个组件发出筛选事件,其他组件监听这个事件并更新数据。为了避免频繁请求接口,我们还做了防抖处理。

第三个是全屏功能。大屏通常是挂在墙上的,但有时候需要近距离看某个图表的细节。我们给每个图表都加了全屏按钮,点击后这个图表会全屏显示,数据也会更详细。全屏状态下可以用ESC键退出,或者点击关闭按钮。

第四个是语音播报。这是客户特别要求的功能,要求当某些关键指标出现异常时,自动语音提醒。我们集成了科大讯飞的语音API,设置了阈值规则,比如销售额突然下降超过20%就会触发语音播报。语音内容可以自定义,播报速度和音量也能调。

第五个是触摸屏支持。有些客户的大屏是触摸屏,要支持手势操作。我们做了适配,支持双指缩放、滑动切换、长按显示详情这些手势。还做了防误触优化,避免无意的触碰导致界面切换。

这些交互功能上线后,客户反馈很好,说操作直观,数据查找也快了。我们统计了下,用户操作效率提升了40%,数据查询的准确率也提升到95%以上。

难点与亮点分析

难点1:多组件数据联动的性能

问题:页面上有20多个图表,点击筛选时所有图表都要更新,会导致明显卡顿。

解决方案:采用发布订阅模式配合批量更新策略。用事件总线管理全局筛选状态,组件订阅状态变化。关键是做了批量更新优化,收集所有组件的更新请求,合并成一个接口调用。还加了防抖和loading状态,避免频繁操作。对于不在可视区的图表,延迟更新或者等滚动到时再更新。

效果:20个图表联动更新的时间从3秒降到0.8秒,用户体验显著提升。

难点2:地图钻取的流畅度

问题:点击地图钻取时,数据加载慢,页面会卡住,用户不知道发生了什么。

解决方案:实现了渐进式加载和过渡动画。点击后立即显示loading动画和骨架屏,给用户反馈。同时预加载下一层级的地图数据,比如显示省级地图时就预先加载热门省份的城市数据。加载完成后用动画过渡,地图平滑缩放到目标区域,配合数据的淡入淡出效果。

效果:地图钻取的等待时间从2秒降到0.3秒,动画过渡让体验更流畅自然。

难点3:触摸屏的手势冲突

问题:触摸屏上的单击、双击、滑动、缩放等手势容易冲突,导致误操作。

解决方案:封装了触摸手势识别库。通过计算触摸点数量、移动距离、持续时间等来识别不同手势。设置了合理的阈值,比如移动超过10px才算滑动,持续超过500ms才算长按。还做了事件优先级管理,缩放手势优先级最高,避免缩放时误触发其他操作。

效果:触摸操作的准确率从70%提升到95%以上,误触率大幅降低。

技术亮点

  1. 智能预加载:根据用户行为预测下一步操作,提前加载数据。
  2. 无感切换:页面切换和数据更新都有流畅的过渡动画,体验接近原生应用。
  3. 自适应交互:根据设备类型(触摸屏、鼠标)自动调整交互方式。

完整技术实现

1. 事件总线

eventBus.js - 全局事件通信

javascript
// utils/eventBus.js
class EventBus {
  constructor() {
    this.events = {}
  }

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

    // 返回取消订阅函数
    return () => this.off(event, callback)
  }

  // 取消订阅
  off(event, callback) {
    if (!this.events[event]) return

    const index = this.events[event].indexOf(callback)
    if (index > -1) {
      this.events[event].splice(index, 1)
    }
  }

  // 发布事件
  emit(event, data) {
    if (!this.events[event]) return

    this.events[event].forEach(callback => {
      try {
        callback(data)
      } catch (e) {
        console.error(`Error in event handler for ${event}:`, e)
      }
    })
  }

  // 订阅一次
  once(event, callback) {
    const wrapper = (data) => {
      callback(data)
      this.off(event, wrapper)
    }
    this.on(event, wrapper)
  }

  // 清除事件
  clear(event) {
    if (event) {
      delete this.events[event]
    } else {
      this.events = {}
    }
  }
}

export const eventBus = new EventBus()
export default eventBus

2. 数据联动Hook

useDataLink.js - 跨组件数据联动

javascript
// composables/useDataLink.js
import { ref, onUnmounted } from 'vue'
import { eventBus } from '@/utils/eventBus'

export function useDataLink(options = {}) {
  const {
    onFilterChange = () => {},
    debounceTime = 300
  } = options

  const currentFilter = ref({})
  const isLoading = ref(false)

  let debounceTimer = null
  let unsubscribe = null

  // 更新筛选条件
  const updateFilter = (filter) => {
    currentFilter.value = { ...currentFilter.value, ...filter }

    if (debounceTimer) clearTimeout(debounceTimer)

    debounceTimer = setTimeout(() => {
      isLoading.value = true

      Promise.resolve(onFilterChange(currentFilter.value))
        .then(() => {
          isLoading.value = false
        })
        .catch((e) => {
          console.error('Filter change error:', e)
          isLoading.value = false
        })
    }, debounceTime)
  }

  // 广播筛选条件
  const broadcastFilter = (filter) => {
    eventBus.emit('filter:change', filter)
  }

  // 订阅筛选事件
  const subscribeFilter = (callback) => {
    unsubscribe = eventBus.on('filter:change', (filter) => {
      updateFilter(filter)
      if (callback) callback(filter)
    })
  }

  // 重置筛选
  const resetFilter = () => {
    currentFilter.value = {}
    broadcastFilter({})
  }

  onUnmounted(() => {
    if (debounceTimer) clearTimeout(debounceTimer)
    if (unsubscribe) unsubscribe()
  })

  return {
    currentFilter,
    isLoading,
    updateFilter,
    broadcastFilter,
    subscribeFilter,
    resetFilter
  }
}

3. 地图钻取组件

DrillDownMap.vue - 可钻取的地图

vue
<template>
  <div class="drill-down-map" ref="mapRef">
    <div class="map-breadcrumb">
      <span v-for="(item, index) in breadcrumb" :key="index"
        @click="backToLevel(index)"
        :class="{ active: index === breadcrumb.length - 1 }">
        {{ item.name }}
        <span v-if="index < breadcrumb.length - 1"> > </span>
      </span>
    </div>

    <div class="map-container" v-loading="loading">
      <div ref="chartRef" style="width: 100%; height: 100%"></div>
    </div>
  </div>
</template>

<script setup>
import { ref, onMounted, onUnmounted, nextTick } from 'vue'
import * as echarts from 'echarts'
import { useDataLink } from './useDataLink'

const props = defineProps({
  initialLevel: { type: String, default: 'china' },
  onDrillDown: { type: Function, required: true }
})

const mapRef = ref(null)
const chartRef = ref(null)
const loading = ref(false)

let chart = null
const breadcrumb = ref([{ name: '全国', code: 'china', level: 0 }])
const currentLevel = ref(0)

const { broadcastFilter } = useDataLink()

// 初始化图表
const initChart = () => {
  if (!chartRef.value) return

  chart = echarts.init(chartRef.value)

  // 监听点击事件
  chart.on('click', (params) => {
    if (params.componentType === 'series') {
      handleMapClick(params)
    }
  })
}

// 处理地图点击
const handleMapClick = async (params) => {
  const { name, adcode } = params.data

  if (!adcode) return

  loading.value = true

  try {
    const nextLevel = currentLevel.value + 1
    const data = await props.onDrillDown({
      name,
      code: adcode,
      level: nextLevel
    })

    // 更新面包屑
    breadcrumb.value.push({
      name,
      code: adcode,
      level: nextLevel
    })

    currentLevel.value = nextLevel

    // 广播筛选条件
    broadcastFilter({
      region: name,
      adcode: adcode
    })

    await nextTick()
    updateMap(data)
  } catch (e) {
    console.error('Drill down error:', e)
  } finally {
    loading.value = false
  }
}

// 返回上级
const backToLevel = async (index) => {
  if (index === breadcrumb.value.length - 1) return

  loading.value = true

  try {
    const target = breadcrumb.value[index]
    const data = await props.onDrillDown(target)

    breadcrumb.value = breadcrumb.value.slice(0, index + 1)
    currentLevel.value = target.level

    broadcastFilter({
      region: target.name,
      adcode: target.code
    })

    await nextTick()
    updateMap(data)
  } catch (e) {
    console.error('Back to level error:', e)
  } finally {
    loading.value = false
  }
}

// 更新地图
const updateMap = (data) => {
  if (!chart) return

  const option = {
    geo: {
      map: data.mapName,
      roam: true,
      itemStyle: {
        areaColor: '#0a2c5a',
        borderColor: '#00d4ff',
        borderWidth: 1
      },
      emphasis: {
        itemStyle: {
          areaColor: '#1a3d6e'
        }
      }
    },
    series: [
      {
        type: 'map',
        map: data.mapName,
        data: data.values,
        label: {
          show: true,
          color: '#fff'
        }
      }
    ]
  }

  chart.setOption(option, true)
}

const handleResize = () => {
  if (chart) {
    chart.resize()
  }
}

onMounted(() => {
  initChart()
  window.addEventListener('resize', handleResize)
})

onUnmounted(() => {
  if (chart) {
    chart.dispose()
    chart = null
  }
  window.removeEventListener('resize', handleResize)
})
</script>

<style scoped>
.drill-down-map {
  width: 100%;
  height: 100%;
  display: flex;
  flex-direction: column;
}

.map-breadcrumb {
  padding: 10px;
  background: rgba(0, 0, 0, 0.3);
  color: #00d4ff;
  font-size: 14px;
}

.map-breadcrumb span {
  cursor: pointer;
  transition: all 0.3s;
}

.map-breadcrumb span:hover {
  color: #fff;
}

.map-breadcrumb span.active {
  color: #fff;
  font-weight: bold;
}

.map-container {
  flex: 1;
  position: relative;
}
</style>

4. 全屏功能Hook

useFullscreen.js - 全屏控制

javascript
// composables/useFullscreen.js
import { ref, onMounted, onUnmounted } from 'vue'

export function useFullscreen(elementRef) {
  const isFullscreen = ref(false)

  // 进入全屏
  const enterFullscreen = () => {
    const element = elementRef.value
    if (!element) return

    try {
      if (element.requestFullscreen) {
        element.requestFullscreen()
      } else if (element.webkitRequestFullscreen) {
        element.webkitRequestFullscreen()
      } else if (element.mozRequestFullScreen) {
        element.mozRequestFullScreen()
      } else if (element.msRequestFullscreen) {
        element.msRequestFullscreen()
      }
    } catch (e) {
      console.error('Enter fullscreen error:', e)
    }
  }

  // 退出全屏
  const exitFullscreen = () => {
    try {
      if (document.exitFullscreen) {
        document.exitFullscreen()
      } else if (document.webkitExitFullscreen) {
        document.webkitExitFullscreen()
      } else if (document.mozCancelFullScreen) {
        document.mozCancelFullScreen()
      } else if (document.msExitFullscreen) {
        document.msExitFullscreen()
      }
    } catch (e) {
      console.error('Exit fullscreen error:', e)
    }
  }

  // 切换全屏
  const toggleFullscreen = () => {
    if (isFullscreen.value) {
      exitFullscreen()
    } else {
      enterFullscreen()
    }
  }

  // 监听全屏状态变化
  const handleFullscreenChange = () => {
    isFullscreen.value = !!(document.fullscreenElement ||
      document.webkitFullscreenElement ||
      document.mozFullScreenElement ||
      document.msFullscreenElement)
  }

  // 监听ESC键
  const handleKeydown = (e) => {
    if (e.key === 'Escape' && isFullscreen.value) {
      exitFullscreen()
    }
  }

  onMounted(() => {
    document.addEventListener('fullscreenchange', handleFullscreenChange)
    document.addEventListener('webkitfullscreenchange', handleFullscreenChange)
    document.addEventListener('mozfullscreenchange', handleFullscreenChange)
    document.addEventListener('msfullscreenchange', handleFullscreenChange)
    document.addEventListener('keydown', handleKeydown)
  })

  onUnmounted(() => {
    document.removeEventListener('fullscreenchange', handleFullscreenChange)
    document.removeEventListener('webkitfullscreenchange', handleFullscreenChange)
    document.removeEventListener('mozfullscreenchange', handleFullscreenChange)
    document.removeEventListener('msfullscreenchange', handleFullscreenChange)
    document.removeEventListener('keydown', handleKeydown)
  })

  return {
    isFullscreen,
    enterFullscreen,
    exitFullscreen,
    toggleFullscreen
  }
}

5. 语音播报Hook

useSpeech.js - 语音合成

javascript
// composables/useSpeech.js
import { ref } from 'vue'

export function useSpeech(options = {}) {
  const {
    rate = 1.0,      // 语速
    pitch = 1.0,     // 音调
    volume = 1.0,    // 音量
    lang = 'zh-CN'   // 语言
  } = options

  const isSpeaking = ref(false)
  const isSupported = ref('speechSynthesis' in window)

  // 播报文字
  const speak = (text) => {
    if (!isSupported.value) {
      console.warn('Speech synthesis not supported')
      return Promise.reject(new Error('Not supported'))
    }

    return new Promise((resolve, reject) => {
      try {
        const utterance = new SpeechSynthesisUtterance(text)

        utterance.rate = rate
        utterance.pitch = pitch
        utterance.volume = volume
        utterance.lang = lang

        utterance.onstart = () => {
          isSpeaking.value = true
        }

        utterance.onend = () => {
          isSpeaking.value = false
          resolve()
        }

        utterance.onerror = (e) => {
          isSpeaking.value = false
          reject(e)
        }

        window.speechSynthesis.speak(utterance)
      } catch (e) {
        reject(e)
      }
    })
  }

  // 停止播报
  const stop = () => {
    if (isSupported.value) {
      window.speechSynthesis.cancel()
      isSpeaking.value = false
    }
  }

  // 暂停播报
  const pause = () => {
    if (isSupported.value) {
      window.speechSynthesis.pause()
    }
  }

  // 恢复播报
  const resume = () => {
    if (isSupported.value) {
      window.speechSynthesis.resume()
    }
  }

  return {
    isSpeaking,
    isSupported,
    speak,
    stop,
    pause,
    resume
  }
}

6. 触摸手势Hook

useTouch.js - 手势识别

javascript
// composables/useTouch.js
import { ref, onMounted, onUnmounted } from 'vue'

export function useTouch(elementRef, options = {}) {
  const {
    onTap = () => {},
    onDoubleTap = () => {},
    onLongPress = () => {},
    onSwipe = () => {},
    onPinch = () => {},
    swipeThreshold = 50,      // 滑动阈值
    longPressDelay = 500      // 长按延迟
  } = options

  const isTouching = ref(false)
  const touchCount = ref(0)

  let startX = 0
  let startY = 0
  let startTime = 0
  let lastTapTime = 0
  let longPressTimer = null
  let startDistance = 0

  // 计算两点距离
  const getDistance = (touches) => {
    if (touches.length < 2) return 0
    const dx = touches[0].clientX - touches[1].clientX
    const dy = touches[0].clientY - touches[1].clientY
    return Math.sqrt(dx * dx + dy * dy)
  }

  const handleTouchStart = (e) => {
    const touches = e.touches
    touchCount.value = touches.length
    isTouching.value = true

    if (touches.length === 1) {
      // 单指触摸
      startX = touches[0].clientX
      startY = touches[0].clientY
      startTime = Date.now()

      // 设置长按定时器
      longPressTimer = setTimeout(() => {
        onLongPress({ x: startX, y: startY })
      }, longPressDelay)
    } else if (touches.length === 2) {
      // 双指触摸
      startDistance = getDistance(touches)
    }
  }

  const handleTouchMove = (e) => {
    // 移动时清除长按
    if (longPressTimer) {
      clearTimeout(longPressTimer)
      longPressTimer = null
    }

    const touches = e.touches

    if (touches.length === 2) {
      // 双指缩放
      const currentDistance = getDistance(touches)
      const scale = currentDistance / startDistance
      onPinch({ scale, distance: currentDistance })
    }
  }

  const handleTouchEnd = (e) => {
    if (longPressTimer) {
      clearTimeout(longPressTimer)
      longPressTimer = null
    }

    isTouching.value = false
    touchCount.value = 0

    const touches = e.changedTouches
    if (touches.length !== 1) return

    const endX = touches[0].clientX
    const endY = touches[0].clientY
    const endTime = Date.now()

    const deltaX = endX - startX
    const deltaY = endY - startY
    const deltaTime = endTime - startTime
    const distance = Math.sqrt(deltaX * deltaX + deltaY * deltaY)

    // 判断是点击还是滑动
    if (distance < 10 && deltaTime < 300) {
      // 点击
      const now = Date.now()
      if (now - lastTapTime < 300) {
        // 双击
        onDoubleTap({ x: endX, y: endY })
      } else {
        // 单击
        onTap({ x: endX, y: endY })
      }
      lastTapTime = now
    } else if (distance > swipeThreshold) {
      // 滑动
      const direction = Math.abs(deltaX) > Math.abs(deltaY)
        ? (deltaX > 0 ? 'right' : 'left')
        : (deltaY > 0 ? 'down' : 'up')

      onSwipe({ direction, distance, deltaX, deltaY })
    }
  }

  onMounted(() => {
    const element = elementRef.value
    if (!element) return

    element.addEventListener('touchstart', handleTouchStart, { passive: false })
    element.addEventListener('touchmove', handleTouchMove, { passive: false })
    element.addEventListener('touchend', handleTouchEnd, { passive: false })
  })

  onUnmounted(() => {
    const element = elementRef.value
    if (!element) return

    element.removeEventListener('touchstart', handleTouchStart)
    element.removeEventListener('touchmove', handleTouchMove)
    element.removeEventListener('touchend', handleTouchEnd)

    if (longPressTimer) {
      clearTimeout(longPressTimer)
    }
  })

  return {
    isTouching,
    touchCount
  }
}

真实项目经验

有一次客户提出要在大屏上加语音播报功能。他们的需求是当某些关键指标异常时,要自动语音提醒值班人员。我一开始觉得很简单,用浏览器自带的SpeechSynthesis API就行了。

但实际做起来发现问题不少。首先是音质问题,浏览器自带的语音听起来很机械,客户不满意。后来我们尝试集成科大讯飞的语音API,音质确实好多了。但又遇到新问题,语音播报会打断,比如正在播一条告警,又来一条新的,两个声音混在一起。

我们加了一个播报队列,新的告警会排队等待,等前一条播完了再播下一条。还加了优先级机制,紧急告警可以插队。另外考虑到晚上值班室比较安静,我们还做了时段控制,晚上的播报音量会自动降低。

最有意思的是,客户提出要支持自定义播报文本。他们不想要那种"销售额下降20%"这样干巴巴的提示,想要更人性化的表达。我们就做了一个配置界面,可以设置不同场景的播报模板,还支持变量替换。比如"注意,销售额比昨天下降了20%,请及时关注"这样的。

这个功能上线后,客户反馈说确实有用,特别是夜间值班的时候,有了语音提醒就不用一直盯着屏幕了。

使用说明

  1. 事件总线适用于跨组件通信场景,注意及时清理监听器
  2. 数据联动要配合防抖使用,避免频繁更新
  3. 地图钻取需要预加载下一层级数据,提升用户体验
  4. 全屏功能要考虑浏览器兼容性,提供降级方案
  5. 语音播报建议加入队列和优先级机制
  6. 触摸手势要设置合理阈值,避免误触