Ver Fonte

完善语音播报相关功能

yawuga há 1 semana atrás
pai
commit
b61c3e43de

+ 80 - 0
src/api/screen.js

@@ -112,6 +112,84 @@ export async function getBroadcastContent() {
   }
 }
 
+// ============================================
+// 播报插播
+// ============================================
+
+// 开发测试开关:启用后跳过真实接口,始终返回 Mock 播报数据
+const ENABLE_BROADCAST_MOCK = true
+
+/**
+ * 获取当前播报状态
+ * 后端生成/缓存 MP3,屏幕端播放 MP3
+ */
+export async function getCurrentBroadcast() {
+  if (ENABLE_BROADCAST_MOCK) {
+    return {
+      code: 200,
+      msg: 'Mock 播报中',
+      data: {
+        broadcasting: true,
+        taskId: 999,
+        contentId: 1001,
+        taskName: '播报插播测试任务',
+        title: '临时通知',
+        content: '欢迎使用迎宾巡逻安防机器人,当前正在测试播报插播功能。播报结束后,系统将自动恢复原来的待机播放状态。',
+        contentType: 'notice',
+        level: 'normal',
+        audioUrl: '/test-audio/broadcast-test.mp3',
+        audioDuration: 8,
+        playMode: 'once',
+        startTime: new Date().toISOString(),
+        estimatedEndTime: '',
+        version: 'mock-broadcast-001'
+      },
+      timestamp: new Date().toISOString().replace('T', ' ').substring(0, 19)
+    }
+  }
+
+  try {
+    return await http.get('/robot-ops/screen/broadcast/current')
+  } catch (error) {
+    console.warn('使用 Mock 数据:getCurrentBroadcast')
+    // 一期返回无播报状态
+    return {
+      code: 200,
+      msg: '当前无播报',
+      data: {
+        broadcasting: false,
+        taskId: null,
+        contentId: null,
+        taskName: '',
+        title: '',
+        content: '',
+        contentType: '',
+        level: 'normal',
+        audioUrl: '',
+        audioDuration: null,
+        playMode: 'once',
+        startTime: null,
+        estimatedEndTime: null,
+        version: ''
+      },
+      timestamp: new Date().toISOString().replace('T', ' ').substring(0, 19)
+    }
+  }
+}
+
+/**
+ * 播报播放完成回执
+ * @param {Object} data { taskId, contentId, resultStatus, resultMsg, playTime }
+ */
+export async function ackBroadcast(data) {
+  try {
+    return await http.post('/robot-ops/screen/broadcast/ack', data)
+  } catch (error) {
+    console.warn('[API] ackBroadcast failed:', error)
+    return { success: false }
+  }
+}
+
 // ============================================
 // 语音指令
 // ============================================
