简历描述模板
设计并实现了大屏可视化系统的交互体系,包括点击钻取、数据联动、全屏切换等核心功能。通过事件总线和状态管理实现了跨组件的数据联动,支持地图点击下钻、图表联动筛选等复杂交互场景。开发了语音播报功能,配合科大讯飞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. 事件总线
eventBus.js - 全局事件通信
// 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 - 跨组件数据联动
// 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 - 可钻取的地图
<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 - 全屏控制
// 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 - 语音合成
// 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 - 手势识别
// 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%,请及时关注"这样的。
这个功能上线后,客户反馈说确实有用,特别是夜间值班的时候,有了语音提醒就不用一直盯着屏幕了。
使用说明
- 事件总线适用于跨组件通信场景,注意及时清理监听器
- 数据联动要配合防抖使用,避免频繁更新
- 地图钻取需要预加载下一层级数据,提升用户体验
- 全屏功能要考虑浏览器兼容性,提供降级方案
- 语音播报建议加入队列和优先级机制
- 触摸手势要设置合理阈值,避免误触