新增打手端新订单语音播报开关

This commit is contained in:
yml2213
2026-08-22 09:12:26 +08:00
parent 700bda8250
commit 1f7ff016df
4 changed files with 131 additions and 0 deletions
@@ -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<number> | null = null
let flushTimer: ReturnType<typeof setTimeout> | null = null
const readHallIds = () => {
const ids = new Set<number>()
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 (
<div className="worker-speech-toggle">
<SoundOutlined />
<Typography.Text></Typography.Text>
<Switch size="small" checked={enabled} onChange={toggle} />
</div>
)
}
@@ -16,6 +16,7 @@ import { clearWorkerSession, getWorkerStatus, getWorkerUsername } from '@/utils/
import { getWorkerToken } from '@/utils/worker-auth' import { getWorkerToken } from '@/utils/worker-auth'
import { useRealtimeEvents, type RealtimeEvent } from '@/hooks/useRealtimeEvents' import { useRealtimeEvents, type RealtimeEvent } from '@/hooks/useRealtimeEvents'
import WorkerAnnouncementBanner from '@/components/worker/WorkerAnnouncementBanner' import WorkerAnnouncementBanner from '@/components/worker/WorkerAnnouncementBanner'
import WorkerNewOrderSpeech from '@/components/worker/WorkerNewOrderSpeech'
const menuItems: MenuProps['items'] = [ const menuItems: MenuProps['items'] = [
{ key: '/hall', icon: <ShoppingOutlined />, label: '抢单大厅' }, { key: '/hall', icon: <ShoppingOutlined />, label: '抢单大厅' },
@@ -80,6 +81,7 @@ export default function WorkerLayout() {
</div> </div>
</div> </div>
<div className="worker-mobile-user-area"> <div className="worker-mobile-user-area">
<WorkerNewOrderSpeech />
<Button <Button
type="text" type="text"
size="small" size="small"
@@ -144,6 +146,7 @@ export default function WorkerLayout() {
onClick={({ key }) => navigate(String(key))} onClick={({ key }) => navigate(String(key))}
/> />
<div className="admin-sider-footer"> <div className="admin-sider-footer">
<WorkerNewOrderSpeech />
<Button block icon={<LogoutOutlined />} onClick={submitLogout}> <Button block icon={<LogoutOutlined />} onClick={submitLogout}>
退 退
</Button> </Button>
+16
View File
@@ -2165,3 +2165,19 @@ body {
border-left: 0; 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;
}
+28
View File
@@ -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 {
// 部分浏览器禁用语音合成,不影响页面功能。
}
}