@@ -381,6 +459,8 @@ export default {
   getScreenTheme,
   getBroadcastState,
   getBroadcastContent,
+  getCurrentBroadcast,
+  ackBroadcast,
   getLatestCommand,
   ackCommand,
   readIdCard,

+ 137 - 147
src/components/BroadcastOverlay.vue

@@ -1,206 +1,196 @@
 <template>
   <Teleport to="body">
-    <Transition name="broadcast">
-      <div v-if="showBroadcast" class="broadcast-overlay">
-        <div class="broadcast-card">
-          <div class="broadcast-header">
-            <div class="broadcast-icon">
-              <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
-                <polygon points="11 5 6 9 2 9 2 15 6 15 11 19 11 5" />
-                <path d="M19.07 4.93a10 10 0 0 1 0 14.14M15.54 8.46a5 5 0 0 1 0 7.07" />
-              </svg>
-            </div>
-            <span class="broadcast-label">正在播报</span>
-          </div>
-
-          <div v-if="broadcastState.title" class="broadcast-title">
-            {{ broadcastState.title }}
-          </div>
-
-          <div class="broadcast-content">
-            {{ broadcastState.content }}
-          </div>
-
-          <div class="broadcast-wave">
-            <span class="wave-bar"></span>
-            <span class="wave-bar"></span>
-            <span class="wave-bar"></span>
-            <span class="wave-bar"></span>
-            <span class="wave-bar"></span>
-          </div>
-        </div>
+    <div v-if="show" class="broadcast-bar" @click.stop>
+      <!-- 左侧状态标签 -->
+      <div class="bar-status">
+        <span class="voice-dot"></span>
+        <span>正在播报</span>
       </div>
-    </Transition>
+
+      <!-- 中间内容区 -->
+      <div class="bar-content">
+        <div class="bar-title">{{ displayTitle }}</div>
+        <div class="bar-text">{{ broadcast?.content }}</div>
+      </div>
+
+      <!-- 右侧音频波纹 -->
+      <div class="bar-wave">
+        <span></span>
+        <span></span>
+        <span></span>
+      </div>
+    </div>
   </Teleport>
 </template>
 
 <script setup>
 import { computed } from 'vue'
-import { useScreenStore } from '@/stores/screen'
 
-const screenStore = useScreenStore()
+const props = defineProps({
+  broadcast: {
+    type: Object,
+    default: () => ({})
+  }
+})
 
-const broadcastState = computed(() => screenStore.broadcastState)
+// 是否显示播报条
+const show = computed(() => {
+  return !!(
+    props.broadcast &&
+    props.broadcast.broadcasting === true &&
+    !!props.broadcast.audioUrl
+  )
+})
 
-const showBroadcast = computed(() => {
-  return broadcastState.value.broadcasting
+// 显示标题
+const displayTitle = computed(() => {
+  return props.broadcast?.title || '通知播报'
 })
 </script>
 
 <style scoped>
-.broadcast-overlay {
+.broadcast-bar {
   position: fixed;
-  top: 0;
-  left: 0;
-  right: 0;
-  bottom: 0;
+  left: 50%;
+  bottom: 128px;
+  transform: translateX(-50%);
+  z-index: 1000;
+  width: min(780px, calc(100vw - 120px));
+  min-height: 86px;
+  padding: 14px 22px;
+  border-radius: 26px;
+  background: rgba(15, 23, 42, 0.72);
+  border: 1px solid rgba(255, 255, 255, 0.18);
+  box-shadow: 0 14px 44px rgba(0, 0, 0, 0.28);
+  backdrop-filter: blur(14px);
+  -webkit-backdrop-filter: blur(14px);
   display: flex;
   align-items: center;
-  justify-content: center;
-  background: rgba(0, 0, 0, 0.6);
-  z-index: 9998;
-  backdrop-filter: blur(4px);
+  gap: 20px;
 }
 
-.broadcast-card {
-  width: 90%;
-  max-width: 700px;
-  padding: 40px;
-  background: var(--bg-card);
-  border-radius: var(--radius-xl);
-  box-shadow: var(--shadow-xl);
-  animation: scaleIn 0.3s ease-out;
-}
-
-.broadcast-header {
+/* 左侧状态标签 */
+.bar-status {
+  flex-shrink: 0;
   display: flex;
   align-items: center;
-  gap: 12px;
-  margin-bottom: 24px;
-}
-
-.broadcast-icon {
-  width: 36px;
-  height: 36px;
-  color: var(--primary);
-  animation: pulse 1.5s ease-in-out infinite;
+  gap: 10px;
+  padding: 7px 14px;
+  border-radius: 999px;
+  background: rgba(47, 142, 229, 0.82);
+  color: #fff;
+  font-size: 17px;
+  font-weight: 800;
+  white-space: nowrap;
 }
 
-.broadcast-icon svg {
-  width: 100%;
-  height: 100%;
-}
-
-.broadcast-label {
-  font-size: 18px;
-  font-weight: 500;
-  color: var(--primary);
-  padding: 6px 16px;
-  background: var(--primary-soft);
-  border-radius: var(--radius-full);
+.voice-dot {
+  width: 8px;
+  height: 8px;
+  border-radius: 50%;
+  background: #fff;
+  box-shadow: 0 0 12px rgba(255, 255, 255, 0.8);
+  animation: voicePulse 1.4s ease-in-out infinite;
 }
 
-.broadcast-title {
-  font-size: 28px;
-  font-weight: 600;
-  color: var(--text-primary);
-  margin-bottom: 16px;
-  text-align: center;
+/* 中间内容区 */
+.bar-content {
+  flex: 1;
+  min-width: 0;
 }
 
-.broadcast-content {
+.bar-title {
   font-size: 24px;
-  line-height: 1.8;
-  color: var(--text-secondary);
-  text-align: center;
-  padding: 24px;
-  background: var(--bg-page);
-  border-radius: var(--radius-lg);
-  margin-bottom: 32px;
+  font-weight: 900;
+  color: rgba(255, 255, 255, 0.96);
+  line-height: 1.2;
+  margin-bottom: 5px;
+  white-space: nowrap;
+  overflow: hidden;
+  text-overflow: ellipsis;
 }
 
-.broadcast-wave {
-  display: flex;
-  align-items: flex-end;
-  justify-content: center;
-  gap: 6px;
-  height: 40px;
-}
-
-.wave-bar {
-  width: 6px;
-  background: var(--primary);
-  border-radius: 3px;
-  animation: wave 1s ease-in-out infinite;
+.bar-text {
+  font-size: 19px;
+  font-weight: 500;
+  color: rgba(255, 255, 255, 0.78);
+  line-height: 1.35;
+  display: -webkit-box;
+  -webkit-line-clamp: 2;
+  -webkit-box-orient: vertical;
+  overflow: hidden;
 }
 
-.wave-bar:nth-child(1) {
-  height: 12px;
-  animation-delay: 0s;
+/* 右侧音频波纹 */
+.bar-wave {
+  flex-shrink: 0;
+  display: flex;
+  align-items: center;
+  gap: 5px;
+  width: 36px;
+  height: 30px;
+  opacity: 0.8;
 }
 
-.wave-bar:nth-child(2) {
-  height: 24px;
-  animation-delay: 0.1s;
+.bar-wave span {
+  width: 5px;
+  height: 14px;
+  border-radius: 999px;
+  background: rgba(255, 255, 255, 0.9);
+  animation: waveMove 1s ease-in-out infinite;
 }
 
-.wave-bar:nth-child(3) {
-  height: 36px;
-  animation-delay: 0.2s;
+.bar-wave span:nth-child(2) {
+  animation-delay: 0.15s;
 }
 
-.wave-bar:nth-child(4) {
-  height: 24px;
+.bar-wave span:nth-child(3) {
   animation-delay: 0.3s;
 }
 
-.wave-bar:nth-child(5) {
-  height: 12px;
-  animation-delay: 0.4s;
-}
-
-@keyframes wave {
+@keyframes voicePulse {
   0%, 100% {
-    transform: scaleY(1);
+    opacity: 1;
+    transform: scale(1);
   }
   50% {
-    transform: scaleY(0.5);
+    opacity: 0.55;
+    transform: scale(0.78);
   }
 }
 
-@keyframes scaleIn {
-  from {
-    opacity: 0;
-    transform: scale(0.9);
+@keyframes waveMove {
+  0%, 100% {
+    height: 12px;
+    opacity: 0.55;
   }
-  to {
+  50% {
+    height: 30px;
     opacity: 1;
-    transform: scale(1);
   }
 }
 
-@keyframes pulse {
-  0%, 100% {
-    opacity: 1;
-  }
-  50% {
-    opacity: 0.6;
+/* 响应式适配 */
+@media (max-width: 980px), (max-height: 720px) {
+  .broadcast-bar {
+    bottom: 122px;
+    width: min(740px, calc(100vw - 100px));
+    min-height: 82px;
+    padding: 13px 20px;
+    border-radius: 24px;
   }
-}
 
-/* 动画 */
-.broadcast-enter-active,
-.broadcast-leave-active {
-  transition: all 0.3s ease;
-}
+  .bar-status {
+    font-size: 16px;
+    padding: 7px 13px;
+  }
 
-.broadcast-enter-from,
-.broadcast-leave-to {
-  opacity: 0;
-}
+  .bar-title {
+    font-size: 23px;
+  }
 
-.broadcast-enter-from .broadcast-card,
-.broadcast-leave-to .broadcast-card {
-  transform: scale(0.9);
+  .bar-text {
+    font-size: 18px;
+  }
 }
 </style>

+ 234 - 7
src/components/IdlePlayer.vue

@@ -110,7 +110,7 @@
           :src="currentMedia.url"
           class="media-video"
           :style="mediaFitStyle"
-          :muted="currentMedia.muted !== false"
+          :muted="currentMedia.muted === true"
           autoplay
           playsinline
           preload="auto"
@@ -177,6 +177,7 @@ import { computed, ref, watch, onMounted, onUnmounted } from 'vue'
 import { useScreenStore } from '@/stores/screen'
 import { formatTimeShort, formatDateCN } from '@/utils/time'
 import defaultWelcomeBg from '@/assets/images/default-welcome-bg.png'
+import * as api from '@/api/screen'
 
 const emit = defineEmits(['click'])
 
@@ -191,6 +192,13 @@ const videoEl = ref(null)
 const isTransitioning = ref(false)
 const isVideoPlaying = ref(false)
 
+// 播报音频相关
+const broadcastAudio = ref(null)
+// 记录已播放的播报 key,避免同一 taskId+version 每 2 秒重复播放
+const lastPlayedBroadcastKey = ref('')
+// 播报开始时缓存一份 snapshot,结束时 ack 用(避免 markBroadcastFinishedLocally 清空后读取不到)
+const lastBroadcastSnapshot = ref(null)
+
 const actualMode = computed(() => screenStore.actualIdleMode)
 
 const hasMedia = computed(() => {
@@ -359,6 +367,7 @@ const startImageTimer = () => {
   clearImageTimer()
   if (!currentMedia.value || currentMedia.value.type !== 'image') return
   const duration = currentMedia.value.duration || 8000
+  console.log('[Broadcast] 图片轮播定时器恢复', currentMedia.value?.duration)
   imageTimer = setTimeout(() => {
     goNextMedia()
     startImageTimer()
@@ -380,7 +389,25 @@ const onVideoEnded = () => {
 
 // 播放方案:素材加载成功
 const onMediaLoad = () => {
-  // nothing
+  // 视频加载后同步全局音量/静音状态;播报中保持视频静音
+  if (currentMedia.value?.type === 'video' && videoEl.value) {
+    if (screenStore.isBroadcastAudioPlaying) {
+      videoEl.value.muted = true
+      videoEl.value.volume = 0
+      try {
+        videoEl.value.pause()
+      } catch (err) {
+        console.warn('[IdlePlayer] 播报中暂停视频失败:', err)
+      }
+      return
+    }
+    const volumeValue = Number(screenStore.volume ?? 80)
+    const shouldMute = screenStore.muted || currentMedia.value.muted === true
+    videoEl.value.muted = shouldMute
+    videoEl.value.volume = shouldMute
+      ? 0
+      : Math.max(0, Math.min(1, volumeValue / 100))
+  }
 }
 
 // 播放方案:素材加载失败,跳下一条
@@ -436,11 +463,6 @@ onMounted(() => {
   }
 })
 
-onUnmounted(() => {
-  if (clockTimer) clearInterval(clockTimer)
-  clearImageTimer()
-})
-
 // 播放方案:mode 切换时管理定时器
 watch(actualMode, (newMode) => {
   if (newMode === 'playlist') {
@@ -450,6 +472,211 @@ watch(actualMode, (newMode) => {
     clearImageTimer()
   }
 })
+
+// =============================================
+// 播报插播:播放音频 + 暂停/恢复素材
+// =============================================
+
+/**
+ * 开始播放播报音频
+ */
+const playBroadcastAudio = (audioUrl) => {
+  if (!audioUrl) return
+
+  // 停止之前的音频
+  stopBroadcastAudio()
+
+  // 创建新的 Audio 实例
+  broadcastAudio.value = new Audio()
+  broadcastAudio.value.src = audioUrl
+  const volumeValue = Number(screenStore.volume ?? 80)
+  broadcastAudio.value.volume = screenStore.muted
+    ? 0
+    : Math.max(0, Math.min(1, volumeValue / 100))
+
+  // 播放结束
+  broadcastAudio.value.onended = () => {
+    console.log('[IdlePlayer] 播报音频播放结束')
+    onBroadcastEnded()
+  }
+
+  // 播放出错
+  broadcastAudio.value.onerror = (e) => {
+    console.warn('[IdlePlayer] 播报音频播放失败:', e)
+    onBroadcastError()
+  }
+
+  // 开始播放
+  broadcastAudio.value.play().catch(err => {
+    console.warn('[IdlePlayer] 播报音频播放被阻止:', err)
+    // 浏览器阻止自动播放时,恢复素材
+    onBroadcastError()
+  })
+  console.log('[Broadcast] 播放播报音频:', screenStore.getBroadcastKey(screenStore.currentBroadcast), audioUrl)
+}
+
+/**
+ * 停止播报音频
+ */
+const stopBroadcastAudio = () => {
+  if (broadcastAudio.value) {
+    broadcastAudio.value.pause()
+    broadcastAudio.value.src = ''
+    broadcastAudio.value = null
+  }
+}
+
+/**
+ * 播报音频播放结束
+ */
+const onBroadcastEnded = () => {
+  console.log('[Broadcast] onBroadcastEnded 触发,准备恢复素材', {
+    actualMode: actualMode.value,
+    mediaType: currentMedia.value?.type,
+    mediaUrl: currentMedia.value?.url,
+    videoExists: !!videoEl.value
+  })
+  screenStore.setBroadcastAudioPlaying(false)
+  // 先恢复素材播放,再标记播报完成
+  resumeMediaForBroadcast()
+  // 再本地标记播报完成并隐藏播报条
+  screenStore.markBroadcastFinishedLocally()
+  // 最后异步 ack,不阻塞恢复播放
+  api.ackBroadcast({
+    taskId: lastBroadcastSnapshot.value?.taskId,
+    contentId: lastBroadcastSnapshot.value?.contentId,
+    resultStatus: 'success',
+    resultMsg: '播放完成',
+    playTime: new Date().toISOString()
+  }).catch(() => {})
+}
+
+/**
+ * 播报音频播放失败
+ */
+const onBroadcastError = () => {
+  console.warn('[Broadcast] onBroadcastError 触发,准备恢复素材', {
+    actualMode: actualMode.value,
+    mediaType: currentMedia.value?.type,
+    mediaUrl: currentMedia.value?.url,
+    videoExists: !!videoEl.value
+  })
+  screenStore.setBroadcastAudioPlaying(false)
+  // 播报失败也要先恢复素材
+  resumeMediaForBroadcast()
+  screenStore.markBroadcastFinishedLocally()
+  api.ackBroadcast({
+    taskId: lastBroadcastSnapshot.value?.taskId,
+    contentId: lastBroadcastSnapshot.value?.contentId,
+    resultStatus: 'failed',
+    resultMsg: '播放失败',
+    playTime: new Date().toISOString()
+  }).catch(() => {})
+}
+
+/**
+ * 播报开始时,暂停当前素材
+ */
+const pauseMediaForBroadcast = () => {
+  // 暂停图片轮播
+  clearImageTimer()
+  // 暂停视频,并强制静音,避免视频声音与播报 MP3 同时输出
+  if (videoEl.value) {
+    try {
+      videoEl.value.pause()
+    } catch (err) {
+      console.warn('[IdlePlayer] 暂停视频失败:', err)
+    }
+    videoEl.value.muted = true
+    videoEl.value.volume = 0
+  }
+}
+
+/**
+ * 播报结束后,恢复素材播放
+ */
+const resumeMediaForBroadcast = () => {
+  console.log('[Broadcast] resumeMediaForBroadcast 执行', {
+    actualMode: actualMode.value,
+    mediaType: currentMedia.value?.type,
+    mediaUrl: currentMedia.value?.url,
+    videoPaused: videoEl.value?.paused,
+    videoMuted: videoEl.value?.muted,
+    videoVolume: videoEl.value?.volume
+  })
+  // 只有在播放方案模式下才恢复
+  if (actualMode.value !== 'playlist') return
+  const media = currentMedia.value
+  if (!media) return
+  if (media.type === 'image') {
+    clearImageTimer()
+    startImageTimer()
+    console.log('[Broadcast] 图片轮播已恢复')
+    return
+  }
+  if (media.type === 'video' && videoEl.value) {
+    const volumeValue = Number(screenStore.volume ?? 80)
+    const shouldMute = screenStore.muted || media.muted === true
+    videoEl.value.muted = shouldMute
+    videoEl.value.volume = shouldMute
+      ? 0
+      : Math.max(0, Math.min(1, volumeValue / 100))
+    // 确保视频元素加载状态可播放
+    const tryPlay = () => {
+      videoEl.value.play()
+        .then(() => {
+          console.log('[Broadcast] 视频已恢复播放')
+        })
+        .catch(err => {
+          console.warn('[Broadcast] 视频恢复播放失败,尝试重新 load 后播放:', err)
+          try {
+            videoEl.value.load()
+            setTimeout(() => {
+              videoEl.value.play().catch(e => {
+                console.warn('[Broadcast] 视频二次恢复播放失败:', e)
+              })
+            }, 120)
+          } catch (e) {
+            console.warn('[Broadcast] 视频 load 恢复失败:', e)
+          }
+        })
+    }
+    tryPlay()
+  }
+}
+
+/**
+ * 监听播报状态变化
+ */
+// 当前播报的 key,用于监听 taskId/version/audioUrl 变化触发新播报
+const activeBroadcastKey = computed(() => {
+  if (!screenStore.isBroadcasting) return ''
+  return screenStore.getBroadcastKey(screenStore.currentBroadcast)
+})
+
+watch(activeBroadcastKey, (key, oldKey) => {
+  if (!key) {
+    // 素材恢复统一由 onBroadcastEnded / onBroadcastError 负责
+    return
+  }
+  const broadcast = screenStore.currentBroadcast
+  if (!broadcast || !broadcast.audioUrl) return
+  // 避免重复播放同一播报
+  if (key === lastPlayedBroadcastKey.value) {
+    return
+  }
+  lastPlayedBroadcastKey.value = key
+  lastBroadcastSnapshot.value = { ...broadcast }
+  pauseMediaForBroadcast()
+  screenStore.setBroadcastAudioPlaying(true)
+  playBroadcastAudio(broadcast.audioUrl)
+})
+
+onUnmounted(() => {
+  if (clockTimer) clearInterval(clockTimer)
+  clearImageTimer()
+  stopBroadcastAudio()
+})
 </script>
 
 <style scoped>

+ 153 - 0
src/stores/screen.js

@@ -41,6 +41,33 @@ export const useScreenStore = defineStore('screen', () => {
     endTime: null
   })
 
+  // 当前播报状态(新版,用于播报插播)
+  const currentBroadcast = ref({
+    broadcasting: false,
+    taskId: null,
+    contentId: null,
+    taskName: '',
+    title: '',
+    content: '',
+    contentType: '',
+    level: 'normal',
+    audioUrl: '',
+    audioDuration: null,
+    playMode: 'once',
+    startTime: null,
+    estimatedEndTime: null,
+    version: ''
+  })
+
+  // 播报轮询定时器
+  const broadcastPollingTimer = ref(null)
+
+  // 是否正在播放播报音频
+  const isBroadcastAudioPlaying = ref(false)
+
+  // 已完成播报的 key 集合(用于避免同一播报重复播放)
+  const finishedBroadcastKeys = ref(new Set())
+
   // 语音指令
   const latestCommand = ref(null)
 
@@ -74,6 +101,15 @@ export const useScreenStore = defineStore('screen', () => {
   const hasFault = computed(() => robotStatus.value.faultFlag)
   const networkOk = computed(() => robotStatus.value.networkStatus === 'online')
 
+  // 是否正在播报(有播报状态、audioUrl 有值、且未被标记为已完成)
+  const isBroadcasting = computed(() => {
+    const b = currentBroadcast.value
+    if (!b || b.broadcasting !== true || !b.audioUrl) return false
+    const key = getBroadcastKey(b)
+    if (key && finishedBroadcastKeys.value.has(key)) return false
+    return true
+  })
+
   const currentMedia = computed(() => {
     if (!playPlan.value || !playPlan.value.items || playPlan.value.items.length === 0) {
       return null
@@ -223,6 +259,111 @@ export const useScreenStore = defineStore('screen', () => {
     }
   }
 
+  /**
+   * 生成播报唯一 key
+   */
+  function getBroadcastKey(broadcast) {
+    if (!broadcast) return ''
+    return [
+      broadcast.taskId || '',
+      broadcast.contentId || '',
+      broadcast.version || '',
+      broadcast.audioUrl || ''
+    ].join('_')
+  }
+
+  /**
+   * 获取空的播报状态对象
+   */
+  function emptyBroadcast() {
+    return {
+      broadcasting: false,
+      taskId: null,
+      contentId: null,
+      taskName: '',
+      title: '',
+      content: '',
+      contentType: '',
+      level: 'normal',
+      audioUrl: '',
+      audioDuration: null,
+      playMode: 'once',
+      startTime: null,
+      estimatedEndTime: null,
+      version: ''
+    }
+  }
+
+  /**
+   * 获取当前播报状态(播报插播专用)
+   * 会过滤已完成播报,避免重复播放
+   */
+  async function fetchCurrentBroadcast() {
+    try {
+      const res = await api.getCurrentBroadcast()
+      const data = res && res.data !== undefined ? res.data : res
+      const key = getBroadcastKey(data)
+      if (data?.broadcasting === true && key && finishedBroadcastKeys.value.has(key)) {
+        console.log('[Broadcast] 已播放完成的播报,本地忽略:', key)
+        return
+      }
+      if (data?.broadcasting === false) {
+        finishedBroadcastKeys.value.clear()
+      }
+      currentBroadcast.value = data || emptyBroadcast()
+      console.log('[Broadcast] 播报状态更新:', data)
+    } catch (e) {
+      console.warn('[Store] 获取当前播报状态失败:', e)
+    }
+  }
+
+  /**
+   * 开始播报状态轮询(每 2 秒一次)
+   */
+  function startBroadcastPolling() {
+    // 避免重复启动
+    if (broadcastPollingTimer.value) {
+      return
+    }
+
+    // 立即请求一次
+    fetchCurrentBroadcast()
+
+    // 每 2 秒轮询一次
+    broadcastPollingTimer.value = setInterval(() => {
+      fetchCurrentBroadcast()
+    }, 2000)
+  }
+
+  /**
+   * 停止播报状态轮询
+   */
+  function stopBroadcastPolling() {
+    if (broadcastPollingTimer.value) {
+      clearInterval(broadcastPollingTimer.value)
+      broadcastPollingTimer.value = null
+    }
+  }
+
+  /**
+   * 设置播报音频播放状态
+   */
+  function setBroadcastAudioPlaying(value) {
+    isBroadcastAudioPlaying.value = value
+  }
+
+  /**
+   * 本地标记播报结束(用于 audio ended/error 后隐藏浮层并记录已播放)
+   */
+  function markBroadcastFinishedLocally() {
+    const key = getBroadcastKey(currentBroadcast.value)
+    if (key) {
+      finishedBroadcastKeys.value.add(key)
+      console.log('[Broadcast] 标记播报为已完成:', key)
+    }
+    currentBroadcast.value = emptyBroadcast()
+  }
+
   async function fetchLatestCommand() {
     try {
       const res = await api.getLatestCommand()
@@ -304,6 +445,10 @@ export const useScreenStore = defineStore('screen', () => {
     pendingPlayPlan,
     currentPlanCycleComplete,
     broadcastState,
+    currentBroadcast,
+    broadcastPollingTimer,
+    isBroadcastAudioPlaying,
+    finishedBroadcastKeys,
     latestCommand,
     globalAlert,
     volume,
@@ -320,6 +465,8 @@ export const useScreenStore = defineStore('screen', () => {
     networkOk,
     currentMedia,
     actualIdleMode,
+    isBroadcasting,
+    getBroadcastKey,
     // 方法
     fetchScreenConfig,
     fetchScreenTheme,
@@ -329,6 +476,12 @@ export const useScreenStore = defineStore('screen', () => {
     discardPendingPlayPlan,
     markCurrentPlanCycleComplete,
     fetchBroadcastState,
+    fetchCurrentBroadcast,
+    startBroadcastPolling,
+    stopBroadcastPolling,
+    setBroadcastAudioPlaying,
+    markBroadcastFinishedLocally,
+    emptyBroadcast,
     fetchLatestCommand,
     ackCommand,
     nextMedia,

+ 6 - 0
src/views/idle/Index.vue

@@ -1,6 +1,7 @@
 <template>
   <div class="page-idle" @click="goToMenu">
     <IdlePlayer @click="goToMenu" />
+    <BroadcastOverlay :broadcast="screenStore.currentBroadcast" />
   </div>
 </template>
 
@@ -9,6 +10,7 @@ import { ref, onMounted, onUnmounted } from 'vue'
 import { useRouter } from 'vue-router'
 import { useScreenStore } from '@/stores/screen'
 import IdlePlayer from '@/components/IdlePlayer.vue'
+import BroadcastOverlay from '@/components/BroadcastOverlay.vue'
 
 const router = useRouter()
 const screenStore = useScreenStore()
@@ -45,11 +47,15 @@ onMounted(() => {
   screenStore.fetchRobotStatus()
   // 开始轮询播放方案
   startPlayPlanPolling()
+  // 开始播报状态轮询(每 2 秒一次)
+  screenStore.startBroadcastPolling()
 })
 
 onUnmounted(() => {
   // 组件销毁时停止轮询
   stopPlayPlanPolling()
+  // 停止播报状态轮询
+  screenStore.stopBroadcastPolling()
 })
 </script>
 

+ 173 - 11
迎宾巡逻安防机器人机身屏交互系统详细设计开发文档(一期).html

@@ -157,17 +157,25 @@
     <h3>6.2 播报内容插播流程</h3>
     <div class="flow">小主机后端判断播报任务到时
-后端调用 TTS / 语音服务播放播报文字
+后端读取播报内容文本
-后端生成屏幕播报指令
+后端判断是否已有可用 MP3
+├─ 已有且未失效:复用 MP3
+└─ 没有或已失效:调用第三方 TTS 生成 MP3
-屏幕前端轮询获取播报指令
+current 接口返回 broadcasting=true、title、content、audioUrl、audioDuration
-暂停当前素材广告或欢迎页
+屏幕前端轮询获取当前播报状态
-展示播报内容文字卡片与“正在播报”状态
+暂停当前图片轮播或视频播放
-播报结束
+显示播报浮层
+↓
+屏幕端播放 audioUrl 对应 MP3
+↓
+audio ended 或 audio error
+↓
+隐藏播报浮层
 恢复原待机播放状态</div>
     <h3>6.3 预约到访流程</h3>
@@ -306,19 +314,30 @@
     <h3>8.1 内容优先级</h3>
     <table><thead><tr><th>优先级</th><th>内容类型</th><th>处理规则</th></tr></thead><tbody>
       <tr><td>最高</td><td>紧急异常 / 告警提示</td><td>立即打断当前页面,展示全局异常提示。</td></tr>
-      <tr><td>高</td><td>播报内容</td><td>暂停素材广告,展示播报文字,由后端调用 TTS/语音服务播放。</td></tr>
+      <tr><td>高</td><td>播报内容</td><td>暂停素材广告,展示播报文字,后端生成 MP3,屏幕端播放。</td></tr>
       <tr><td>普通</td><td>素材广告</td><td>作为待机主内容播放图片或视频。</td></tr>
       <tr><td>最低</td><td>欢迎页</td><td>无播放方案时的兜底页面。</td></tr>
     </tbody></table>
     <h3>8.2 声音来源与职责</h3>
     <table><thead><tr><th>声音来源</th><th>归属</th><th>说明</th></tr></thead><tbody>
       <tr><td>视频素材声音</td><td>屏幕端</td><td>视频播放时可通过喇叭输出,屏幕端需要支持静音和音量控制。</td></tr>
-      <tr><td>播报内容语音</td><td>小主机后端/语音服务</td><td>数据库只有文字,由后端调用 TTS 或语音服务播放,屏幕端展示文字。</td></tr>
+      <tr><td>播报内容语音</td><td>屏幕端播放后端 MP3</td><td>数据库播报内容以文字为主;一期由后端调用第三方 TTS 生成并缓存 MP3 文件,屏幕端通过当前播报状态接口获取 audioUrl 后进行播放,同时展示播报文字和状态。</td></tr>
       <tr><td>登记成功/导航到达/异常提示音</td><td>屏幕端或本地服务</td><td>可根据后续实现选择前端音频文件播放或后端统一播放。</td></tr>
       <tr><td>语音对话声音</td><td>语音服务</td><td>不归屏幕系统控制,屏幕端只展示识别结果或跳转页面。</td></tr>
     </tbody></table>
     <h3>8.3 音量与静音控制</h3>
     <ul><li>屏幕端应提供静音/取消静音和音量调节能力,优先作用于视频素材和前端提示音。</li><li>当播报内容或语音对话发生时,屏幕端应暂停或静音当前视频素材。</li><li>播报结束后,屏幕端恢复素材播放状态。</li><li>语音服务和浏览器同时输出到同一喇叭时,应由小主机后端协调音频抢占策略。</li></ul>
+    <h3>8.4 播报插播运行规则</h3>
+    <ul>
+      <li>播报内容优先级高于素材广告和默认欢迎页。</li>
+      <li>播报任务到时后,后端负责调用第三方 TTS 生成或复用 MP3 文件,并通过当前播报状态接口返回 audioUrl;屏幕端负责播放该 MP3,并通过 audio ended 事件感知播报结束。</li>
+      <li>播报开始时,屏幕端暂停当前图片轮播或视频播放,并播放播报 MP3。</li>
+      <li>播报播放期间,屏幕端显示播报浮层。</li>
+      <li>播报 MP3 播放结束后,屏幕端隐藏播报浮层,并恢复原素材播放状态。</li>
+      <li>播报插播不应重置当前播放方案和当前素材索引。</li>
+      <li>如果 MP3 播放失败,屏幕端应记录 warning 日志,并恢复素材播放。</li>
+      <li>一期只在待机页执行播报插播,不强制打断访客登记、路线引导等业务办理页面。</li>
+    </ul>
   </div>
 
   <div class="section" id="s9"><h2>9. 语音指令与事件通信设计</h2>
@@ -444,7 +463,8 @@
       <tr><td>/screen/config</td><td>GET</td><td>获取屏幕配置</td><td>robotName、logoUrl、idleTimeout、theme、volume、mute</td></tr>
       <tr><td>/screen/status</td><td>GET</td><td>获取机器人简要状态</td><td>batteryLevel、networkStatus、workStatus、chargeStatus、faultFlag</td></tr>
       <tr><td>/robot-ops/screen/play-plan/current</td><td>GET</td><td>获取当前播放方案</td><td>enabled、planId、planName、loopMode、defaultFitMode、version、items</td></tr>
-      <tr><td>/screen/broadcast/current</td><td>GET</td><td>获取当前播报状态</td><td>broadcasting、title、content、startTime、endTime</td></tr>
+      <tr><td>/robot-ops/screen/broadcast/current</td><td>GET</td><td>获取当前播报状态</td><td>broadcasting、taskId、contentId、title、content、contentType、audioUrl、audioDuration、playMode、startTime、estimatedEndTime、version</td></tr>
+      <tr><td>/robot-ops/screen/broadcast/ack</td><td>POST</td><td>播报播放完成回执</td><td>taskId、contentId、resultStatus、resultMsg、playTime</td></tr>
       <tr><td>/screen/command/latest</td><td>GET</td><td>获取最新语音/系统指令</td><td>commandId、type、action、payload、timestamp</td></tr>
       <tr><td>/screen/command/ack</td><td>POST</td><td>指令处理回执</td><td>commandId、resultStatus、resultMsg</td></tr>
       <tr><td>/screen/id-card/read</td><td>POST</td><td>读取身份证</td><td>name、idCardNo、gender、nation、address、photoUrl</td></tr>
@@ -597,6 +617,120 @@
       <tr><td>方案被取消或素材为空</td><td>当前素材结束后回退默认欢迎页</td><td>避免播放过程中突兀黑屏或直接中断。</td></tr>
     </tbody></table>
     <div class="note">后续如需降低接口数据量,可扩展轻量版本检查接口 <code class="inline">GET /robot-ops/screen/play-plan/version</code>。屏幕端每 30-60 秒请求 version,只有版本变化时再请求 current 完整播放方案。一期建议先使用 current 接口轮询,降低前后端联调复杂度。</div>
+
+    <h3>13.2 当前播报状态接口详细设计</h3>
+    <div class="info">本接口供机身屏 <code class="inline">/idle</code> 待机页获取当前播报状态。前端根据 broadcasting 判断是否显示播报浮层,并暂停或恢复当前素材播放。</div>
+
+    <h4>13.2.1 接口基本信息</h4>
+    <table><thead><tr><th>项</th><th>设计内容</th></tr></thead><tbody>
+      <tr><td>接口地址</td><td><code class="inline">GET /robot-ops/screen/broadcast/current</code></td></tr>
+      <tr><td>接口用途</td><td>供机身屏 <code class="inline">/idle</code> 待机页获取当前播报状态。前端根据 broadcasting 判断是否显示播报浮层、播放 MP3,并暂停或恢复当前素材播放。</td></tr>
+      <tr><td>数据来源</td><td>运维端播报内容管理与播报任务管理。后端关联播报任务和播报内容后,生成 MP3 并返回当前播报文本和音频地址。</td></tr>
+      <tr><td>轮询频率</td><td>一期建议待机页每 2 秒轮询一次。</td></tr>
+      <tr><td>TTS 职责</td><td>后端负责播报文本转 MP3、音频缓存、任务命中判断和音频地址返回;屏幕端负责根据 audioUrl 播放 MP3、展示播报浮层、暂停和恢复当前素材播放。</td></tr>
+      <tr><td>生效范围</td><td>一期优先只在 <code class="inline">/idle</code> 待机页执行播报插播;访客登记、路线引导等业务办理页面暂不强制打断。</td></tr>
+    </tbody></table>
+
+    <h4>13.2.2 数据来源与字段映射</h4>
+    <table><thead><tr><th>来源表</th><th>来源字段</th><th>接口字段</th><th>说明</th></tr></thead><tbody>
+      <tr><td>robot_ops_broadcast_task</td><td>id</td><td>taskId</td><td>播报任务 ID。</td></tr>
+      <tr><td>robot_ops_broadcast_task</td><td>task_name</td><td>taskName</td><td>播报任务名称。</td></tr>
+      <tr><td>robot_ops_broadcast_task</td><td>content_id</td><td>contentId</td><td>关联播报内容 ID。</td></tr>
+      <tr><td>robot_ops_broadcast_task</td><td>start_time</td><td>startTime</td><td>本次播报开始时间。</td></tr>
+      <tr><td>robot_ops_broadcast_task</td><td>end_time、frequency_minutes、cycle_type、cycle_value、status</td><td>后端判断依据</td><td>由后端根据任务规则判断是否命中当前播报,前端不参与计算。</td></tr>
+      <tr><td>robot_ops_broadcast_content</td><td>content_name</td><td>title</td><td>播报标题。</td></tr>
+      <tr><td>robot_ops_broadcast_content</td><td>content_type</td><td>contentType</td><td>播报分类,例如通知、宣传、提示、安防提醒、自定义。</td></tr>
+      <tr><td>robot_ops_broadcast_content</td><td>broadcast_text</td><td>content</td><td>播报文本,用于屏幕展示,并由后端调用第三方 TTS 生成 MP3。</td></tr>
+      <tr><td>robot_ops_broadcast_content</td><td>status</td><td>过滤依据</td><td>只有启用状态的播报内容可被任务触发。</td></tr>
+    </tbody></table>
+
+    <h4>13.2.3 有播报返回示例</h4>
+    <div class="code">{
+  "code": 200,
+  "msg": "查询成功",
+  "data": {
+    "broadcasting": true,
+    "taskId": 12,
+    "contentId": 35,
+    "taskName": "大厅整点提醒",
+    "title": "参观提醒",
+    "content": "欢迎各位来宾参观,请按照现场工作人员引导有序通行。",
+    "contentType": "notice",
+    "level": "normal",
+    "audioUrl": "http://192.168.0.30/profile/audio/broadcast/35_abc123.mp3",
+    "audioDuration": 20.5,
+    "playMode": "once",
+    "startTime": "2026-05-18 16:30:00",
+    "estimatedEndTime": "2026-05-18 16:30:20",
+    "version": "20260518163000"
+  },
+  "timestamp": "2026-05-18 16:30:01"
+}</div>
+
+    <h4>13.2.4 无播报返回示例</h4>
+    <div class="code">{
+  "code": 200,
+  "msg": "当前无播报",
+  "data": {
+    "broadcasting": false,
+    "taskId": null,
+    "contentId": null,
+    "taskName": "",
+    "title": "",
+    "content": "",
+    "contentType": "",
+    "level": "normal",
+    "audioUrl": "",
+    "audioDuration": null,
+    "playMode": "once",
+    "startTime": null,
+    "estimatedEndTime": null,
+    "version": "20260518163100"
+  },
+  "timestamp": "2026-05-18 16:31:00"
+}</div>
+
+    <h4>13.2.5 屏幕端处理规则</h4>
+    <ul>
+      <li>屏幕端进入 <code class="inline">/idle</code> 后立即请求一次当前播报状态接口,并每 2 秒轮询一次。</li>
+      <li>当 broadcasting=true 且 audioUrl 不为空时,屏幕端显示播报浮层,展示播报标题、播报正文和"正在播报"状态。</li>
+      <li>当前为图片素材时,播报开始后暂停图片轮播定时器,并播放 audioUrl 对应 MP3;播报结束后恢复图片轮播。</li>
+      <li>当前为视频素材时,播报开始后暂停视频播放,并播放 audioUrl 对应 MP3;播报结束后继续播放视频。</li>
+      <li>当前为默认欢迎页时,播报开始后显示播报浮层并播放 MP3;播报结束后隐藏播报浮层。</li>
+      <li>屏幕端应通过 audio ended 事件判断播报播放完成。</li>
+      <li>播报插播不应重置当前播放方案和当前素材索引。</li>
+      <li>如果 audioUrl 为空、MP3 加载失败或播放失败,屏幕端应记录 warning 日志,并恢复原素材播放状态。</li>
+      <li>接口请求失败或后端播报服务异常时,不影响当前素材播放。</li>
+    </ul>
+
+    <h4>13.2.6 后端与 TTS 处理建议</h4>
+    <ul>
+      <li>一期 TTS 不采用流式 TTS,统一采用"第三方 TTS 生成 MP3 文件 + 屏幕端播放 MP3"的方式实现。</li>
+      <li>后端根据播报任务的开始时间、结束时间、频率、循环类型和循环取值判断当前是否命中播报。</li>
+      <li>后端应避免同一任务在同一时间窗口内重复触发。</li>
+      <li>播报任务命中后,后端读取播报内容文本,判断该内容是否已有可用 MP3 文件;如果已有且未失效,则直接复用;如果没有或内容已修改,则调用第三方 TTS 重新生成 MP3。</li>
+      <li>MP3 文件建议保存到小主机本地目录,例如 <code class="inline">/data/robot/audio/broadcast/</code>,并通过静态资源服务暴露为屏幕端可访问的 audioUrl,例如 <code class="inline">http://192.168.0.30/profile/audio/broadcast/35_abc123.mp3</code>。</li>
+      <li>MP3 是否需要重新生成,应根据播报文本、音色、语速、音量、TTS 供应商和模型版本等生成 Hash 进行判断;Hash 不一致或本地文件不存在时重新生成。</li>
+      <li>后端在 current 接口中返回 broadcasting=true、audioUrl、audioDuration、title、content 等字段。</li>
+      <li>屏幕端负责播放 MP3。后端不直接控制浏览器音频播放,也不直接负责暂停/恢复素材。</li>
+      <li>后端可根据任务命中状态和播放窗口维护当前播报状态;如增加播放完成回执接口,则以后端收到 ack 后结束本次播报状态。</li>
+      <li>如果 TTS 生成失败,应记录失败原因,并避免影响素材播放;current 接口可返回 broadcasting=false 或返回错误状态,具体按后端实现处理。</li>
+      <li>后端应封装统一 TtsService,不要将第三方 TTS API 直接写死在播报任务逻辑中。</li>
+    </ul>
+
+    <h4>13.2.7 MP3 文件生成与复用规则</h4>
+    <table><thead><tr><th>场景</th><th>处理规则</th><th>说明</th></tr></thead><tbody>
+      <tr><td>首次播报</td><td>调用第三方 TTS 生成 MP3</td><td>生成完成后保存本地文件,并记录音频路径、访问 URL、时长、Hash 和生成状态。</td></tr>
+      <tr><td>重复播报相同内容</td><td>复用已生成 MP3</td><td>避免每次播报都请求第三方 TTS,降低成本和延迟。</td></tr>
+      <tr><td>播报文本修改</td><td>重新生成 MP3</td><td>文本变化后 Hash 变化,旧音频失效。</td></tr>
+      <tr><td>音色/语速/音量/供应商参数修改</td><td>重新生成 MP3</td><td>同一文本在不同语音参数下应视为不同音频。</td></tr>
+      <tr><td>本地音频文件丢失</td><td>重新生成 MP3</td><td>数据库有记录但文件不存在时应自动补偿生成。</td></tr>
+      <tr><td>MP3 播放失败</td><td>屏幕端记录失败并恢复素材播放</td><td>不应阻塞待机素材播放,后续可通过事件上报或运行日志记录。</td></tr>
+      <tr><td>TTS 生成失败</td><td>后端记录失败状态和错误信息</td><td>不返回可播放 audioUrl,屏幕端不进入播报播放状态。</td></tr>
+    </tbody></table>
+    <p>建议 Hash 计算口径为:<code class="inline">md5(播报文本 + 音色 + 语速 + 音量 + TTS供应商 + 模型版本)</code></p>
+    <p>后端应封装统一 TtsService。屏幕端应封装统一音频播放逻辑,用于播放播报 MP3、监听 ended/error 事件,并在播放完成或失败后恢复素材播放。</p>
+    <div class="note">一期如后端需要确认屏幕端实际播放完成,可提供 POST <code class="inline">/robot-ops/screen/broadcast/ack</code>。屏幕端在 audio ended 后上报 success,在 audio error 后上报 failed。若一期暂不做 ack,后端可根据播报窗口或 estimatedEndTime 结束播报状态。</div>
   </div>
 
   <div class="section" id="s14"><h2>14. 开发优先级与实施顺序</h2>
@@ -606,7 +740,7 @@
       <tr><td>第三阶段</td><td>主菜单页、上方欢迎引导、2×2 大触摸入口、返回待机按钮、图标与文案优化</td><td>已完成前端收口,后续重点进入访客登记流程开发。</td></tr>
       <tr><td>第四阶段</td><td>访客登记、预约核验、身份证读取 Mock、数字键盘</td><td>完成核心登记流程。</td></tr>
       <tr><td>第五阶段</td><td>路线引导正式前端流程、目的地 Mock、导航状态 Mock</td><td>完成路线引导演示闭环。</td></tr>
-      <tr><td>第六阶段</td><td>通知公告列表、播报内容插播、声音/静音控制</td><td>完成公告展示和插播体验。</td></tr>
+      <tr><td>第六阶段</td><td>当前播报状态接口、播报浮层、素材暂停/恢复、通知公告列表、声音/静音控制</td><td>优先跑通运维端播报内容与播报任务到机身屏插播展示链路。</td></tr>
       <tr><td>第七阶段</td><td>语音指令轮询 Mock、人脸识别结果 Mock、全局异常提示</td><td>完成事件驱动页面跳转能力。</td></tr>
       <tr><td>第八阶段</td><td>隐藏系统信息页、屏幕刷新、版本展示</td><td>补充现场维护辅助能力。</td></tr>
     </tbody></table>
@@ -620,7 +754,14 @@
       <li>1024×768 分辨率下所有页面无横向滚动、无明显遮挡。</li><li>主菜单四个入口清晰可点击,按钮尺寸适合 8 寸触摸屏。</li><li>长时间无操作可自动返回待机。</li><li>访客登记流程完整,支持预约到访和现场登记。</li><li>手机号和身份证号输入使用内置数字键盘。</li><li>路线引导页面流程完整,可从目的地选择到模拟到达。</li><li>通知公告列表和详情可正常展示。</li><li>呼叫工作人员入口有明确占位反馈。</li>
     </ul>
     <h3>15.2 播放与声音验收</h3>
-    <ul><li>待机状态有播放方案时播放素材,无播放方案时显示欢迎页。</li><li>视频素材可播放声音,支持静音和音量调节。</li><li>播报内容插播时可暂停素材广告,播报结束后恢复。</li><li>异常提示优先级高于播报和素材广告。</li></ul>
+    <ul>
+      <li>待机状态有播放方案时播放素材,无播放方案时显示欢迎页。</li>
+      <li>视频素材可播放声音,支持静音和音量调节。</li>
+      <li>播报内容插播时可暂停当前图片轮播或视频播放,播报结束后恢复原素材播放状态。</li>
+      <li>屏幕端在待机页应每 2 秒轮询一次 <code class="inline">/robot-ops/screen/broadcast/current</code>,当返回 broadcasting=true 时显示播报浮层。</li>
+      <li>播报期间不应重置当前播放方案和当前素材索引。</li>
+      <li>异常提示优先级高于播报和素材广告。</li>
+    </ul>
     <h3>15.3 Mock 与接口替换验收</h3>
     <ul>
       <li>运维端上传图片/视频素材并创建播放方案后,机身屏应可通过 <code class="inline">/robot-ops/screen/play-plan/current</code> 获取当前播放方案并播放素材。</li>
@@ -693,3 +834,24 @@
 </div>
 </body>
 </html>
+
+    <h3>12.5 Cursor 实现提示(前端:播报插播/Idle 播放器)</h3>
+    <div class="info">
+      <strong>Cursor 实现提示:</strong><br>
+      <ul>
+        <li>本阶段需实现 <strong>播报插播功能</strong> 的前端 Cursor/Vue 组件。</li>
+        <li>参考 <code>BroadcastOverlay.vue</code> 组件拆分建议,实现如下要点:</li>
+        <ul>
+          <li>每 2 秒轮询 <code>/robot-ops/screen/broadcast/current</code>,判断是否有播报任务。</li>
+          <li>当 <code>broadcasting=true</code> 且 <code>audioUrl</code> 不为空时,显示播报浮层,暂停 Idle 播放器,播放 MP3。</li>
+          <li>监听 audio 播放结束,自动隐藏浮层并恢复 Idle 播放。</li>
+          <li>如有播放失败、audioUrl 为空等情况,需记录 warning 日志,并恢复 Idle 播放。</li>
+          <li>仅在 <code>/idle</code> 待机页生效,业务办理页不强制打断。</li>
+          <li>支持 <code>BroadcastOverlay.vue</code> 作为全局蒙层组件,动画渐入/渐出,展示播报标题、正文、状态。</li>
+        </ul>
+        <li>接口示例、字段说明与前端处理规则详见本章节 <code>13.2 当前播报状态接口详细设计</code>。</li>
+        <li>如需 Idle 播放器与播报插播协同,请确保 Idle 播放器(<code>IdlePlayer.vue</code>)支持暂停/恢复控制。</li>
+        <li>所有状态由 Pinia 管理,避免多处状态冲突。</li>
+      </ul>
+      <strong>实现目标:</strong> 实现播报插播浮层组件(BroadcastOverlay.vue),并集成到 Idle 播放器页面,确保播报流程、暂停/恢复、异常兜底、动画效果等完整体验。
+    </div>