feat(web): 增强消息提醒声音与语音朗读
This commit is contained in:
@@ -0,0 +1,287 @@
|
||||
import { useState } from 'react'
|
||||
import {
|
||||
AudioOutlined,
|
||||
DeleteOutlined,
|
||||
PlayCircleOutlined,
|
||||
SoundOutlined,
|
||||
UploadOutlined,
|
||||
} from '@ant-design/icons'
|
||||
import { Button, Popconfirm, Select, Slider, Switch, Tag, Upload, message as antMsg } from 'antd'
|
||||
import { useAgentNotifications } from '@/stores/notifications'
|
||||
import {
|
||||
MAX_CUSTOM_SOUND_BYTES,
|
||||
MAX_CUSTOM_SOUND_SECONDS,
|
||||
SOUND_PRESETS,
|
||||
SPEAK_MODES,
|
||||
type SoundPresetId,
|
||||
} from '@/lib/notificationSettings'
|
||||
|
||||
interface NotificationPreferencesProps {
|
||||
className?: string
|
||||
}
|
||||
|
||||
function formatDuration(seconds: number): string {
|
||||
if (!Number.isFinite(seconds) || seconds <= 0) return ''
|
||||
return `${seconds.toFixed(1)}s`
|
||||
}
|
||||
|
||||
export default function NotificationPreferences({ className = '' }: NotificationPreferencesProps) {
|
||||
const {
|
||||
soundEnabled,
|
||||
setSoundEnabled,
|
||||
soundSource,
|
||||
preset,
|
||||
setPreset,
|
||||
setSoundSource,
|
||||
customSound,
|
||||
volume,
|
||||
setVolume,
|
||||
previewSound,
|
||||
uploadCustomSound,
|
||||
removeCustomSound,
|
||||
speakEnabled,
|
||||
setSpeakEnabled,
|
||||
speakMode,
|
||||
setSpeakMode,
|
||||
speakRate,
|
||||
setSpeakRate,
|
||||
voiceURI,
|
||||
setVoiceURI,
|
||||
voices,
|
||||
ttsSupported,
|
||||
previewSpeech,
|
||||
} = useAgentNotifications()
|
||||
const [uploading, setUploading] = useState(false)
|
||||
const [removing, setRemoving] = useState(false)
|
||||
const [previewing, setPreviewing] = useState(false)
|
||||
|
||||
const soundOptions = [
|
||||
...SOUND_PRESETS.map(item => ({ value: item.value, label: item.label })),
|
||||
...(customSound ? [{ value: 'custom', label: `自定义:${customSound.name}` }] : []),
|
||||
]
|
||||
const selectedSound = soundSource === 'custom' && customSound ? 'custom' : preset
|
||||
const voiceOptions = [
|
||||
{ value: '', label: '系统默认' },
|
||||
...voices.map(item => ({
|
||||
value: item.voiceURI,
|
||||
label: `${item.name}${item.lang ? ` · ${item.lang}` : ''}`,
|
||||
})),
|
||||
]
|
||||
|
||||
const handleSoundChange = (value: string) => {
|
||||
if (value === 'custom') {
|
||||
setSoundSource('custom')
|
||||
return
|
||||
}
|
||||
setPreset(value as SoundPresetId)
|
||||
}
|
||||
|
||||
const handlePreview = async () => {
|
||||
setPreviewing(true)
|
||||
try {
|
||||
await previewSound()
|
||||
} catch {
|
||||
antMsg.error('试听失败,请检查浏览器音频权限')
|
||||
} finally {
|
||||
window.setTimeout(() => setPreviewing(false), 500)
|
||||
}
|
||||
}
|
||||
|
||||
const handleUpload = async (file: File) => {
|
||||
setUploading(true)
|
||||
try {
|
||||
await uploadCustomSound(file)
|
||||
antMsg.success('自定义提示音已保存')
|
||||
await previewSound()
|
||||
} catch (e) {
|
||||
antMsg.error(e instanceof Error ? e.message : '上传音频失败')
|
||||
} finally {
|
||||
setUploading(false)
|
||||
}
|
||||
}
|
||||
|
||||
const handleRemove = async () => {
|
||||
setRemoving(true)
|
||||
try {
|
||||
await removeCustomSound()
|
||||
antMsg.success('已删除自定义提示音')
|
||||
} catch (e) {
|
||||
antMsg.error(e instanceof Error ? e.message : '删除失败')
|
||||
} finally {
|
||||
setRemoving(false)
|
||||
}
|
||||
}
|
||||
|
||||
const handleSpeechPreview = () => {
|
||||
if (!ttsSupported) {
|
||||
antMsg.warning('当前浏览器不支持语音朗读')
|
||||
return
|
||||
}
|
||||
if (!previewSpeech()) antMsg.error('语音试听失败,请检查系统语音设置')
|
||||
}
|
||||
|
||||
return (
|
||||
<div className={`rounded-2xl border border-slate-200/80 bg-white p-5 md:p-6 shadow-xs ${className}`}>
|
||||
<div className="flex items-start justify-between gap-3 mb-5">
|
||||
<div className="min-w-0">
|
||||
<div className="flex items-center gap-2 text-sm font-bold text-slate-900">
|
||||
<SoundOutlined className="text-blue-500" />
|
||||
声音与朗读
|
||||
</div>
|
||||
<div className="text-xs text-slate-400 mt-1">仅保存在当前浏览器,换设备后需要重新设置</div>
|
||||
</div>
|
||||
<Tag color="blue" className="!m-0 !rounded-full !text-[10px]">本机设置</Tag>
|
||||
</div>
|
||||
|
||||
<div className="space-y-4">
|
||||
<div className="flex items-center justify-between gap-4">
|
||||
<div className="min-w-0">
|
||||
<div className="text-sm font-semibold text-slate-800">声音提醒</div>
|
||||
<div className="text-xs text-slate-400 mt-0.5">收到访客消息时播放提示音</div>
|
||||
</div>
|
||||
<Switch checked={soundEnabled} onChange={setSoundEnabled} />
|
||||
</div>
|
||||
|
||||
{soundEnabled && (
|
||||
<div className="space-y-3 pl-3 border-l-2 border-blue-100">
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="w-14 shrink-0 text-xs text-slate-500">提示音</div>
|
||||
<Select
|
||||
size="small"
|
||||
className="flex-1 min-w-0"
|
||||
value={selectedSound}
|
||||
options={soundOptions}
|
||||
onChange={handleSoundChange}
|
||||
/>
|
||||
<Button
|
||||
size="small"
|
||||
icon={<PlayCircleOutlined />}
|
||||
loading={previewing}
|
||||
onClick={() => { void handlePreview() }}
|
||||
>
|
||||
试听
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="w-14 shrink-0 text-xs text-slate-500">音量</div>
|
||||
<Slider
|
||||
className="flex-1 !my-0"
|
||||
min={0}
|
||||
max={1}
|
||||
step={0.05}
|
||||
value={volume}
|
||||
onChange={setVolume}
|
||||
/>
|
||||
<span className="w-10 text-right text-[11px] tabular-nums text-slate-400">
|
||||
{Math.round(volume * 100)}%
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="w-14 shrink-0 text-xs text-slate-500">自定义</div>
|
||||
<div className="flex-1 min-w-0">
|
||||
{customSound ? (
|
||||
<div className="flex items-center gap-2 min-w-0 rounded-lg bg-slate-50 px-2.5 py-1.5">
|
||||
<AudioOutlined className="text-blue-500 shrink-0" />
|
||||
<span className="truncate text-xs text-slate-600">{customSound.name}</span>
|
||||
{customSound.duration > 0 && (
|
||||
<span className="shrink-0 text-[10px] text-slate-400">{formatDuration(customSound.duration)}</span>
|
||||
)}
|
||||
</div>
|
||||
) : (
|
||||
<span className="text-xs text-slate-400">未上传,使用上方预设音</span>
|
||||
)}
|
||||
</div>
|
||||
<Upload
|
||||
accept=".mp3,.wav,.ogg,.m4a,.aac,audio/*"
|
||||
showUploadList={false}
|
||||
beforeUpload={file => {
|
||||
void handleUpload(file)
|
||||
return Upload.LIST_IGNORE
|
||||
}}
|
||||
>
|
||||
<Button size="small" icon={<UploadOutlined />} loading={uploading}>上传</Button>
|
||||
</Upload>
|
||||
{customSound && (
|
||||
<Popconfirm
|
||||
title="删除自定义提示音?"
|
||||
description="删除后会恢复使用预设音"
|
||||
onConfirm={() => { void handleRemove() }}
|
||||
okText="删除"
|
||||
cancelText="取消"
|
||||
>
|
||||
<Button size="small" danger icon={<DeleteOutlined />} loading={removing} aria-label="删除自定义提示音" />
|
||||
</Popconfirm>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="text-[11px] leading-relaxed text-slate-400">
|
||||
支持 mp3 / wav / ogg / m4a,文件不超过 {Math.round(MAX_CUSTOM_SOUND_BYTES / 1024 / 1024)}MB,
|
||||
建议时长不超过 {MAX_CUSTOM_SOUND_SECONDS} 秒。
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="border-t border-slate-100 pt-4">
|
||||
<div className="flex items-center justify-between gap-4">
|
||||
<div className="min-w-0">
|
||||
<div className="text-sm font-semibold text-slate-800">消息朗读</div>
|
||||
<div className="text-xs text-slate-400 mt-0.5">
|
||||
{ttsSupported ? '使用系统语音播报新访客消息' : '当前浏览器不支持 Web Speech 朗读'}
|
||||
</div>
|
||||
</div>
|
||||
<Switch checked={speakEnabled} disabled={!ttsSupported} onChange={setSpeakEnabled} />
|
||||
</div>
|
||||
|
||||
{speakEnabled && ttsSupported && (
|
||||
<div className="mt-3 space-y-3 pl-3 border-l-2 border-blue-100">
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="w-14 shrink-0 text-xs text-slate-500">朗读内容</div>
|
||||
<Select
|
||||
size="small"
|
||||
className="flex-1 min-w-0"
|
||||
value={speakMode}
|
||||
options={SPEAK_MODES.map(item => ({ value: item.value, label: item.label }))}
|
||||
onChange={setSpeakMode}
|
||||
/>
|
||||
</div>
|
||||
<div className="text-[11px] text-slate-400 pl-[68px] -mt-1">
|
||||
{SPEAK_MODES.find(item => item.value === speakMode)?.desc}
|
||||
</div>
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="w-14 shrink-0 text-xs text-slate-500">语速</div>
|
||||
<Slider
|
||||
className="flex-1 !my-0"
|
||||
min={0.6}
|
||||
max={1.6}
|
||||
step={0.05}
|
||||
value={speakRate}
|
||||
onChange={setSpeakRate}
|
||||
/>
|
||||
<span className="w-10 text-right text-[11px] tabular-nums text-slate-400">
|
||||
{speakRate.toFixed(2)}x
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="w-14 shrink-0 text-xs text-slate-500">音色</div>
|
||||
<Select
|
||||
size="small"
|
||||
className="flex-1 min-w-0"
|
||||
value={voiceURI}
|
||||
options={voiceOptions}
|
||||
onChange={setVoiceURI}
|
||||
/>
|
||||
</div>
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="w-14 shrink-0" />
|
||||
<Button size="small" icon={<AudioOutlined />} onClick={handleSpeechPreview}>试听朗读</Button>
|
||||
<span className="text-[11px] text-slate-400">首次使用请先点击试听,部分浏览器需要用户交互才能播放</span>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -4,11 +4,14 @@ import {
|
||||
AppstoreOutlined, HistoryOutlined, TeamOutlined,
|
||||
FileTextOutlined, BarChartOutlined, SettingOutlined, LogoutOutlined,
|
||||
ThunderboltOutlined, DownOutlined, StopOutlined, SafetyOutlined, BellOutlined, BellFilled,
|
||||
SoundOutlined,
|
||||
} from '@ant-design/icons'
|
||||
import { Button, Dropdown, message as antMsg, Popover, Switch, Tooltip } from 'antd'
|
||||
import { Button, Dropdown, message as antMsg, Modal, Popover, Switch, Tooltip } from 'antd'
|
||||
import { useAuth } from '@/stores/auth'
|
||||
import { LogoIcon } from '@/components/common/Logo'
|
||||
import NotificationPreferences from '@/components/common/NotificationPreferences'
|
||||
import { useAgentNotifications } from '@/stores/notifications'
|
||||
import { SOUND_PRESETS } from '@/lib/notificationSettings'
|
||||
import type { AgentPresence } from '@/services/api'
|
||||
|
||||
const menuItems = [
|
||||
@@ -45,8 +48,14 @@ const AgentSidebar = () => {
|
||||
setSoundEnabled,
|
||||
setDesktopEnabled,
|
||||
requestDesktopPermission,
|
||||
preset,
|
||||
soundSource,
|
||||
customSound,
|
||||
volume,
|
||||
} = useAgentNotifications()
|
||||
const [switching, setSwitching] = useState(false)
|
||||
const [notificationOpen, setNotificationOpen] = useState(false)
|
||||
const [notificationSettingsOpen, setNotificationSettingsOpen] = useState(false)
|
||||
|
||||
const visibleMenuItems = menuItems.filter(item => item.permissions.some(code => permissions.has(code)))
|
||||
const selectedKey = visibleMenuItems.find(item => location.pathname.startsWith(item.key))?.key || '/agent/dashboard'
|
||||
@@ -72,6 +81,9 @@ const AgentSidebar = () => {
|
||||
|
||||
const initial = (user?.nickname || user?.username || '客').slice(0, 1).toUpperCase()
|
||||
const current = presenceMeta(user?.status || 'online')
|
||||
const soundLabel = soundSource === 'custom' && customSound
|
||||
? customSound.name
|
||||
: SOUND_PRESETS.find(item => item.value === preset)?.label || '清脆叮咚'
|
||||
|
||||
const notificationContent = (
|
||||
<div className="w-68 p-1 space-y-3.5">
|
||||
@@ -79,6 +91,11 @@ const AgentSidebar = () => {
|
||||
<div>
|
||||
<div className="text-sm font-medium text-slate-800">声音提醒</div>
|
||||
<div className="text-xs text-slate-400">收到访客消息时播放提示音</div>
|
||||
{soundEnabled && (
|
||||
<div className="mt-0.5 text-[10px] text-blue-500 truncate max-w-44">
|
||||
{soundLabel} · 音量 {Math.round(volume * 100)}%
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<Switch size="small" checked={soundEnabled} onChange={setSoundEnabled} />
|
||||
</div>
|
||||
@@ -109,6 +126,20 @@ const AgentSidebar = () => {
|
||||
授权浏览器通知权限
|
||||
</Button>
|
||||
)}
|
||||
<div className="pt-2 border-t border-slate-100">
|
||||
<Button
|
||||
type="text"
|
||||
size="small"
|
||||
icon={<SoundOutlined />}
|
||||
className="!w-full !justify-start !h-8 !px-2 !text-xs !text-slate-600 hover:!text-blue-600 hover:!bg-blue-50"
|
||||
onClick={() => {
|
||||
setNotificationOpen(false)
|
||||
setNotificationSettingsOpen(true)
|
||||
}}
|
||||
>
|
||||
声音与朗读设置
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
|
||||
@@ -127,7 +158,14 @@ const AgentSidebar = () => {
|
||||
<span className="font-bold text-slate-900 text-[14px] leading-tight truncate">客服工作台</span>
|
||||
<span className="text-[10px] text-blue-600 font-semibold font-mono tracking-wider leading-none mt-0.5">KEFU CLOUD</span>
|
||||
</div>
|
||||
<Popover trigger="click" placement="bottomRight" content={notificationContent} title={<span className="text-xs font-semibold text-slate-700">消息提醒设置</span>}>
|
||||
<Popover
|
||||
trigger="click"
|
||||
placement="bottomRight"
|
||||
open={notificationOpen}
|
||||
onOpenChange={setNotificationOpen}
|
||||
content={notificationContent}
|
||||
title={<span className="text-xs font-semibold text-slate-700">消息提醒设置</span>}
|
||||
>
|
||||
<button
|
||||
type="button"
|
||||
title="消息提醒设置"
|
||||
@@ -222,6 +260,17 @@ const AgentSidebar = () => {
|
||||
</Tooltip>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<Modal
|
||||
open={notificationSettingsOpen}
|
||||
onCancel={() => setNotificationSettingsOpen(false)}
|
||||
footer={null}
|
||||
width={560}
|
||||
title="声音与朗读设置"
|
||||
styles={{ body: { padding: 12 } }}
|
||||
>
|
||||
<NotificationPreferences className="!border-0 !shadow-none" />
|
||||
</Modal>
|
||||
</aside>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -0,0 +1,230 @@
|
||||
import type { CustomSoundMeta, SoundPresetId } from './notificationSettings'
|
||||
|
||||
const DB_NAME = 'kefu_notification_audio'
|
||||
const DB_VERSION = 1
|
||||
const STORE_NAME = 'sounds'
|
||||
const CUSTOM_SOUND_KEY = 'custom'
|
||||
|
||||
interface StoredSoundRecord extends CustomSoundMeta {
|
||||
blob: Blob
|
||||
}
|
||||
|
||||
const AUDIO_MIME_ALLOWLIST = [
|
||||
'audio/mpeg',
|
||||
'audio/mp3',
|
||||
'audio/wav',
|
||||
'audio/x-wav',
|
||||
'audio/ogg',
|
||||
'audio/webm',
|
||||
'audio/mp4',
|
||||
'audio/aac',
|
||||
]
|
||||
|
||||
const AUDIO_EXTENSION_RE = /\.(mp3|wav|ogg|oga|m4a|aac|webm)$/i
|
||||
|
||||
function openAudioDB(): Promise<IDBDatabase> {
|
||||
return new Promise((resolve, reject) => {
|
||||
if (typeof indexedDB === 'undefined') {
|
||||
reject(new Error('当前浏览器不支持本地音频存储'))
|
||||
return
|
||||
}
|
||||
const request = indexedDB.open(DB_NAME, DB_VERSION)
|
||||
request.onupgradeneeded = () => {
|
||||
const db = request.result
|
||||
if (!db.objectStoreNames.contains(STORE_NAME)) db.createObjectStore(STORE_NAME)
|
||||
}
|
||||
request.onsuccess = () => resolve(request.result)
|
||||
request.onerror = () => reject(request.error || new Error('打开本地音频存储失败'))
|
||||
})
|
||||
}
|
||||
|
||||
export function isSupportedAudioFile(file: File): boolean {
|
||||
const mime = String(file.type || '').toLowerCase()
|
||||
return AUDIO_MIME_ALLOWLIST.includes(mime) || AUDIO_EXTENSION_RE.test(file.name)
|
||||
}
|
||||
|
||||
export function readAudioDuration(file: Blob): Promise<number> {
|
||||
return new Promise(resolve => {
|
||||
if (typeof window === 'undefined') {
|
||||
resolve(0)
|
||||
return
|
||||
}
|
||||
const url = URL.createObjectURL(file)
|
||||
const audio = new Audio()
|
||||
let settled = false
|
||||
const finish = (duration: number) => {
|
||||
if (settled) return
|
||||
settled = true
|
||||
window.clearTimeout(timer)
|
||||
audio.removeAttribute('src')
|
||||
URL.revokeObjectURL(url)
|
||||
resolve(Number.isFinite(duration) && duration > 0 ? duration : 0)
|
||||
}
|
||||
const timer = window.setTimeout(() => finish(0), 5000)
|
||||
audio.preload = 'metadata'
|
||||
audio.onloadedmetadata = () => finish(audio.duration)
|
||||
audio.onerror = () => finish(0)
|
||||
audio.src = url
|
||||
})
|
||||
}
|
||||
|
||||
export async function saveCustomSound(blob: Blob, meta: CustomSoundMeta): Promise<void> {
|
||||
const db = await openAudioDB()
|
||||
await new Promise<void>((resolve, reject) => {
|
||||
const tx = db.transaction(STORE_NAME, 'readwrite')
|
||||
tx.objectStore(STORE_NAME).put({ ...meta, blob } satisfies StoredSoundRecord, CUSTOM_SOUND_KEY)
|
||||
tx.oncomplete = () => resolve()
|
||||
tx.onerror = () => reject(tx.error || new Error('保存自定义音频失败'))
|
||||
tx.onabort = () => reject(tx.error || new Error('保存自定义音频被中止'))
|
||||
})
|
||||
db.close()
|
||||
}
|
||||
|
||||
export async function loadCustomSound(): Promise<{ meta: CustomSoundMeta; blob: Blob } | null> {
|
||||
if (typeof indexedDB === 'undefined') return null
|
||||
const db = await openAudioDB()
|
||||
const record = await new Promise<StoredSoundRecord | null>((resolve, reject) => {
|
||||
const tx = db.transaction(STORE_NAME, 'readonly')
|
||||
const request = tx.objectStore(STORE_NAME).get(CUSTOM_SOUND_KEY)
|
||||
request.onsuccess = () => resolve((request.result as StoredSoundRecord | undefined) || null)
|
||||
request.onerror = () => reject(request.error || new Error('读取自定义音频失败'))
|
||||
})
|
||||
db.close()
|
||||
if (!record?.blob) return null
|
||||
return {
|
||||
meta: {
|
||||
id: record.id,
|
||||
name: record.name,
|
||||
mime: record.mime,
|
||||
size: record.size,
|
||||
duration: record.duration,
|
||||
savedAt: record.savedAt,
|
||||
},
|
||||
blob: record.blob,
|
||||
}
|
||||
}
|
||||
|
||||
export async function clearCustomSound(): Promise<void> {
|
||||
if (typeof indexedDB === 'undefined') return
|
||||
const db = await openAudioDB()
|
||||
await new Promise<void>((resolve, reject) => {
|
||||
const tx = db.transaction(STORE_NAME, 'readwrite')
|
||||
tx.objectStore(STORE_NAME).delete(CUSTOM_SOUND_KEY)
|
||||
tx.oncomplete = () => resolve()
|
||||
tx.onerror = () => reject(tx.error || new Error('删除自定义音频失败'))
|
||||
})
|
||||
db.close()
|
||||
}
|
||||
|
||||
export async function decodeAudioBlob(context: AudioContext, blob: Blob): Promise<AudioBuffer> {
|
||||
const data = await blob.arrayBuffer()
|
||||
return context.decodeAudioData(data)
|
||||
}
|
||||
|
||||
interface Tone {
|
||||
frequency: number
|
||||
start: number
|
||||
duration: number
|
||||
type: OscillatorType
|
||||
gain: number
|
||||
slideTo?: number
|
||||
}
|
||||
|
||||
const PRESET_PATTERNS: Record<SoundPresetId, Tone[]> = {
|
||||
chime: [
|
||||
{ frequency: 880, start: 0, duration: 0.16, type: 'sine', gain: 0.26 },
|
||||
{ frequency: 660, start: 0.12, duration: 0.22, type: 'sine', gain: 0.2 },
|
||||
],
|
||||
bell: [
|
||||
{ frequency: 1046.5, start: 0, duration: 0.34, type: 'triangle', gain: 0.2 },
|
||||
{ frequency: 1568, start: 0.02, duration: 0.48, type: 'sine', gain: 0.08 },
|
||||
{ frequency: 784, start: 0.08, duration: 0.4, type: 'sine', gain: 0.07 },
|
||||
],
|
||||
pop: [
|
||||
{ frequency: 520, start: 0, duration: 0.09, type: 'sine', gain: 0.3, slideTo: 760 },
|
||||
{ frequency: 1040, start: 0.06, duration: 0.1, type: 'sine', gain: 0.12 },
|
||||
],
|
||||
urgent: [
|
||||
{ frequency: 880, start: 0, duration: 0.11, type: 'triangle', gain: 0.22 },
|
||||
{ frequency: 880, start: 0.17, duration: 0.11, type: 'triangle', gain: 0.22 },
|
||||
{ frequency: 1174.7, start: 0.34, duration: 0.25, type: 'triangle', gain: 0.26 },
|
||||
],
|
||||
}
|
||||
|
||||
export function playPresetSound(context: AudioContext, preset: SoundPresetId, volume: number): OscillatorNode[] {
|
||||
const safeVolume = Math.min(1, Math.max(0, volume))
|
||||
const base = context.currentTime + 0.01
|
||||
const oscillators: OscillatorNode[] = []
|
||||
for (const tone of PRESET_PATTERNS[preset] || PRESET_PATTERNS.chime) {
|
||||
const start = base + tone.start
|
||||
const end = start + tone.duration
|
||||
const oscillator = context.createOscillator()
|
||||
const gain = context.createGain()
|
||||
oscillator.type = tone.type
|
||||
oscillator.frequency.setValueAtTime(tone.frequency, start)
|
||||
if (tone.slideTo) oscillator.frequency.exponentialRampToValueAtTime(tone.slideTo, end)
|
||||
const peak = Math.max(0.0001, tone.gain * safeVolume)
|
||||
gain.gain.setValueAtTime(0.0001, start)
|
||||
gain.gain.exponentialRampToValueAtTime(peak, start + 0.012)
|
||||
gain.gain.exponentialRampToValueAtTime(0.0001, end)
|
||||
oscillator.connect(gain)
|
||||
gain.connect(context.destination)
|
||||
oscillator.start(start)
|
||||
oscillator.stop(end + 0.03)
|
||||
oscillators.push(oscillator)
|
||||
}
|
||||
return oscillators
|
||||
}
|
||||
|
||||
export function playAudioBuffer(context: AudioContext, buffer: AudioBuffer, volume: number): AudioBufferSourceNode {
|
||||
const source = context.createBufferSource()
|
||||
const gain = context.createGain()
|
||||
source.buffer = buffer
|
||||
gain.gain.value = Math.min(1, Math.max(0, volume))
|
||||
source.connect(gain)
|
||||
gain.connect(context.destination)
|
||||
source.start()
|
||||
return source
|
||||
}
|
||||
|
||||
export function isSpeechSupported(): boolean {
|
||||
return typeof window !== 'undefined' && 'speechSynthesis' in window && 'SpeechSynthesisUtterance' in window
|
||||
}
|
||||
|
||||
export function listSpeechVoices(): SpeechSynthesisVoice[] {
|
||||
if (!isSpeechSupported()) return []
|
||||
return window.speechSynthesis.getVoices().slice().sort((a, b) => {
|
||||
const aZh = a.lang.toLowerCase().startsWith('zh') ? 0 : 1
|
||||
const bZh = b.lang.toLowerCase().startsWith('zh') ? 0 : 1
|
||||
if (aZh !== bZh) return aZh - bZh
|
||||
return a.name.localeCompare(b.name)
|
||||
})
|
||||
}
|
||||
|
||||
export interface SpeakOptions {
|
||||
voiceURI?: string
|
||||
rate?: number
|
||||
volume?: number
|
||||
lang?: string
|
||||
}
|
||||
|
||||
export function speakText(text: string, options: SpeakOptions = {}): boolean {
|
||||
const content = text.trim()
|
||||
if (!content || !isSpeechSupported()) return false
|
||||
const synth = window.speechSynthesis
|
||||
synth.cancel()
|
||||
const utterance = new SpeechSynthesisUtterance(content)
|
||||
utterance.lang = options.lang || 'zh-CN'
|
||||
utterance.rate = Math.min(1.6, Math.max(0.6, options.rate ?? 1))
|
||||
utterance.volume = Math.min(1, Math.max(0, options.volume ?? 0.8))
|
||||
const voice = options.voiceURI
|
||||
? synth.getVoices().find(item => item.voiceURI === options.voiceURI)
|
||||
: undefined
|
||||
if (voice) utterance.voice = voice
|
||||
synth.speak(utterance)
|
||||
return true
|
||||
}
|
||||
|
||||
export function cancelSpeech(): void {
|
||||
if (isSpeechSupported()) window.speechSynthesis.cancel()
|
||||
}
|
||||
@@ -0,0 +1,137 @@
|
||||
export type SoundPresetId = 'chime' | 'bell' | 'pop' | 'urgent'
|
||||
export type SoundSource = 'preset' | 'custom'
|
||||
export type SpeakMode = 'brief' | 'summary'
|
||||
|
||||
export interface CustomSoundMeta {
|
||||
id: string
|
||||
name: string
|
||||
mime: string
|
||||
size: number
|
||||
duration: number
|
||||
savedAt: number
|
||||
}
|
||||
|
||||
export interface NotificationPreferences {
|
||||
version: 1
|
||||
soundEnabled: boolean
|
||||
desktopEnabled: boolean
|
||||
soundSource: SoundSource
|
||||
preset: SoundPresetId
|
||||
volume: number
|
||||
customSound: CustomSoundMeta | null
|
||||
speakEnabled: boolean
|
||||
speakMode: SpeakMode
|
||||
speakRate: number
|
||||
voiceURI: string
|
||||
}
|
||||
|
||||
export const SOUND_PRESETS: { value: SoundPresetId; label: string; desc: string }[] = [
|
||||
{ value: 'chime', label: '清脆叮咚', desc: '双音提示,适合日常消息' },
|
||||
{ value: 'bell', label: '轻铃', desc: '柔和铃声,适合安静环境' },
|
||||
{ value: 'pop', label: '气泡', desc: '短促轻提示,不打扰' },
|
||||
{ value: 'urgent', label: '紧急提醒', desc: '连续强提示,适合重要会话' },
|
||||
]
|
||||
|
||||
export const SPEAK_MODES: { value: SpeakMode; label: string; desc: string }[] = [
|
||||
{ value: 'brief', label: '仅提示有新消息', desc: '例如:访客发来一条新消息' },
|
||||
{ value: 'summary', label: '名称 + 内容摘要', desc: '例如:访客说,请问什么时候发货' },
|
||||
]
|
||||
|
||||
const STORAGE_KEY = 'kefu_notification_prefs_v2'
|
||||
const LEGACY_SOUND_KEY = 'kefu_notification_sound'
|
||||
const LEGACY_DESKTOP_KEY = 'kefu_notification_desktop'
|
||||
|
||||
export const MAX_CUSTOM_SOUND_BYTES = 2 * 1024 * 1024
|
||||
export const MAX_CUSTOM_SOUND_SECONDS = 15
|
||||
|
||||
function clamp(value: number, min: number, max: number): number {
|
||||
return Math.min(max, Math.max(min, value))
|
||||
}
|
||||
|
||||
function readLegacyBoolean(key: string, fallback: boolean): boolean {
|
||||
try {
|
||||
const raw = localStorage.getItem(key)
|
||||
return raw == null ? fallback : raw === 'true'
|
||||
} catch {
|
||||
return fallback
|
||||
}
|
||||
}
|
||||
|
||||
function normalizePreset(value: unknown): SoundPresetId {
|
||||
return SOUND_PRESETS.some(item => item.value === value) ? value as SoundPresetId : 'chime'
|
||||
}
|
||||
|
||||
function normalizeCustomSound(value: unknown): CustomSoundMeta | null {
|
||||
if (!value || typeof value !== 'object') return null
|
||||
const raw = value as Partial<CustomSoundMeta>
|
||||
if (typeof raw.id !== 'string' || !raw.id || typeof raw.name !== 'string' || !raw.name) return null
|
||||
return {
|
||||
id: raw.id,
|
||||
name: raw.name.slice(0, 120),
|
||||
mime: typeof raw.mime === 'string' ? raw.mime : '',
|
||||
size: Number.isFinite(raw.size) ? Number(raw.size) : 0,
|
||||
duration: Number.isFinite(raw.duration) ? Number(raw.duration) : 0,
|
||||
savedAt: Number.isFinite(raw.savedAt) ? Number(raw.savedAt) : Date.now(),
|
||||
}
|
||||
}
|
||||
|
||||
export function defaultNotificationPreferences(): NotificationPreferences {
|
||||
return {
|
||||
version: 1,
|
||||
soundEnabled: true,
|
||||
desktopEnabled: true,
|
||||
soundSource: 'preset',
|
||||
preset: 'chime',
|
||||
volume: 0.7,
|
||||
customSound: null,
|
||||
speakEnabled: false,
|
||||
speakMode: 'brief',
|
||||
speakRate: 1,
|
||||
voiceURI: '',
|
||||
}
|
||||
}
|
||||
|
||||
export function loadNotificationPreferences(): NotificationPreferences {
|
||||
const fallback = defaultNotificationPreferences()
|
||||
fallback.soundEnabled = readLegacyBoolean(LEGACY_SOUND_KEY, fallback.soundEnabled)
|
||||
fallback.desktopEnabled = readLegacyBoolean(LEGACY_DESKTOP_KEY, fallback.desktopEnabled)
|
||||
try {
|
||||
const raw = localStorage.getItem(STORAGE_KEY)
|
||||
if (!raw) return fallback
|
||||
const saved = JSON.parse(raw) as Partial<NotificationPreferences>
|
||||
const customSound = normalizeCustomSound(saved.customSound)
|
||||
const soundSource: SoundSource = saved.soundSource === 'custom' && customSound ? 'custom' : 'preset'
|
||||
return {
|
||||
version: 1,
|
||||
soundEnabled: typeof saved.soundEnabled === 'boolean' ? saved.soundEnabled : fallback.soundEnabled,
|
||||
desktopEnabled: typeof saved.desktopEnabled === 'boolean' ? saved.desktopEnabled : fallback.desktopEnabled,
|
||||
soundSource,
|
||||
preset: normalizePreset(saved.preset),
|
||||
volume: typeof saved.volume === 'number' ? clamp(saved.volume, 0, 1) : fallback.volume,
|
||||
customSound,
|
||||
speakEnabled: typeof saved.speakEnabled === 'boolean' ? saved.speakEnabled : fallback.speakEnabled,
|
||||
speakMode: saved.speakMode === 'summary' ? 'summary' : 'brief',
|
||||
speakRate: typeof saved.speakRate === 'number' ? clamp(saved.speakRate, 0.6, 1.6) : fallback.speakRate,
|
||||
voiceURI: typeof saved.voiceURI === 'string' ? saved.voiceURI.slice(0, 300) : '',
|
||||
}
|
||||
} catch {
|
||||
return fallback
|
||||
}
|
||||
}
|
||||
|
||||
export function saveNotificationPreferences(prefs: NotificationPreferences): void {
|
||||
try {
|
||||
const serialized = JSON.stringify(prefs)
|
||||
if (localStorage.getItem(STORAGE_KEY) !== serialized) {
|
||||
localStorage.setItem(STORAGE_KEY, serialized)
|
||||
}
|
||||
localStorage.setItem(LEGACY_SOUND_KEY, String(prefs.soundEnabled))
|
||||
localStorage.setItem(LEGACY_DESKTOP_KEY, String(prefs.desktopEnabled))
|
||||
} catch {
|
||||
// localStorage may be unavailable in private browsing / restricted contexts.
|
||||
}
|
||||
}
|
||||
|
||||
export function notificationPreferencesStorageKey(): string {
|
||||
return STORAGE_KEY
|
||||
}
|
||||
@@ -1,5 +1,6 @@
|
||||
import { Button, Form, Spin, Switch } from 'antd'
|
||||
import type { FormInstance } from 'antd'
|
||||
import NotificationPreferences from '@/components/common/NotificationPreferences'
|
||||
import type { SaveSettingsFn } from './types'
|
||||
|
||||
interface NotifySectionProps {
|
||||
@@ -12,6 +13,7 @@ interface NotifySectionProps {
|
||||
/** 通知设置:新会话、留言与日报偏好 */
|
||||
const NotifySection = ({ notifyForm, loading, saving, onSave }: NotifySectionProps) => {
|
||||
return (
|
||||
<>
|
||||
<div className="bg-white rounded-2xl border border-slate-200/80 shadow-xs p-6 md:p-8">
|
||||
{loading ? (
|
||||
<div className="py-16 text-center"><Spin /></div>
|
||||
@@ -32,7 +34,7 @@ const NotifySection = ({ notifyForm, loading, saving, onSave }: NotifySectionPro
|
||||
|
||||
<div className="space-y-3 mb-6">
|
||||
{[
|
||||
{ name: 'notify_new_session', title: '新会话提醒', desc: '有新访客进线时提醒;浏览器通知与声音可在工作台铃铛中配置' },
|
||||
{ name: 'notify_new_session', title: '新会话提醒', desc: '有新访客进线时提醒;声音、朗读与浏览器通知可在下方本机设置中调整' },
|
||||
{ name: 'notify_offline_leave', title: '离线留言通知', desc: '访客在离线时段提交留言时发送系统提醒' },
|
||||
{ name: 'notify_daily_report', title: '服务日报推送', desc: '每日定时汇总推送坐席接待与满意度数据摘要' },
|
||||
].map(item => (
|
||||
@@ -64,6 +66,11 @@ const NotifySection = ({ notifyForm, loading, saving, onSave }: NotifySectionPro
|
||||
</Form>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="mt-5">
|
||||
<NotificationPreferences />
|
||||
</div>
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
@@ -38,7 +38,7 @@ export const tabGroups: TabGroup[] = [
|
||||
groupName: '自动化与通知',
|
||||
items: [
|
||||
{ key: 'autoreply', label: '自动回复', desc: '多段欢迎语(含图片)与离线留言提示', icon: <MessageOutlined /> },
|
||||
{ key: 'notify', label: '通知设置', desc: '新会话、留言与日报偏好', icon: <BellOutlined /> },
|
||||
{ key: 'notify', label: '通知设置', desc: '声音、朗读、新会话与留言提醒', icon: <BellOutlined /> },
|
||||
],
|
||||
},
|
||||
]
|
||||
|
||||
@@ -1,9 +1,38 @@
|
||||
import { createContext, useCallback, useContext, useEffect, useMemo, useRef, useState, type ReactNode } from 'react'
|
||||
import { useAuth } from './auth'
|
||||
import {
|
||||
MAX_CUSTOM_SOUND_BYTES,
|
||||
MAX_CUSTOM_SOUND_SECONDS,
|
||||
loadNotificationPreferences,
|
||||
notificationPreferencesStorageKey,
|
||||
saveNotificationPreferences,
|
||||
type CustomSoundMeta,
|
||||
type NotificationPreferences,
|
||||
type SoundPresetId,
|
||||
type SoundSource,
|
||||
type SpeakMode,
|
||||
} from '@/lib/notificationSettings'
|
||||
import {
|
||||
cancelSpeech,
|
||||
clearCustomSound,
|
||||
decodeAudioBlob,
|
||||
isSpeechSupported,
|
||||
isSupportedAudioFile,
|
||||
listSpeechVoices,
|
||||
loadCustomSound,
|
||||
playAudioBuffer,
|
||||
playPresetSound,
|
||||
readAudioDuration,
|
||||
saveCustomSound,
|
||||
speakText,
|
||||
} from '@/lib/notificationAudio'
|
||||
|
||||
const SOUND_KEY = 'kefu_notification_sound'
|
||||
const DESKTOP_KEY = 'kefu_notification_desktop'
|
||||
const OPEN_SESSION_EVENT = 'kefu-open-session'
|
||||
const LEADER_KEY = 'kefu_notification_leader_v1'
|
||||
const LEADER_LEASE_MS = 12_000
|
||||
const LEADER_HEARTBEAT_MS = 4_000
|
||||
const NOTIFICATION_THROTTLE_MS = 2_000
|
||||
const SPEECH_SUMMARY_CHARS = 36
|
||||
|
||||
interface IncomingMessage {
|
||||
id?: number | string
|
||||
@@ -14,27 +43,40 @@ interface IncomingMessage {
|
||||
visitor_name?: string
|
||||
}
|
||||
|
||||
interface NotificationState {
|
||||
export interface NotificationState {
|
||||
prefs: NotificationPreferences
|
||||
soundEnabled: boolean
|
||||
desktopEnabled: boolean
|
||||
permission: NotificationPermission | 'unsupported'
|
||||
preset: SoundPresetId
|
||||
soundSource: SoundSource
|
||||
customSound: CustomSoundMeta | null
|
||||
volume: number
|
||||
speakEnabled: boolean
|
||||
speakMode: SpeakMode
|
||||
speakRate: number
|
||||
voiceURI: string
|
||||
voices: SpeechSynthesisVoice[]
|
||||
ttsSupported: boolean
|
||||
setSoundEnabled: (enabled: boolean) => void
|
||||
setDesktopEnabled: (enabled: boolean) => void
|
||||
setPreset: (preset: SoundPresetId) => void
|
||||
setSoundSource: (source: SoundSource) => void
|
||||
setVolume: (volume: number) => void
|
||||
setSpeakEnabled: (enabled: boolean) => void
|
||||
setSpeakMode: (mode: SpeakMode) => void
|
||||
setSpeakRate: (rate: number) => void
|
||||
setVoiceURI: (voiceURI: string) => void
|
||||
uploadCustomSound: (file: File) => Promise<void>
|
||||
removeCustomSound: () => Promise<void>
|
||||
previewSound: () => Promise<void>
|
||||
previewSpeech: () => boolean
|
||||
requestDesktopPermission: () => Promise<boolean>
|
||||
setActiveSessionId: (id: number | null) => void
|
||||
}
|
||||
|
||||
const NotificationContext = createContext<NotificationState | null>(null)
|
||||
|
||||
function readBoolean(key: string, fallback: boolean): boolean {
|
||||
try {
|
||||
const value = localStorage.getItem(key)
|
||||
return value == null ? fallback : value === 'true'
|
||||
} catch {
|
||||
return fallback
|
||||
}
|
||||
}
|
||||
|
||||
function messagePreview(message: IncomingMessage): string {
|
||||
if (message.type === 'image') return '[图片]'
|
||||
const content = String(message.content || '').replace(/\s+/g, ' ').trim()
|
||||
@@ -52,55 +94,229 @@ function getPermission(): NotificationPermission | 'unsupported' {
|
||||
: 'unsupported'
|
||||
}
|
||||
|
||||
function clamp(value: number, min: number, max: number): number {
|
||||
return Math.min(max, Math.max(min, value))
|
||||
}
|
||||
|
||||
function newTabId(): string {
|
||||
if (typeof crypto !== 'undefined' && typeof crypto.randomUUID === 'function') {
|
||||
return crypto.randomUUID()
|
||||
}
|
||||
return `${Date.now()}-${Math.random().toString(36).slice(2)}`
|
||||
}
|
||||
|
||||
function speechText(message: IncomingMessage, mode: SpeakMode): string {
|
||||
const name = displayName(message)
|
||||
if (message.type === 'image') return `${name}发来一张图片`
|
||||
const raw = String(message.content || '')
|
||||
.replace(/https?:\/\/\S+/gi, '链接')
|
||||
.replace(/[A-Z0-9._%+-]+@[A-Z0-9.-]+\.[A-Z]{2,}/gi, '邮箱')
|
||||
.replace(/(?:\+?86[- ]?)?1[3-9]\d{9}/g, '手机号')
|
||||
.replace(/\s+/g, ' ')
|
||||
.trim()
|
||||
if (!raw) return `${name}发来一条新消息`
|
||||
if (mode === 'brief') return `${name}发来一条新消息`
|
||||
return `${name}说,${raw.slice(0, SPEECH_SUMMARY_CHARS)}`
|
||||
}
|
||||
|
||||
export function AgentNotificationProvider({ children }: { children: ReactNode }) {
|
||||
const { user } = useAuth()
|
||||
const [soundEnabled, setSoundEnabledState] = useState(() => readBoolean(SOUND_KEY, true))
|
||||
const [desktopEnabled, setDesktopEnabledState] = useState(() => readBoolean(DESKTOP_KEY, true))
|
||||
const [prefs, setPrefsState] = useState<NotificationPreferences>(() => loadNotificationPreferences())
|
||||
const prefsRef = useRef(prefs)
|
||||
const [permission, setPermission] = useState<NotificationPermission | 'unsupported'>(getPermission)
|
||||
const permissionRef = useRef(permission)
|
||||
const [voices, setVoices] = useState<SpeechSynthesisVoice[]>(() => listSpeechVoices())
|
||||
const [ttsSupported] = useState(() => isSpeechSupported())
|
||||
const activeSessionIdRef = useRef<number | null>(null)
|
||||
const audioContextRef = useRef<AudioContext | null>(null)
|
||||
const activeSourcesRef = useRef<AudioScheduledSourceNode[]>([])
|
||||
const customBufferRef = useRef<{ id: string; buffer: AudioBuffer } | null>(null)
|
||||
const seenMessageIdsRef = useRef<Set<number>>(new Set())
|
||||
const leaderRef = useRef(false)
|
||||
const lastNotifiedAtRef = useRef(0)
|
||||
|
||||
const setSoundEnabled = useCallback((enabled: boolean) => {
|
||||
setSoundEnabledState(enabled)
|
||||
try { localStorage.setItem(SOUND_KEY, String(enabled)) } catch { /* storage unavailable */ }
|
||||
useEffect(() => {
|
||||
prefsRef.current = prefs
|
||||
saveNotificationPreferences(prefs)
|
||||
}, [prefs])
|
||||
|
||||
useEffect(() => {
|
||||
permissionRef.current = permission
|
||||
}, [permission])
|
||||
|
||||
const updatePrefs = useCallback((patch: Partial<NotificationPreferences>) => {
|
||||
const next = { ...prefsRef.current, ...patch }
|
||||
prefsRef.current = next
|
||||
setPrefsState(next)
|
||||
}, [])
|
||||
|
||||
const setDesktopEnabled = useCallback((enabled: boolean) => {
|
||||
setDesktopEnabledState(enabled)
|
||||
try { localStorage.setItem(DESKTOP_KEY, String(enabled)) } catch { /* storage unavailable */ }
|
||||
}, [])
|
||||
|
||||
const ensureAudioContext = useCallback(() => {
|
||||
if (!soundEnabled || typeof window === 'undefined') return null
|
||||
const AudioContextCtor = window.AudioContext || (window as typeof window & { webkitAudioContext?: typeof AudioContext }).webkitAudioContext
|
||||
const ensureAudioContext = useCallback((): AudioContext | null => {
|
||||
if (typeof window === 'undefined') return null
|
||||
const AudioContextCtor = window.AudioContext
|
||||
|| (window as typeof window & { webkitAudioContext?: typeof AudioContext }).webkitAudioContext
|
||||
if (!AudioContextCtor) return null
|
||||
if (!audioContextRef.current) audioContextRef.current = new AudioContextCtor()
|
||||
return audioContextRef.current
|
||||
}, [soundEnabled])
|
||||
const context = audioContextRef.current
|
||||
if (context.state === 'suspended') void context.resume()
|
||||
return context
|
||||
}, [])
|
||||
|
||||
const stopActiveSources = useCallback(() => {
|
||||
for (const source of activeSourcesRef.current) {
|
||||
try { source.stop() } catch { /* already stopped */ }
|
||||
}
|
||||
activeSourcesRef.current = []
|
||||
}, [])
|
||||
|
||||
const loadCustomBuffer = useCallback(async (): Promise<AudioBuffer | null> => {
|
||||
const meta = prefsRef.current.customSound
|
||||
if (!meta) return null
|
||||
if (customBufferRef.current?.id === meta.id) return customBufferRef.current.buffer
|
||||
const context = ensureAudioContext()
|
||||
if (!context) return null
|
||||
try {
|
||||
const stored = await loadCustomSound()
|
||||
if (!stored || stored.meta.id !== meta.id) return null
|
||||
const buffer = await decodeAudioBlob(context, stored.blob)
|
||||
customBufferRef.current = { id: meta.id, buffer }
|
||||
return buffer
|
||||
} catch {
|
||||
return null
|
||||
}
|
||||
}, [ensureAudioContext])
|
||||
|
||||
const playSoundInternal = useCallback(async (force: boolean) => {
|
||||
const current = prefsRef.current
|
||||
if (!force && !current.soundEnabled) return
|
||||
const context = ensureAudioContext()
|
||||
if (!context) return
|
||||
try { await context.resume() } catch { /* browser may require a gesture */ }
|
||||
if (context.state !== 'running') return
|
||||
stopActiveSources()
|
||||
|
||||
if (current.soundSource === 'custom' && current.customSound) {
|
||||
const buffer = await loadCustomBuffer()
|
||||
if (buffer) {
|
||||
const source = playAudioBuffer(context, buffer, current.volume)
|
||||
activeSourcesRef.current = [source]
|
||||
source.onended = () => {
|
||||
activeSourcesRef.current = activeSourcesRef.current.filter(item => item !== source)
|
||||
}
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
activeSourcesRef.current = playPresetSound(context, current.preset, current.volume)
|
||||
}, [ensureAudioContext, loadCustomBuffer, stopActiveSources])
|
||||
|
||||
const speakWithCurrentPrefs = useCallback((text: string) => {
|
||||
const current = prefsRef.current
|
||||
return speakText(text, {
|
||||
voiceURI: current.voiceURI,
|
||||
rate: current.speakRate,
|
||||
volume: current.volume,
|
||||
})
|
||||
}, [])
|
||||
|
||||
const speakIncoming = useCallback((message: IncomingMessage) => {
|
||||
if (!prefsRef.current.speakEnabled) return false
|
||||
return speakWithCurrentPrefs(speechText(message, prefsRef.current.speakMode))
|
||||
}, [speakWithCurrentPrefs])
|
||||
|
||||
const unlockAudio = useCallback(() => {
|
||||
const context = ensureAudioContext()
|
||||
if (context?.state === 'suspended') void context.resume()
|
||||
}, [ensureAudioContext])
|
||||
|
||||
const playSound = useCallback(() => {
|
||||
const setSoundEnabled = useCallback((enabled: boolean) => {
|
||||
updatePrefs({ soundEnabled: enabled })
|
||||
if (enabled) unlockAudio()
|
||||
}, [unlockAudio, updatePrefs])
|
||||
|
||||
const setDesktopEnabled = useCallback((enabled: boolean) => {
|
||||
updatePrefs({ desktopEnabled: enabled })
|
||||
}, [updatePrefs])
|
||||
|
||||
const setPreset = useCallback((preset: SoundPresetId) => {
|
||||
updatePrefs({ preset, soundSource: 'preset' })
|
||||
}, [updatePrefs])
|
||||
|
||||
const setSoundSource = useCallback((source: SoundSource) => {
|
||||
if (source === 'custom' && !prefsRef.current.customSound) return
|
||||
updatePrefs({ soundSource: source })
|
||||
}, [updatePrefs])
|
||||
|
||||
const setVolume = useCallback((volume: number) => {
|
||||
updatePrefs({ volume: clamp(volume, 0, 1) })
|
||||
}, [updatePrefs])
|
||||
|
||||
const setSpeakEnabled = useCallback((enabled: boolean) => {
|
||||
updatePrefs({ speakEnabled: enabled })
|
||||
if (enabled) unlockAudio()
|
||||
else cancelSpeech()
|
||||
}, [unlockAudio, updatePrefs])
|
||||
|
||||
const setSpeakMode = useCallback((speakMode: SpeakMode) => {
|
||||
updatePrefs({ speakMode })
|
||||
}, [updatePrefs])
|
||||
|
||||
const setSpeakRate = useCallback((speakRate: number) => {
|
||||
updatePrefs({ speakRate: clamp(speakRate, 0.6, 1.6) })
|
||||
}, [updatePrefs])
|
||||
|
||||
const setVoiceURI = useCallback((voiceURI: string) => {
|
||||
updatePrefs({ voiceURI })
|
||||
}, [updatePrefs])
|
||||
|
||||
const uploadCustomSound = useCallback(async (file: File) => {
|
||||
if (!isSupportedAudioFile(file)) {
|
||||
throw new Error('仅支持 mp3、wav、ogg、m4a、aac 音频')
|
||||
}
|
||||
if (file.size > MAX_CUSTOM_SOUND_BYTES) {
|
||||
throw new Error('音频文件不能超过 2MB')
|
||||
}
|
||||
const context = ensureAudioContext()
|
||||
if (!context) return
|
||||
const start = context.currentTime
|
||||
const oscillator = context.createOscillator()
|
||||
const gain = context.createGain()
|
||||
oscillator.type = 'sine'
|
||||
oscillator.frequency.setValueAtTime(880, start)
|
||||
oscillator.frequency.setValueAtTime(660, start + 0.11)
|
||||
gain.gain.setValueAtTime(0.0001, start)
|
||||
gain.gain.exponentialRampToValueAtTime(0.12, start + 0.015)
|
||||
gain.gain.exponentialRampToValueAtTime(0.0001, start + 0.24)
|
||||
oscillator.connect(gain)
|
||||
gain.connect(context.destination)
|
||||
oscillator.start(start)
|
||||
oscillator.stop(start + 0.25)
|
||||
}, [ensureAudioContext])
|
||||
let buffer: AudioBuffer | null = null
|
||||
let duration = 0
|
||||
if (context) {
|
||||
try {
|
||||
buffer = await decodeAudioBlob(context, file)
|
||||
duration = buffer.duration
|
||||
} catch {
|
||||
throw new Error('无法解析该音频文件,请换一个 mp3 / wav 文件')
|
||||
}
|
||||
} else {
|
||||
duration = await readAudioDuration(file)
|
||||
}
|
||||
if (duration > MAX_CUSTOM_SOUND_SECONDS) {
|
||||
throw new Error(`提示音不能超过 ${MAX_CUSTOM_SOUND_SECONDS} 秒`)
|
||||
}
|
||||
const meta: CustomSoundMeta = {
|
||||
id: `custom-${Date.now()}`,
|
||||
name: file.name.slice(0, 120),
|
||||
mime: file.type,
|
||||
size: file.size,
|
||||
duration,
|
||||
savedAt: Date.now(),
|
||||
}
|
||||
await saveCustomSound(file, meta)
|
||||
customBufferRef.current = buffer ? { id: meta.id, buffer } : null
|
||||
updatePrefs({ customSound: meta, soundSource: 'custom' })
|
||||
}, [ensureAudioContext, updatePrefs])
|
||||
|
||||
const removeCustomSound = useCallback(async () => {
|
||||
await clearCustomSound()
|
||||
customBufferRef.current = null
|
||||
updatePrefs({ customSound: null, soundSource: 'preset' })
|
||||
}, [updatePrefs])
|
||||
|
||||
const previewSound = useCallback(async () => {
|
||||
await playSoundInternal(true)
|
||||
}, [playSoundInternal])
|
||||
|
||||
const previewSpeech = useCallback(() => {
|
||||
return speakWithCurrentPrefs('您好,这是客服云的声音提醒试听。')
|
||||
}, [speakWithCurrentPrefs])
|
||||
|
||||
useEffect(() => {
|
||||
window.addEventListener('pointerdown', unlockAudio)
|
||||
@@ -111,9 +327,101 @@ export function AgentNotificationProvider({ children }: { children: ReactNode })
|
||||
}
|
||||
}, [unlockAudio])
|
||||
|
||||
useEffect(() => () => {
|
||||
void audioContextRef.current?.close()
|
||||
audioContextRef.current = null
|
||||
useEffect(() => {
|
||||
if (!ttsSupported) return
|
||||
const refreshVoices = () => setVoices(listSpeechVoices())
|
||||
refreshVoices()
|
||||
const synth = window.speechSynthesis
|
||||
synth.addEventListener?.('voiceschanged', refreshVoices)
|
||||
return () => {
|
||||
synth.removeEventListener?.('voiceschanged', refreshVoices)
|
||||
cancelSpeech()
|
||||
}
|
||||
}, [ttsSupported])
|
||||
|
||||
useEffect(() => {
|
||||
const refreshPermission = () => setPermission(getPermission())
|
||||
const onVisibilityChange = () => {
|
||||
if (document.visibilityState === 'visible') refreshPermission()
|
||||
}
|
||||
window.addEventListener('focus', refreshPermission)
|
||||
document.addEventListener('visibilitychange', onVisibilityChange)
|
||||
return () => {
|
||||
window.removeEventListener('focus', refreshPermission)
|
||||
document.removeEventListener('visibilitychange', onVisibilityChange)
|
||||
}
|
||||
}, [])
|
||||
|
||||
// Keep settings in sync when another tab changes them.
|
||||
useEffect(() => {
|
||||
const onStorage = (event: StorageEvent) => {
|
||||
if (event.key !== notificationPreferencesStorageKey()) return
|
||||
const next = loadNotificationPreferences()
|
||||
prefsRef.current = next
|
||||
setPrefsState(next)
|
||||
}
|
||||
window.addEventListener('storage', onStorage)
|
||||
return () => window.removeEventListener('storage', onStorage)
|
||||
}, [])
|
||||
|
||||
// Only one tab should play sound / speak / show desktop notifications.
|
||||
useEffect(() => {
|
||||
let active = true
|
||||
let releaseLock: (() => void) | null = null
|
||||
if (typeof navigator !== 'undefined' && 'locks' in navigator) {
|
||||
const controller = new AbortController()
|
||||
void navigator.locks.request('kefu-notification-leader', { signal: controller.signal }, async () => {
|
||||
leaderRef.current = true
|
||||
await new Promise<void>(resolve => { releaseLock = resolve })
|
||||
leaderRef.current = false
|
||||
}).catch(() => {
|
||||
// If Web Locks is unavailable despite the feature check, keep notifications working.
|
||||
if (active) leaderRef.current = true
|
||||
})
|
||||
return () => {
|
||||
active = false
|
||||
controller.abort()
|
||||
releaseLock?.()
|
||||
releaseLock = null
|
||||
leaderRef.current = false
|
||||
}
|
||||
}
|
||||
|
||||
const tabId = newTabId()
|
||||
const readLeader = (): { id: string; expiresAt: number } | null => {
|
||||
try {
|
||||
const raw = localStorage.getItem(LEADER_KEY)
|
||||
if (!raw) return null
|
||||
const parsed = JSON.parse(raw) as { id?: string; expiresAt?: number }
|
||||
if (!parsed.id || typeof parsed.expiresAt !== 'number') return null
|
||||
return { id: parsed.id, expiresAt: parsed.expiresAt }
|
||||
} catch {
|
||||
return null
|
||||
}
|
||||
}
|
||||
const claim = () => {
|
||||
try {
|
||||
const now = Date.now()
|
||||
const current = readLeader()
|
||||
if (!current || current.id === tabId || current.expiresAt <= now) {
|
||||
localStorage.setItem(LEADER_KEY, JSON.stringify({ id: tabId, expiresAt: now + LEADER_LEASE_MS }))
|
||||
leaderRef.current = true
|
||||
return
|
||||
}
|
||||
leaderRef.current = current.id === tabId
|
||||
} catch {
|
||||
// Without localStorage every tab has to behave as a single tab.
|
||||
leaderRef.current = true
|
||||
}
|
||||
}
|
||||
claim()
|
||||
const timer = window.setInterval(claim, LEADER_HEARTBEAT_MS)
|
||||
return () => {
|
||||
window.clearInterval(timer)
|
||||
try {
|
||||
if (readLeader()?.id === tabId) localStorage.removeItem(LEADER_KEY)
|
||||
} catch { /* ignore */ }
|
||||
}
|
||||
}, [])
|
||||
|
||||
const requestDesktopPermission = useCallback(async () => {
|
||||
@@ -148,12 +456,25 @@ export function AgentNotificationProvider({ children }: { children: ReactNode })
|
||||
}
|
||||
|
||||
const showNotification = (message: IncomingMessage, sessionId: number) => {
|
||||
const body = `${displayName(message)}:${messagePreview(message)}`
|
||||
if (soundEnabled) playSound()
|
||||
if (!desktopEnabled || permission !== 'granted') return
|
||||
if (!leaderRef.current) return
|
||||
if (activeSessionIdRef.current === sessionId && document.visibilityState === 'visible') return
|
||||
const now = Date.now()
|
||||
if (now - lastNotifiedAtRef.current < NOTIFICATION_THROTTLE_MS) return
|
||||
lastNotifiedAtRef.current = now
|
||||
|
||||
const current = prefsRef.current
|
||||
if (current.soundEnabled) void playSoundInternal(false)
|
||||
if (current.speakEnabled) {
|
||||
window.setTimeout(() => {
|
||||
if (prefsRef.current.speakEnabled) speakIncoming(message)
|
||||
}, 180)
|
||||
}
|
||||
if (!current.desktopEnabled || permissionRef.current !== 'granted') return
|
||||
|
||||
const notification = new window.Notification('客服云:新消息', {
|
||||
body,
|
||||
body: `${displayName(message)}:${messagePreview(message)}`,
|
||||
tag: `kefu-session-${sessionId}`,
|
||||
silent: current.soundEnabled || current.speakEnabled,
|
||||
})
|
||||
notification.onclick = () => {
|
||||
window.focus()
|
||||
@@ -181,7 +502,6 @@ export function AgentNotificationProvider({ children }: { children: ReactNode })
|
||||
const first = seenMessageIdsRef.current.values().next().value
|
||||
if (typeof first === 'number') seenMessageIdsRef.current.delete(first)
|
||||
}
|
||||
if (activeSessionIdRef.current === sessionId && document.visibilityState === 'visible') return
|
||||
showNotification(payload.data, sessionId)
|
||||
} catch {
|
||||
// Ignore malformed WebSocket frames.
|
||||
@@ -207,17 +527,66 @@ export function AgentNotificationProvider({ children }: { children: ReactNode })
|
||||
activeSocket = null
|
||||
}
|
||||
}
|
||||
}, [desktopEnabled, permission, playSound, soundEnabled, user?.token])
|
||||
}, [playSoundInternal, speakIncoming, user?.token])
|
||||
|
||||
useEffect(() => () => {
|
||||
cancelSpeech()
|
||||
stopActiveSources()
|
||||
void audioContextRef.current?.close()
|
||||
audioContextRef.current = null
|
||||
}, [stopActiveSources])
|
||||
|
||||
const value = useMemo(() => ({
|
||||
soundEnabled,
|
||||
desktopEnabled,
|
||||
prefs,
|
||||
soundEnabled: prefs.soundEnabled,
|
||||
desktopEnabled: prefs.desktopEnabled,
|
||||
permission,
|
||||
preset: prefs.preset,
|
||||
soundSource: prefs.soundSource,
|
||||
customSound: prefs.customSound,
|
||||
volume: prefs.volume,
|
||||
speakEnabled: prefs.speakEnabled,
|
||||
speakMode: prefs.speakMode,
|
||||
speakRate: prefs.speakRate,
|
||||
voiceURI: prefs.voiceURI,
|
||||
voices,
|
||||
ttsSupported,
|
||||
setSoundEnabled,
|
||||
setDesktopEnabled,
|
||||
setPreset,
|
||||
setSoundSource,
|
||||
setVolume,
|
||||
setSpeakEnabled,
|
||||
setSpeakMode,
|
||||
setSpeakRate,
|
||||
setVoiceURI,
|
||||
uploadCustomSound,
|
||||
removeCustomSound,
|
||||
previewSound,
|
||||
previewSpeech,
|
||||
requestDesktopPermission,
|
||||
setActiveSessionId,
|
||||
}), [desktopEnabled, permission, requestDesktopPermission, setActiveSessionId, setDesktopEnabled, setSoundEnabled, soundEnabled])
|
||||
}), [
|
||||
permission,
|
||||
prefs,
|
||||
previewSound,
|
||||
previewSpeech,
|
||||
removeCustomSound,
|
||||
requestDesktopPermission,
|
||||
setActiveSessionId,
|
||||
setDesktopEnabled,
|
||||
setPreset,
|
||||
setSoundEnabled,
|
||||
setSoundSource,
|
||||
setSpeakEnabled,
|
||||
setSpeakMode,
|
||||
setSpeakRate,
|
||||
setVoiceURI,
|
||||
setVolume,
|
||||
ttsSupported,
|
||||
uploadCustomSound,
|
||||
voices,
|
||||
])
|
||||
|
||||
return <NotificationContext.Provider value={value}>{children}</NotificationContext.Provider>
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user