feat(web): 增强消息提醒声音与语音朗读
CI / Frontend (typecheck + lint + build) (push) Failing after 18m50s
CI / Backend (go vet + test) (push) Failing after 18m50s

This commit is contained in:
yml2213
2026-10-29 21:53:06 +08:00
parent e6837a4878
commit 3d04175ef3
7 changed files with 1140 additions and 61 deletions
@@ -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>
)
}
+51 -2
View File
@@ -4,11 +4,14 @@ import {
AppstoreOutlined, HistoryOutlined, TeamOutlined, AppstoreOutlined, HistoryOutlined, TeamOutlined,
FileTextOutlined, BarChartOutlined, SettingOutlined, LogoutOutlined, FileTextOutlined, BarChartOutlined, SettingOutlined, LogoutOutlined,
ThunderboltOutlined, DownOutlined, StopOutlined, SafetyOutlined, BellOutlined, BellFilled, ThunderboltOutlined, DownOutlined, StopOutlined, SafetyOutlined, BellOutlined, BellFilled,
SoundOutlined,
} from '@ant-design/icons' } 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 { useAuth } from '@/stores/auth'
import { LogoIcon } from '@/components/common/Logo' import { LogoIcon } from '@/components/common/Logo'
import NotificationPreferences from '@/components/common/NotificationPreferences'
import { useAgentNotifications } from '@/stores/notifications' import { useAgentNotifications } from '@/stores/notifications'
import { SOUND_PRESETS } from '@/lib/notificationSettings'
import type { AgentPresence } from '@/services/api' import type { AgentPresence } from '@/services/api'
const menuItems = [ const menuItems = [
@@ -45,8 +48,14 @@ const AgentSidebar = () => {
setSoundEnabled, setSoundEnabled,
setDesktopEnabled, setDesktopEnabled,
requestDesktopPermission, requestDesktopPermission,
preset,
soundSource,
customSound,
volume,
} = useAgentNotifications() } = useAgentNotifications()
const [switching, setSwitching] = useState(false) 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 visibleMenuItems = menuItems.filter(item => item.permissions.some(code => permissions.has(code)))
const selectedKey = visibleMenuItems.find(item => location.pathname.startsWith(item.key))?.key || '/agent/dashboard' 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 initial = (user?.nickname || user?.username || '客').slice(0, 1).toUpperCase()
const current = presenceMeta(user?.status || 'online') const current = presenceMeta(user?.status || 'online')
const soundLabel = soundSource === 'custom' && customSound
? customSound.name
: SOUND_PRESETS.find(item => item.value === preset)?.label || '清脆叮咚'
const notificationContent = ( const notificationContent = (
<div className="w-68 p-1 space-y-3.5"> <div className="w-68 p-1 space-y-3.5">
@@ -79,6 +91,11 @@ const AgentSidebar = () => {
<div> <div>
<div className="text-sm font-medium text-slate-800">声音提醒</div> <div className="text-sm font-medium text-slate-800">声音提醒</div>
<div className="text-xs text-slate-400">收到访客消息时播放提示音</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> </div>
<Switch size="small" checked={soundEnabled} onChange={setSoundEnabled} /> <Switch size="small" checked={soundEnabled} onChange={setSoundEnabled} />
</div> </div>
@@ -109,6 +126,20 @@ const AgentSidebar = () => {
授权浏览器通知权限 授权浏览器通知权限
</Button> </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> </div>
) )
@@ -127,7 +158,14 @@ const AgentSidebar = () => {
<span className="font-bold text-slate-900 text-[14px] leading-tight truncate">客服工作台</span> <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> <span className="text-[10px] text-blue-600 font-semibold font-mono tracking-wider leading-none mt-0.5">KEFU CLOUD</span>
</div> </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 <button
type="button" type="button"
title="消息提醒设置" title="消息提醒设置"
@@ -222,6 +260,17 @@ const AgentSidebar = () => {
</Tooltip> </Tooltip>
</div> </div>
</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> </aside>
) )
} }
+230
View File
@@ -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()
}
+137
View File
@@ -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 { Button, Form, Spin, Switch } from 'antd'
import type { FormInstance } from 'antd' import type { FormInstance } from 'antd'
import NotificationPreferences from '@/components/common/NotificationPreferences'
import type { SaveSettingsFn } from './types' import type { SaveSettingsFn } from './types'
interface NotifySectionProps { interface NotifySectionProps {
@@ -12,6 +13,7 @@ interface NotifySectionProps {
/** 通知设置:新会话、留言与日报偏好 */ /** 通知设置:新会话、留言与日报偏好 */
const NotifySection = ({ notifyForm, loading, saving, onSave }: NotifySectionProps) => { const NotifySection = ({ notifyForm, loading, saving, onSave }: NotifySectionProps) => {
return ( return (
<>
<div className="bg-white rounded-2xl border border-slate-200/80 shadow-xs p-6 md:p-8"> <div className="bg-white rounded-2xl border border-slate-200/80 shadow-xs p-6 md:p-8">
{loading ? ( {loading ? (
<div className="py-16 text-center"><Spin /></div> <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"> <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_offline_leave', title: '离线留言通知', desc: '访客在离线时段提交留言时发送系统提醒' },
{ name: 'notify_daily_report', title: '服务日报推送', desc: '每日定时汇总推送坐席接待与满意度数据摘要' }, { name: 'notify_daily_report', title: '服务日报推送', desc: '每日定时汇总推送坐席接待与满意度数据摘要' },
].map(item => ( ].map(item => (
@@ -64,6 +66,11 @@ const NotifySection = ({ notifyForm, loading, saving, onSave }: NotifySectionPro
</Form> </Form>
)} )}
</div> </div>
<div className="mt-5">
<NotificationPreferences />
</div>
</>
) )
} }
+1 -1
View File
@@ -38,7 +38,7 @@ export const tabGroups: TabGroup[] = [
groupName: '自动化与通知', groupName: '自动化与通知',
items: [ items: [
{ key: 'autoreply', label: '自动回复', desc: '多段欢迎语(含图片)与离线留言提示', icon: <MessageOutlined /> }, { key: 'autoreply', label: '自动回复', desc: '多段欢迎语(含图片)与离线留言提示', icon: <MessageOutlined /> },
{ key: 'notify', label: '通知设置', desc: '新会话、留言与日报偏好', icon: <BellOutlined /> }, { key: 'notify', label: '通知设置', desc: '声音、朗读、新会话与留言提醒', icon: <BellOutlined /> },
], ],
}, },
] ]
+424 -55
View File
@@ -1,9 +1,38 @@
import { createContext, useCallback, useContext, useEffect, useMemo, useRef, useState, type ReactNode } from 'react' import { createContext, useCallback, useContext, useEffect, useMemo, useRef, useState, type ReactNode } from 'react'
import { useAuth } from './auth' 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 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 { interface IncomingMessage {
id?: number | string id?: number | string
@@ -14,27 +43,40 @@ interface IncomingMessage {
visitor_name?: string visitor_name?: string
} }
interface NotificationState { export interface NotificationState {
prefs: NotificationPreferences
soundEnabled: boolean soundEnabled: boolean
desktopEnabled: boolean desktopEnabled: boolean
permission: NotificationPermission | 'unsupported' 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 setSoundEnabled: (enabled: boolean) => void
setDesktopEnabled: (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> requestDesktopPermission: () => Promise<boolean>
setActiveSessionId: (id: number | null) => void setActiveSessionId: (id: number | null) => void
} }
const NotificationContext = createContext<NotificationState | null>(null) 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 { function messagePreview(message: IncomingMessage): string {
if (message.type === 'image') return '[图片]' if (message.type === 'image') return '[图片]'
const content = String(message.content || '').replace(/\s+/g, ' ').trim() const content = String(message.content || '').replace(/\s+/g, ' ').trim()
@@ -52,55 +94,229 @@ function getPermission(): NotificationPermission | 'unsupported' {
: '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 }) { export function AgentNotificationProvider({ children }: { children: ReactNode }) {
const { user } = useAuth() const { user } = useAuth()
const [soundEnabled, setSoundEnabledState] = useState(() => readBoolean(SOUND_KEY, true)) const [prefs, setPrefsState] = useState<NotificationPreferences>(() => loadNotificationPreferences())
const [desktopEnabled, setDesktopEnabledState] = useState(() => readBoolean(DESKTOP_KEY, true)) const prefsRef = useRef(prefs)
const [permission, setPermission] = useState<NotificationPermission | 'unsupported'>(getPermission) 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 activeSessionIdRef = useRef<number | null>(null)
const audioContextRef = useRef<AudioContext | 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 seenMessageIdsRef = useRef<Set<number>>(new Set())
const leaderRef = useRef(false)
const lastNotifiedAtRef = useRef(0)
const setSoundEnabled = useCallback((enabled: boolean) => { useEffect(() => {
setSoundEnabledState(enabled) prefsRef.current = prefs
try { localStorage.setItem(SOUND_KEY, String(enabled)) } catch { /* storage unavailable */ } 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) => { const ensureAudioContext = useCallback((): AudioContext | null => {
setDesktopEnabledState(enabled) if (typeof window === 'undefined') return null
try { localStorage.setItem(DESKTOP_KEY, String(enabled)) } catch { /* storage unavailable */ } const AudioContextCtor = window.AudioContext
}, []) || (window as typeof window & { webkitAudioContext?: typeof AudioContext }).webkitAudioContext
const ensureAudioContext = useCallback(() => {
if (!soundEnabled || typeof window === 'undefined') return null
const AudioContextCtor = window.AudioContext || (window as typeof window & { webkitAudioContext?: typeof AudioContext }).webkitAudioContext
if (!AudioContextCtor) return null if (!AudioContextCtor) return null
if (!audioContextRef.current) audioContextRef.current = new AudioContextCtor() if (!audioContextRef.current) audioContextRef.current = new AudioContextCtor()
return audioContextRef.current const context = audioContextRef.current
}, [soundEnabled]) 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 unlockAudio = useCallback(() => {
const context = ensureAudioContext() const context = ensureAudioContext()
if (context?.state === 'suspended') void context.resume() if (context?.state === 'suspended') void context.resume()
}, [ensureAudioContext]) }, [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() const context = ensureAudioContext()
if (!context) return let buffer: AudioBuffer | null = null
const start = context.currentTime let duration = 0
const oscillator = context.createOscillator() if (context) {
const gain = context.createGain() try {
oscillator.type = 'sine' buffer = await decodeAudioBlob(context, file)
oscillator.frequency.setValueAtTime(880, start) duration = buffer.duration
oscillator.frequency.setValueAtTime(660, start + 0.11) } catch {
gain.gain.setValueAtTime(0.0001, start) throw new Error('无法解析该音频文件,请换一个 mp3 / wav 文件')
gain.gain.exponentialRampToValueAtTime(0.12, start + 0.015) }
gain.gain.exponentialRampToValueAtTime(0.0001, start + 0.24) } else {
oscillator.connect(gain) duration = await readAudioDuration(file)
gain.connect(context.destination) }
oscillator.start(start) if (duration > MAX_CUSTOM_SOUND_SECONDS) {
oscillator.stop(start + 0.25) throw new Error(`提示音不能超过 ${MAX_CUSTOM_SOUND_SECONDS} 秒`)
}, [ensureAudioContext]) }
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(() => { useEffect(() => {
window.addEventListener('pointerdown', unlockAudio) window.addEventListener('pointerdown', unlockAudio)
@@ -111,9 +327,101 @@ export function AgentNotificationProvider({ children }: { children: ReactNode })
} }
}, [unlockAudio]) }, [unlockAudio])
useEffect(() => () => { useEffect(() => {
void audioContextRef.current?.close() if (!ttsSupported) return
audioContextRef.current = null 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 () => { const requestDesktopPermission = useCallback(async () => {
@@ -148,12 +456,25 @@ export function AgentNotificationProvider({ children }: { children: ReactNode })
} }
const showNotification = (message: IncomingMessage, sessionId: number) => { const showNotification = (message: IncomingMessage, sessionId: number) => {
const body = `${displayName(message)}:${messagePreview(message)}` if (!leaderRef.current) return
if (soundEnabled) playSound() if (activeSessionIdRef.current === sessionId && document.visibilityState === 'visible') return
if (!desktopEnabled || permission !== 'granted') 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('客服云:新消息', { const notification = new window.Notification('客服云:新消息', {
body, body: `${displayName(message)}:${messagePreview(message)}`,
tag: `kefu-session-${sessionId}`, tag: `kefu-session-${sessionId}`,
silent: current.soundEnabled || current.speakEnabled,
}) })
notification.onclick = () => { notification.onclick = () => {
window.focus() window.focus()
@@ -181,7 +502,6 @@ export function AgentNotificationProvider({ children }: { children: ReactNode })
const first = seenMessageIdsRef.current.values().next().value const first = seenMessageIdsRef.current.values().next().value
if (typeof first === 'number') seenMessageIdsRef.current.delete(first) if (typeof first === 'number') seenMessageIdsRef.current.delete(first)
} }
if (activeSessionIdRef.current === sessionId && document.visibilityState === 'visible') return
showNotification(payload.data, sessionId) showNotification(payload.data, sessionId)
} catch { } catch {
// Ignore malformed WebSocket frames. // Ignore malformed WebSocket frames.
@@ -207,17 +527,66 @@ export function AgentNotificationProvider({ children }: { children: ReactNode })
activeSocket = null 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(() => ({ const value = useMemo(() => ({
soundEnabled, prefs,
desktopEnabled, soundEnabled: prefs.soundEnabled,
desktopEnabled: prefs.desktopEnabled,
permission, 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, setSoundEnabled,
setDesktopEnabled, setDesktopEnabled,
setPreset,
setSoundSource,
setVolume,
setSpeakEnabled,
setSpeakMode,
setSpeakRate,
setVoiceURI,
uploadCustomSound,
removeCustomSound,
previewSound,
previewSpeech,
requestDesktopPermission, requestDesktopPermission,
setActiveSessionId, 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> return <NotificationContext.Provider value={value}>{children}</NotificationContext.Provider>
} }