screen.js 16 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616
  1. import { defineStore } from 'pinia'
  2. import { ref, computed } from 'vue'
  3. import * as api from '@/api/screen'
  4. export const useScreenStore = defineStore('screen', () => {
  5. // 机器人状态
  6. const robotStatus = ref({
  7. batteryLevel: 85,
  8. networkStatus: 'online',
  9. workStatus: 'idle',
  10. chargeStatus: 'not_charging',
  11. faultFlag: false
  12. })
  13. // 播放方案
  14. const playPlan = ref(null)
  15. const currentMediaIndex = ref(0)
  16. const isPlaying = ref(true)
  17. // 播放方案版本号(用于检测方案是否变化)
  18. const playPlanVersion = ref('')
  19. // 待切换的新播放方案(version 变化时暂存)
  20. const pendingPlayPlan = ref(null)
  21. // 当前播放方案是否已完成至少一轮(所有素材都播放过一次)
  22. const currentPlanCycleComplete = ref(false)
  23. // 待机页模式: 'welcome' | 'playlist'
  24. const idleMode = ref('welcome')
  25. // 是否有播放方案(计算属性 + 手动覆盖)
  26. const hasPlayPlan = ref(false)
  27. // 强制模式覆盖(开发调试用)
  28. const forceMode = ref(null)
  29. // 播报状态
  30. const broadcastState = ref({
  31. broadcasting: false,
  32. title: '',
  33. content: '',
  34. startTime: null,
  35. endTime: null
  36. })
  37. // 当前播报状态(新版,用于播报插播)
  38. const currentBroadcast = ref({
  39. broadcasting: false,
  40. taskId: null,
  41. contentId: null,
  42. taskName: '',
  43. title: '',
  44. content: '',
  45. contentType: '',
  46. level: 'normal',
  47. audioUrl: '',
  48. audioDuration: null,
  49. playMode: 'once',
  50. startTime: null,
  51. estimatedEndTime: null,
  52. version: ''
  53. })
  54. // 播报轮询定时器
  55. const broadcastPollingTimer = ref(null)
  56. // 播放方案轮询定时器(降级方案)
  57. const playPlanPollingTimer = ref(null)
  58. // WebSocket 连接状态
  59. const wsConnected = ref(false)
  60. // 是否使用 WebSocket 模式
  61. const useWebSocketMode = ref(true)
  62. // 是否正在播放播报音频
  63. const isBroadcastAudioPlaying = ref(false)
  64. // 语音指令
  65. const latestCommand = ref(null)
  66. // 全局提示
  67. const globalAlert = ref({
  68. show: false,
  69. type: 'info',
  70. title: '',
  71. message: '',
  72. duration: 3000
  73. })
  74. // 音量
  75. const volume = ref(80)
  76. const muted = ref(false)
  77. // 屏幕配置
  78. const screenConfig = ref({
  79. robotName: '智能迎宾机器人',
  80. logoUrl: '',
  81. idleTimeout: 60,
  82. theme: 'default'
  83. })
  84. // 待机页主题配置
  85. const screenTheme = ref(null)
  86. // 计算属性
  87. const isIdle = computed(() => robotStatus.value.workStatus === 'idle')
  88. const isCharging = computed(() => robotStatus.value.chargeStatus === 'charging')
  89. const hasFault = computed(() => robotStatus.value.faultFlag)
  90. const networkOk = computed(() => robotStatus.value.networkStatus === 'online')
  91. // 是否正在播报(有播报状态、audioUrl 有值)
  92. const isBroadcasting = computed(() => {
  93. const b = currentBroadcast.value
  94. if (!b || b.broadcasting !== true || !b.audioUrl) return false
  95. return true
  96. })
  97. const currentMedia = computed(() => {
  98. if (!playPlan.value || !playPlan.value.items || playPlan.value.items.length === 0) {
  99. return null
  100. }
  101. return playPlan.value.items[currentMediaIndex.value] || null
  102. })
  103. // 实际的待机页展示模式
  104. const actualIdleMode = computed(() => {
  105. if (forceMode.value !== null) {
  106. return forceMode.value
  107. }
  108. return hasPlayPlan.value ? 'playlist' : 'welcome'
  109. })
  110. // 方法
  111. async function fetchScreenConfig() {
  112. try {
  113. const res = await api.getScreenConfig()
  114. const data = res && res.data !== undefined ? res.data : res
  115. screenConfig.value = data
  116. } catch (e) {
  117. console.error('Failed to fetch screen config:', e)
  118. }
  119. }
  120. async function fetchScreenTheme() {
  121. try {
  122. const res = await api.getScreenTheme()
  123. const data = res && res.data !== undefined ? res.data : res
  124. screenTheme.value = data
  125. } catch (e) {
  126. console.error('Failed to fetch screen theme:', e)
  127. }
  128. }
  129. async function fetchRobotStatus() {
  130. try {
  131. const res = await api.getRobotStatus()
  132. const data = res && res.data !== undefined ? res.data : res
  133. robotStatus.value = data
  134. } catch (e) {
  135. console.error('Failed to fetch robot status:', e)
  136. }
  137. }
  138. async function fetchPlayPlan() {
  139. try {
  140. const res = await api.getPlayPlan()
  141. // 处理 RuoYi 风格的响应结构 { msg, code, data }
  142. const data = res && res.data !== undefined ? res.data : res
  143. // 检查版本是否变化(支持 version 字段或不带 version 的兼容处理)
  144. const newVersion = data?.version || ''
  145. const hasVersionField = data && 'version' in data
  146. // 如果是首次加载(playPlanVersion 为空),直接应用方案
  147. if (!playPlanVersion.value) {
  148. playPlan.value = data
  149. playPlanVersion.value = newVersion
  150. pendingPlayPlan.value = null
  151. currentMediaIndex.value = 0
  152. hasPlayPlan.value = !!(
  153. data &&
  154. data.enabled !== false &&
  155. Array.isArray(data.items) &&
  156. data.items.length > 0
  157. )
  158. console.log('[Store] 首次加载播放方案', hasPlayPlan.value ? '有播放方案' : '无播放方案')
  159. return
  160. }
  161. // 后续轮询:检查是否有 version 字段
  162. if (!hasVersionField) {
  163. // 接口没有 version 字段,保持当前播放进度不变
  164. console.log('[Store] 接口无 version 字段,保持当前播放状态')
  165. return
  166. }
  167. // 有 version 字段时,进行版本对比
  168. const isVersionChanged = newVersion && playPlanVersion.value !== newVersion
  169. if (isVersionChanged) {
  170. // 版本变化,暂存新方案
  171. pendingPlayPlan.value = data
  172. console.log('[Store] 播放方案版本变化,暂存待切换方案:', newVersion)
  173. } else {
  174. // 版本未变化,保持当前播放进度不变
  175. console.log('[Store] 播放方案版本未变化,保持当前播放进度')
  176. }
  177. } catch (e) {
  178. console.error('[Store] 播放方案加载失败:', e)
  179. playPlan.value = null
  180. pendingPlayPlan.value = null
  181. hasPlayPlan.value = false
  182. }
  183. }
  184. /**
  185. * 切换到待生效的新播放方案
  186. * 在当前方案完成一轮后调用
  187. */
  188. function applyPendingPlayPlan() {
  189. if (pendingPlayPlan.value) {
  190. console.log('[Store] 应用待切换的播放方案')
  191. playPlan.value = pendingPlayPlan.value
  192. playPlanVersion.value = pendingPlayPlan.value?.version || ''
  193. pendingPlayPlan.value = null
  194. currentMediaIndex.value = 0
  195. currentPlanCycleComplete.value = false
  196. hasPlayPlan.value = !!(
  197. playPlan.value &&
  198. playPlan.value.enabled !== false &&
  199. Array.isArray(playPlan.value.items) &&
  200. playPlan.value.items.length > 0
  201. )
  202. }
  203. }
  204. /**
  205. * 标记当前方案已完成一轮播放
  206. */
  207. function markCurrentPlanCycleComplete() {
  208. if (!currentPlanCycleComplete.value && pendingPlayPlan.value) {
  209. currentPlanCycleComplete.value = true
  210. console.log('[Store] 当前方案已完成一轮,可切换新方案')
  211. }
  212. }
  213. /**
  214. * 丢弃待切换的播放方案(当原方案被取消时)
  215. */
  216. function discardPendingPlayPlan() {
  217. if (pendingPlayPlan.value) {
  218. console.log('[Store] 丢弃待切换的播放方案')
  219. pendingPlayPlan.value = null
  220. }
  221. }
  222. // ============================================
  223. // WebSocket 驱动方法
  224. // ============================================
  225. /**
  226. * 处理 WebSocket 推送的播放方案变化
  227. * 由 websocket store 调用
  228. */
  229. function handlePlayPlanChanged(data) {
  230. console.log('[Store] WebSocket 收到播放方案变化:', data)
  231. if (!data || data.enabled === false) {
  232. // 方案禁用
  233. handlePlayPlanDisabled()
  234. return
  235. }
  236. const newVersion = data?.version || ''
  237. // 如果是首次加载,直接应用方案
  238. if (!playPlanVersion.value) {
  239. playPlan.value = data
  240. playPlanVersion.value = newVersion
  241. pendingPlayPlan.value = null
  242. currentMediaIndex.value = 0
  243. hasPlayPlan.value = !!(
  244. data &&
  245. data.enabled !== false &&
  246. Array.isArray(data.items) &&
  247. data.items.length > 0
  248. )
  249. console.log('[Store] WebSocket 首次加载播放方案')
  250. return
  251. }
  252. // 后续更新:检查版本是否变化
  253. const isVersionChanged = newVersion && playPlanVersion.value !== newVersion
  254. if (isVersionChanged) {
  255. // 版本变化,暂存新方案
  256. pendingPlayPlan.value = data
  257. console.log('[Store] WebSocket 播放方案版本变化,暂存待切换方案:', newVersion)
  258. }
  259. }
  260. /**
  261. * 处理 WebSocket 推送的播放方案禁用
  262. */
  263. function handlePlayPlanDisabled() {
  264. console.log('[Store] WebSocket 收到播放方案禁用')
  265. pendingPlayPlan.value = null
  266. playPlan.value = null
  267. playPlanVersion.value = ''
  268. hasPlayPlan.value = false
  269. currentMediaIndex.value = 0
  270. }
  271. /**
  272. * 处理 WebSocket 推送的播报开始
  273. */
  274. function handleBroadcastStarted(data) {
  275. console.log('[Store] WebSocket 收到播报开始:', data)
  276. if (!data) return
  277. currentBroadcast.value = data
  278. }
  279. /**
  280. * 处理 WebSocket 推送的播报结束
  281. */
  282. function handleBroadcastEnded() {
  283. console.log('[Store] WebSocket 收到播报结束')
  284. currentBroadcast.value = emptyBroadcast()
  285. }
  286. /**
  287. * 设置 WebSocket 连接状态
  288. */
  289. function setWsConnected(connected) {
  290. wsConnected.value = connected
  291. console.log('[Store] WebSocket 连接状态:', connected ? '已连接' : '已断开')
  292. }
  293. /**
  294. * 开始播放方案轮询(降级方案)
  295. * 当 WebSocket 不可用时启用
  296. */
  297. function startPlayPlanPolling() {
  298. if (playPlanPollingTimer.value) return
  299. // 立即请求一次
  300. fetchPlayPlan()
  301. // 每 30 秒轮询一次(降级方案,频率较低)
  302. playPlanPollingTimer.value = setInterval(() => {
  303. fetchPlayPlan()
  304. }, 30000)
  305. }
  306. /**
  307. * 停止播放方案轮询
  308. */
  309. function stopPlayPlanPolling() {
  310. if (playPlanPollingTimer.value) {
  311. clearInterval(playPlanPollingTimer.value)
  312. playPlanPollingTimer.value = null
  313. }
  314. }
  315. async function fetchBroadcastState() {
  316. try {
  317. const res = await api.getBroadcastState()
  318. const data = res && res.data !== undefined ? res.data : res
  319. broadcastState.value = data
  320. } catch (e) {
  321. console.error('Failed to fetch broadcast state:', e)
  322. }
  323. }
  324. /**
  325. * 生成播报唯一 key
  326. * 使用时间戳确保每次推送都是新的 key,配合后端频率控制
  327. */
  328. function getBroadcastKey(broadcast) {
  329. if (!broadcast) return ''
  330. return [
  331. broadcast.taskId || '',
  332. broadcast.contentId || '',
  333. Date.now() // 使用时间戳让每次都是新的 key
  334. ].join('_')
  335. }
  336. /**
  337. * 获取空的播报状态对象
  338. */
  339. function emptyBroadcast() {
  340. return {
  341. broadcasting: false,
  342. taskId: null,
  343. contentId: null,
  344. taskName: '',
  345. title: '',
  346. content: '',
  347. contentType: '',
  348. level: 'normal',
  349. audioUrl: '',
  350. audioDuration: null,
  351. playMode: 'once',
  352. startTime: null,
  353. estimatedEndTime: null,
  354. version: ''
  355. }
  356. }
  357. /**
  358. * 获取当前播报状态(播报插播专用)
  359. */
  360. async function fetchCurrentBroadcast() {
  361. try {
  362. const res = await api.getCurrentBroadcast()
  363. const data = res && res.data !== undefined ? res.data : res
  364. // 音频正在播放时,不清空当前播报状态
  365. if (isBroadcastAudioPlaying.value) {
  366. console.log('[Broadcast] 音频正在播放,忽略后端的 broadcasting=false 状态')
  367. return
  368. }
  369. currentBroadcast.value = data || emptyBroadcast()
  370. console.log('[Broadcast] 播报状态更新:', data)
  371. } catch (e) {
  372. console.warn('[Store] 获取当前播报状态失败:', e)
  373. }
  374. }
  375. /**
  376. * 开始播报状态轮询(每 2 秒一次)
  377. */
  378. function startBroadcastPolling() {
  379. // 避免重复启动
  380. if (broadcastPollingTimer.value) {
  381. return
  382. }
  383. // 立即请求一次
  384. fetchCurrentBroadcast()
  385. // 每 2 秒轮询一次
  386. broadcastPollingTimer.value = setInterval(() => {
  387. fetchCurrentBroadcast()
  388. }, 2000)
  389. }
  390. /**
  391. * 停止播报状态轮询
  392. */
  393. function stopBroadcastPolling() {
  394. if (broadcastPollingTimer.value) {
  395. clearInterval(broadcastPollingTimer.value)
  396. broadcastPollingTimer.value = null
  397. }
  398. }
  399. /**
  400. * 设置播报音频播放状态
  401. */
  402. function setBroadcastAudioPlaying(value) {
  403. isBroadcastAudioPlaying.value = value
  404. }
  405. /**
  406. * 本地标记播报结束(用于 audio ended/error 后隐藏浮层)
  407. */
  408. function markBroadcastFinishedLocally() {
  409. console.log('[Broadcast] 标记播报为已完成')
  410. currentBroadcast.value = emptyBroadcast()
  411. }
  412. async function fetchLatestCommand() {
  413. try {
  414. const res = await api.getLatestCommand()
  415. if (res && res.commandId !== latestCommand.value?.commandId) {
  416. latestCommand.value = res
  417. return res
  418. }
  419. } catch (e) {
  420. console.error('Failed to fetch latest command:', e)
  421. }
  422. return null
  423. }
  424. async function ackCommand(commandId) {
  425. try {
  426. await api.ackCommand(commandId)
  427. } catch (e) {
  428. console.error('Failed to ack command:', e)
  429. }
  430. }
  431. function nextMedia() {
  432. if (playPlan.value && playPlan.value.items && playPlan.value.items.length > 0) {
  433. currentMediaIndex.value = (currentMediaIndex.value + 1) % playPlan.value.items.length
  434. }
  435. }
  436. function showAlert(options) {
  437. const { type = 'info', title = '', message = '', duration = 3000 } = options
  438. globalAlert.value = { show: true, type, title, message, duration }
  439. if (duration > 0) {
  440. setTimeout(() => {
  441. globalAlert.value.show = false
  442. }, duration)
  443. }
  444. }
  445. function hideAlert() {
  446. globalAlert.value.show = false
  447. }
  448. function setVolume(val) {
  449. volume.value = Math.max(0, Math.min(100, val))
  450. }
  451. function toggleMute() {
  452. muted.value = !muted.value
  453. }
  454. function pausePlay() {
  455. isPlaying.value = false
  456. }
  457. function resumePlay() {
  458. isPlaying.value = true
  459. }
  460. function resetToIdle() {
  461. robotStatus.value.workStatus = 'idle'
  462. }
  463. // 切换待机页模式(开发调试用)
  464. function toggleIdleMode() {
  465. forceMode.value = actualIdleMode.value === 'welcome' ? 'playlist' : 'welcome'
  466. }
  467. // 重置强制模式
  468. function resetForceMode() {
  469. forceMode.value = null
  470. }
  471. return {
  472. // 状态
  473. robotStatus,
  474. playPlan,
  475. currentMediaIndex,
  476. isPlaying,
  477. playPlanVersion,
  478. pendingPlayPlan,
  479. currentPlanCycleComplete,
  480. broadcastState,
  481. currentBroadcast,
  482. broadcastPollingTimer,
  483. playPlanPollingTimer,
  484. wsConnected,
  485. useWebSocketMode,
  486. isBroadcastAudioPlaying,
  487. latestCommand,
  488. globalAlert,
  489. volume,
  490. muted,
  491. screenConfig,
  492. screenTheme,
  493. idleMode,
  494. hasPlayPlan,
  495. forceMode,
  496. // 计算属性
  497. isIdle,
  498. isCharging,
  499. hasFault,
  500. networkOk,
  501. currentMedia,
  502. actualIdleMode,
  503. isBroadcasting,
  504. getBroadcastKey,
  505. // 方法
  506. fetchScreenConfig,
  507. fetchScreenTheme,
  508. fetchRobotStatus,
  509. fetchPlayPlan,
  510. applyPendingPlayPlan,
  511. discardPendingPlayPlan,
  512. markCurrentPlanCycleComplete,
  513. fetchBroadcastState,
  514. fetchCurrentBroadcast,
  515. startBroadcastPolling,
  516. stopBroadcastPolling,
  517. setBroadcastAudioPlaying,
  518. markBroadcastFinishedLocally,
  519. emptyBroadcast,
  520. fetchLatestCommand,
  521. ackCommand,
  522. nextMedia,
  523. showAlert,
  524. hideAlert,
  525. setVolume,
  526. toggleMute,
  527. pausePlay,
  528. resumePlay,
  529. resetToIdle,
  530. toggleIdleMode,
  531. resetForceMode,
  532. // WebSocket 驱动方法
  533. handlePlayPlanChanged,
  534. handlePlayPlanDisabled,
  535. handleBroadcastStarted,
  536. handleBroadcastEnded,
  537. setWsConnected,
  538. startPlayPlanPolling,
  539. stopPlayPlanPolling
  540. }
  541. })