新增打手端新订单语音播报开关
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 { 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>
|
||||||
|
|||||||
@@ -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;
|
||||||
|
}
|
||||||
|
|||||||
@@ -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