diff --git a/web/src/components/common/NotificationPreferences.tsx b/web/src/components/common/NotificationPreferences.tsx
new file mode 100644
index 0000000..7dfb6e6
--- /dev/null
+++ b/web/src/components/common/NotificationPreferences.tsx
@@ -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 (
+
+
+
+
+
+ 声音与朗读
+
+
仅保存在当前浏览器,换设备后需要重新设置
+
+
本机设置
+
+
+
+
+
+ {soundEnabled && (
+
+
+
提示音
+
+
}
+ loading={previewing}
+ onClick={() => { void handlePreview() }}
+ >
+ 试听
+
+
+
+
+
音量
+
+
+ {Math.round(volume * 100)}%
+
+
+
+
+
自定义
+
+ {customSound ? (
+
+
+
{customSound.name}
+ {customSound.duration > 0 && (
+
{formatDuration(customSound.duration)}
+ )}
+
+ ) : (
+
未上传,使用上方预设音
+ )}
+
+
{
+ void handleUpload(file)
+ return Upload.LIST_IGNORE
+ }}
+ >
+ } loading={uploading}>上传
+
+ {customSound && (
+
{ void handleRemove() }}
+ okText="删除"
+ cancelText="取消"
+ >
+ } loading={removing} aria-label="删除自定义提示音" />
+
+ )}
+
+
+
+ 支持 mp3 / wav / ogg / m4a,文件不超过 {Math.round(MAX_CUSTOM_SOUND_BYTES / 1024 / 1024)}MB,
+ 建议时长不超过 {MAX_CUSTOM_SOUND_SECONDS} 秒。
+
+
+ )}
+
+
+
+
+
消息朗读
+
+ {ttsSupported ? '使用系统语音播报新访客消息' : '当前浏览器不支持 Web Speech 朗读'}
+
+
+
+
+
+ {speakEnabled && ttsSupported && (
+
+
+
朗读内容
+
+
+ {SPEAK_MODES.find(item => item.value === speakMode)?.desc}
+
+
+
语速
+
+
+ {speakRate.toFixed(2)}x
+
+
+
+
+
+
} onClick={handleSpeechPreview}>试听朗读
+
首次使用请先点击试听,部分浏览器需要用户交互才能播放
+
+
+ )}
+
+
+
+ )
+}
diff --git a/web/src/components/layout/AgentSidebar.tsx b/web/src/components/layout/AgentSidebar.tsx
index 0d8c680..d9cb509 100644
--- a/web/src/components/layout/AgentSidebar.tsx
+++ b/web/src/components/layout/AgentSidebar.tsx
@@ -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 = (
@@ -79,6 +91,11 @@ const AgentSidebar = () => {
声音提醒
收到访客消息时播放提示音
+ {soundEnabled && (
+
+ {soundLabel} · 音量 {Math.round(volume * 100)}%
+
+ )}
@@ -109,6 +126,20 @@ const AgentSidebar = () => {
授权浏览器通知权限
)}
+
+ }
+ 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)
+ }}
+ >
+ 声音与朗读设置
+
+
)
@@ -127,7 +158,14 @@ const AgentSidebar = () => {
客服工作台
KEFU CLOUD
- 消息提醒设置}>
+ 消息提醒设置}
+ >