From 1f7ff016df7178f4524a93448b941c1468414b84 Mon Sep 17 00:00:00 2001 From: yml2213 Date: Sat, 22 Aug 2026 09:12:26 +0800 Subject: [PATCH] =?UTF-8?q?=E6=96=B0=E5=A2=9E=E6=89=93=E6=89=8B=E7=AB=AF?= =?UTF-8?q?=E6=96=B0=E8=AE=A2=E5=8D=95=E8=AF=AD=E9=9F=B3=E6=92=AD=E6=8A=A5?= =?UTF-8?q?=E5=BC=80=E5=85=B3?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../worker/WorkerNewOrderSpeech.tsx | 84 +++++++++++++++++++ apps/frontend/src/layouts/WorkerLayout.tsx | 3 + apps/frontend/src/styles/worker.css | 16 ++++ apps/frontend/src/utils/worker-speech.ts | 28 +++++++ 4 files changed, 131 insertions(+) create mode 100644 apps/frontend/src/components/worker/WorkerNewOrderSpeech.tsx create mode 100644 apps/frontend/src/utils/worker-speech.ts diff --git a/apps/frontend/src/components/worker/WorkerNewOrderSpeech.tsx b/apps/frontend/src/components/worker/WorkerNewOrderSpeech.tsx new file mode 100644 index 00000000..7b880e82 --- /dev/null +++ b/apps/frontend/src/components/worker/WorkerNewOrderSpeech.tsx @@ -0,0 +1,84 @@ +import { SoundOutlined } from '@ant-design/icons' +import { Switch, Typography } from 'antd' +import { useEffect, useRef, useState } from 'react' +import { useQueryClient } from '@tanstack/react-query' + +import { + isWorkerSpeechEnabled, + setWorkerSpeechEnabled, + speakWorkerText, +} from '@/utils/worker-speech' + +/** + * 打手端新订单语音播报:开关持久化到 localStorage(默认关闭), + * 通过对比抢单大厅缓存中新出现的工单 ID 触发浏览器语音播报。 + */ +export default function WorkerNewOrderSpeech() { + const queryClient = useQueryClient() + const [enabled, setEnabled] = useState(isWorkerSpeechEnabled) + const enabledRef = useRef(enabled) + + useEffect(() => { + enabledRef.current = enabled + }, [enabled]) + + function toggle(checked: boolean) { + setEnabled(checked) + setWorkerSpeechEnabled(checked) + if (checked) speakWorkerText('新订单播报已开启') + } + + useEffect(() => { + if (!enabled) return + // 首次观察到的大厅快照全部视为已知订单,避免打开开关时播报存量订单。 + let seenIds: Set | null = null + let flushTimer: ReturnType | null = null + const readHallIds = () => { + const ids = new Set() + for (const [, data] of queryClient.getQueriesData({ queryKey: ['worker-hall-orders'] })) { + const pages = (data as { pages?: unknown } | undefined)?.pages + if (!Array.isArray(pages)) continue + for (const page of pages) { + const items = (page as { data?: { items?: unknown } } | null)?.data?.items + if (!Array.isArray(items)) continue + for (const item of items) { + const id = Number((item as { workOrderId?: unknown } | null)?.workOrderId || 0) + if (id > 0) ids.add(id) + } + } + } + return ids + } + const unsubscribe = queryClient.getQueryCache().subscribe(() => { + if (!enabledRef.current) return + const ids = readHallIds() + if (!seenIds) { + seenIds = ids + return + } + let hasNew = false + for (const id of ids) { + if (!seenIds.has(id)) hasNew = true + } + seenIds = ids + // 大厅刷新事件可能连发,聚合 2 秒内的多批新订单只播报一次。 + if (!hasNew || flushTimer) return + flushTimer = setTimeout(() => { + flushTimer = null + speakWorkerText('您有新的订单,请及时抢单') + }, 2000) + }) + return () => { + if (flushTimer) clearTimeout(flushTimer) + unsubscribe() + } + }, [enabled, queryClient]) + + return ( +
+ + 新订单播报 + +
+ ) +} diff --git a/apps/frontend/src/layouts/WorkerLayout.tsx b/apps/frontend/src/layouts/WorkerLayout.tsx index 0aa19234..387610fe 100644 --- a/apps/frontend/src/layouts/WorkerLayout.tsx +++ b/apps/frontend/src/layouts/WorkerLayout.tsx @@ -16,6 +16,7 @@ import { clearWorkerSession, getWorkerStatus, getWorkerUsername } from '@/utils/ import { getWorkerToken } from '@/utils/worker-auth' import { useRealtimeEvents, type RealtimeEvent } from '@/hooks/useRealtimeEvents' import WorkerAnnouncementBanner from '@/components/worker/WorkerAnnouncementBanner' +import WorkerNewOrderSpeech from '@/components/worker/WorkerNewOrderSpeech' const menuItems: MenuProps['items'] = [ { key: '/hall', icon: , label: '抢单大厅' }, @@ -80,6 +81,7 @@ export default function WorkerLayout() {
+ diff --git a/apps/frontend/src/styles/worker.css b/apps/frontend/src/styles/worker.css index e9537fdb..6b9822d1 100644 --- a/apps/frontend/src/styles/worker.css +++ b/apps/frontend/src/styles/worker.css @@ -2165,3 +2165,19 @@ body { border-left: 0; } } + +.worker-speech-toggle { + display: flex; + align-items: center; + gap: 8px; + margin-bottom: 12px; + padding: 0 4px; + color: #6b7280; + font-size: 13px; +} + +.worker-mobile-header .worker-speech-toggle { + margin-bottom: 0; + padding: 0; + font-size: 12px; +} diff --git a/apps/frontend/src/utils/worker-speech.ts b/apps/frontend/src/utils/worker-speech.ts new file mode 100644 index 00000000..3b699acc --- /dev/null +++ b/apps/frontend/src/utils/worker-speech.ts @@ -0,0 +1,28 @@ +const WORKER_SPEECH_KEY = 'order-site-worker-new-order-speech' + +export function isWorkerSpeechEnabled() { + return window.localStorage.getItem(WORKER_SPEECH_KEY) === '1' +} + +export function setWorkerSpeechEnabled(enabled: boolean) { + window.localStorage.setItem(WORKER_SPEECH_KEY, enabled ? '1' : '0') +} + +/** 用浏览器语音合成播报一段中文;不支持或被浏览器拦截时静默失败。 */ +export function speakWorkerText(text: string) { + if (!('speechSynthesis' in window)) return + try { + window.speechSynthesis.cancel() + const utterance = new SpeechSynthesisUtterance(text) + utterance.lang = 'zh-CN' + utterance.rate = 1 + utterance.volume = 1 + const zhVoice = window.speechSynthesis + .getVoices() + .find((voice) => voice.lang.toLowerCase().startsWith('zh')) + if (zhVoice) utterance.voice = zhVoice + window.speechSynthesis.speak(utterance) + } catch { + // 部分浏览器禁用语音合成,不影响页面功能。 + } +}