新增打手端新订单语音播报开关
This commit is contained in:
@@ -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 { 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: <ShoppingOutlined />, label: '抢单大厅' },
|
||||
@@ -80,6 +81,7 @@ export default function WorkerLayout() {
|
||||
</div>
|
||||
</div>
|
||||
<div className="worker-mobile-user-area">
|
||||
<WorkerNewOrderSpeech />
|
||||
<Button
|
||||
type="text"
|
||||
size="small"
|
||||
@@ -144,6 +146,7 @@ export default function WorkerLayout() {
|
||||
onClick={({ key }) => navigate(String(key))}
|
||||
/>
|
||||
<div className="admin-sider-footer">
|
||||
<WorkerNewOrderSpeech />
|
||||
<Button block icon={<LogoutOutlined />} onClick={submitLogout}>
|
||||
退出登录
|
||||
</Button>
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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 {
|
||||
// 部分浏览器禁用语音合成,不影响页面功能。
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user