fix(widget): 完善自动回复与新会话进线提醒
CI / Backend (go vet + test) (push) Failing after 40m21s
CI / Frontend (typecheck + lint + build) (push) Successful in 1h21m3s

This commit is contained in:
yml2213
2026-11-03 19:29:32 +08:00
parent 0eb16c1ce2
commit 24fac1bcf1
15 changed files with 275 additions and 115 deletions
+15
View File
@@ -18,6 +18,7 @@
console.warn('[kefu-widget] missing data-id on script tag');
return;
}
var autoOpen = !(script && /^(false|0)$/i.test(script.getAttribute('data-auto-open') || ''));
var src = script && script.src ? script.src : '';
var base = '';
@@ -139,6 +140,15 @@
}
}
function shouldAutoOpenOnce() {
var key = 'kefu_widget_auto_opened_' + channelKey;
try {
if (window.sessionStorage.getItem(key) === '1') return false;
window.sessionStorage.setItem(key, '1');
} catch (e) { /* privacy mode: fall back to opening on each page load */ }
return true;
}
btn.addEventListener('click', function () { setOpen(true); });
window.addEventListener('message', function (event) {
@@ -188,6 +198,11 @@
function mount() {
document.body.appendChild(btn);
document.body.appendChild(panel);
if (autoOpen) {
window.setTimeout(function () {
if (!open && shouldAutoOpenOnce()) setOpen(true);
}, 600);
}
}
if (document.readyState === 'loading') {
document.addEventListener('DOMContentLoaded', mount);
@@ -137,7 +137,7 @@ export default function NotificationPreferences({ className = '' }: Notification
<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 className="text-xs text-slate-400 mt-0.5">新访客进线或收到访客消息时播放提示音</div>
</div>
<Switch checked={soundEnabled} onChange={setSoundEnabled} />
</div>
+25 -11
View File
@@ -1,4 +1,4 @@
import { useEffect, useState } from 'react'
import { useEffect, useMemo, useState } from 'react'
import { Button, Form, message } from 'antd'
import { PlusOutlined } from '@ant-design/icons'
import {
@@ -24,11 +24,19 @@ import TagsSection from './settings/TagsSection'
import TagModal from './settings/TagModal'
const Settings = () => {
const { user } = useAuth()
const isAdmin = usePermission('settings.basic')
const { user, permissions } = useAuth()
const canViewStaff = usePermission('settings.staff')
const canEditAssignment = usePermission('settings.assign_rule')
const [activeTab, setActiveTab] = useState('basic')
const visibleTabGroups = useMemo(
() => tabGroups
.map(group => ({ ...group, items: group.items.filter(item => permissions.has(item.permission)) }))
.filter(group => group.items.length > 0),
[permissions],
)
const visibleTabItems = useMemo(() => visibleTabGroups.flatMap(group => group.items), [visibleTabGroups])
const [activeTab, setActiveTab] = useState(() => visibleTabItems[0]?.key || '')
const [basicForm] = Form.useForm()
const [autoReplyForm] = Form.useForm()
const [workForm] = Form.useForm()
@@ -60,7 +68,7 @@ const Settings = () => {
const [savingTag, setSavingTag] = useState(false)
const [tagForm] = Form.useForm()
const currentTab = tabItems.find(t => t.key === activeTab) || tabItems[0]
const currentTab = visibleTabItems.find(t => t.key === activeTab) || visibleTabItems[0] || tabItems[0]
const loadCustomerTags = async () => {
setLoadingTags(true)
@@ -146,6 +154,12 @@ const Settings = () => {
}
}
useEffect(() => {
if (visibleTabItems.length > 0 && !visibleTabItems.some(item => item.key === activeTab)) {
setActiveTab(visibleTabItems[0].key)
}
}, [activeTab, visibleTabItems])
useEffect(() => {
if (activeTab === 'channels') loadChannels()
if (['basic', 'autoreply', 'worktime', 'notify', 'assignment'].includes(activeTab)) loadSettings()
@@ -284,7 +298,7 @@ const Settings = () => {
</Button>
) : null
const currentGroup = tabGroups.find(g => g.items.some(i => i.key === activeTab))
const currentGroup = visibleTabGroups.find(g => g.items.some(i => i.key === activeTab))
return (
<div className="h-full flex min-h-0 overflow-hidden bg-slate-50/60">
@@ -298,7 +312,7 @@ const Settings = () => {
<div className="text-[10px] text-slate-400 truncate leading-tight mt-0.5 font-medium">企业配置与业务规则</div>
</div>
<nav className="flex-1 overflow-y-auto p-3 space-y-4">
{tabGroups.map(group => (
{visibleTabGroups.map(group => (
<div key={group.groupName} className="space-y-1">
<div className="text-[10px] font-bold text-slate-400 uppercase tracking-wider px-2.5 mb-1 select-none">
{group.groupName}
@@ -345,7 +359,7 @@ const Settings = () => {
<p className="text-xs text-slate-500 m-0 leading-tight truncate">{currentTab.desc}</p>
</div>
{panelHeaderAction}
{activeTab === 'staff' && isAdmin && (
{activeTab === 'staff' && canViewStaff && (
<Button
type="primary"
size="small"
@@ -386,7 +400,7 @@ const Settings = () => {
<AssignmentSection
assignForm={assignForm}
loading={loadingSettings && !settings}
isAdmin={isAdmin}
isAdmin={canEditAssignment}
saving={saving}
onSave={savePartial}
/>
@@ -397,7 +411,7 @@ const Settings = () => {
canViewStaff={canViewStaff}
loadingStaff={loadingStaff}
staffList={staffList}
isAdmin={isAdmin}
isAdmin={canViewStaff}
seatUsed={seatUsed}
seatLimit={seatLimit}
currentUserId={user?.user_id}
@@ -467,7 +481,7 @@ const Settings = () => {
setStaffModalOpen={setStaffModalOpen}
editingStaff={editingStaff}
savingStaff={savingStaff}
isAdmin={isAdmin}
isAdmin={canViewStaff}
staffForm={staffForm}
onFinish={handleSaveStaff}
/>
+9 -8
View File
@@ -9,6 +9,7 @@ export interface TabItem {
key: string
label: string
desc: string
permission: string
icon: ReactNode
}
@@ -21,24 +22,24 @@ export const tabGroups: TabGroup[] = [
{
groupName: '企业与渠道',
items: [
{ key: 'basic', label: '基本设置', desc: '租户展示名称、默认昵称与时区', icon: <SettingOutlined /> },
{ key: 'channels', label: '渠道管理', desc: '配置客户服务接入渠道与嵌入代码', icon: <ApiOutlined /> },
{ key: 'staff', label: '坐席账号', desc: '管理客服/主管账号与坐席配额', icon: <UserSwitchOutlined /> },
{ key: 'basic', label: '基本设置', desc: '租户展示名称、默认昵称与时区', permission: 'settings.basic', icon: <SettingOutlined /> },
{ key: 'channels', label: '渠道管理', desc: '配置客户服务接入渠道与嵌入代码', permission: 'settings.channel', icon: <ApiOutlined /> },
{ key: 'staff', label: '坐席账号', desc: '管理客服/主管账号与坐席配额', permission: 'settings.staff', icon: <UserSwitchOutlined /> },
],
},
{
groupName: '规则与路由',
items: [
{ key: 'assignment', label: '客服分配规则', desc: '自动分配策略与并发上限', icon: <TeamOutlined /> },
{ key: 'tags', label: '客户标签', desc: '维护标签库,坐席为客户打标时选择', icon: <TagsOutlined /> },
{ key: 'worktime', label: '工作时间', desc: '在线服务时段与非工作时间提示', icon: <ClockCircleOutlined /> },
{ key: 'assignment', label: '客服分配规则', desc: '自动分配策略与并发上限', permission: 'settings.assign_rule', icon: <TeamOutlined /> },
{ key: 'tags', label: '客户标签', desc: '维护标签库,坐席为客户打标时选择', permission: 'settings.customer_tag', icon: <TagsOutlined /> },
{ key: 'worktime', label: '工作时间', desc: '在线服务时段与非工作时间提示', permission: 'settings.worktime', icon: <ClockCircleOutlined /> },
],
},
{
groupName: '自动化与通知',
items: [
{ key: 'autoreply', label: '自动回复', desc: '多段欢迎语(含图片)与离线留言提示', icon: <MessageOutlined /> },
{ key: 'notify', label: '通知设置', desc: '声音、朗读、新会话与留言提醒', icon: <BellOutlined /> },
{ key: 'autoreply', label: '自动回复', desc: '多段欢迎语(含图片)与离线留言提示', permission: 'settings.auto_reply', icon: <MessageOutlined /> },
{ key: 'notify', label: '通知设置', desc: '声音、朗读、新会话与留言提醒', permission: 'settings.notification', icon: <BellOutlined /> },
],
},
]
+6 -1
View File
@@ -24,6 +24,11 @@ const Ops = lazy(() => import('@/pages/admin/Ops'))
const WidgetPreview = lazy(() => import('@/pages/WidgetPreview'))
const WidgetEmbed = lazy(() => import('@/pages/WidgetEmbed'))
const SETTINGS_PERMISSIONS: string[] = [
'settings.basic', 'settings.channel', 'settings.staff', 'settings.assign_rule',
'settings.customer_tag', 'settings.auto_reply', 'settings.worktime', 'settings.notification',
]
const loading = (
<div className="h-full flex items-center justify-center">
<Spin size="large" />
@@ -74,10 +79,10 @@ export const router = createBrowserRouter([
{ element: <RequirePermission anyOf={['quick_reply.view']} />, children: [{ path: 'quick-replies', element: <Lazy><QuickReplies /></Lazy> }] },
{ element: <RequirePermission anyOf={['blacklist.view']} />, children: [{ path: 'blacklist', element: <Lazy><Blacklist /></Lazy> }] },
{ element: <RequirePermission anyOf={['statistics.view']} />, children: [{ path: 'statistics', element: <Lazy><Statistics /></Lazy> }] },
{ element: <RequirePermission anyOf={SETTINGS_PERMISSIONS} />, children: [{ path: 'settings', element: <Lazy><Settings /></Lazy> }] },
{
element: <RequireTenantAdmin />,
children: [
{ element: <RequirePermission anyOf={['settings.basic']} />, children: [{ path: 'settings', element: <Lazy><Settings /></Lazy> }] },
{ element: <RequirePermission anyOf={['permission.view']} />, children: [
{ path: 'roles', element: <Lazy><RoleList /></Lazy> },
{ path: 'roles/:id', element: <Lazy><RoleConfig /></Lazy> },
+40 -14
View File
@@ -218,11 +218,6 @@ export function AgentNotificationProvider({ children }: { children: ReactNode })
})
}, [])
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()
@@ -455,34 +450,58 @@ export function AgentNotificationProvider({ children }: { children: ReactNode })
}
}
const showNotification = (message: IncomingMessage, sessionId: number) => {
const showAlert = (input: {
sessionId: number
title: string
body: string
speech?: string
}) => {
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) {
if (current.speakEnabled && input.speech) {
window.setTimeout(() => {
if (prefsRef.current.speakEnabled) speakIncoming(message)
if (prefsRef.current.speakEnabled) speakWithCurrentPrefs(input.speech!)
}, 180)
}
if (!current.desktopEnabled || permissionRef.current !== 'granted') return
const notification = new window.Notification('客服云:新消息', {
body: `${displayName(message)}:${messagePreview(message)}`,
tag: `kefu-session-${sessionId}`,
const notification = new window.Notification(input.title, {
body: input.body,
tag: `kefu-session-${input.sessionId}`,
silent: current.soundEnabled || current.speakEnabled,
})
notification.onclick = () => {
window.focus()
window.dispatchEvent(new CustomEvent(OPEN_SESSION_EVENT, { detail: sessionId }))
window.dispatchEvent(new CustomEvent(OPEN_SESSION_EVENT, { detail: input.sessionId }))
notification.close()
}
}
const showNotification = (message: IncomingMessage, sessionId: number) => {
if (activeSessionIdRef.current === sessionId && document.visibilityState === 'visible') return
showAlert({
sessionId,
title: '客服云:新消息',
body: `${displayName(message)}:${messagePreview(message)}`,
speech: speechText(message, prefsRef.current.speakMode),
})
}
const showNewSessionNotification = (visitorName: string, sessionId: number) => {
const name = visitorName.trim() || '新访客'
showAlert({
sessionId,
title: '客服云:新访客进线',
body: `${name}进入网站并发起咨询`,
speech: `${name}进入网站并发起咨询`,
})
}
const connect = () => {
if (disposed) return
const scheme = window.location.protocol === 'https:' ? 'wss:' : 'ws:'
@@ -492,6 +511,13 @@ export function AgentNotificationProvider({ children }: { children: ReactNode })
socket.onmessage = event => {
try {
const payload = JSON.parse(event.data) as { type?: string; session_id?: number | string; data?: IncomingMessage }
if (payload.type === 'session_created_notify') {
const sessionId = Number(payload.session_id)
if (Number.isFinite(sessionId)) {
showNewSessionNotification(String(payload.data?.visitor_name || ''), sessionId)
}
return
}
if (payload.type !== 'message' || payload.data?.sender_type !== 'visitor') return
const sessionId = Number(payload.session_id ?? payload.data.session_id)
const messageId = Number(payload.data.id)
@@ -527,7 +553,7 @@ export function AgentNotificationProvider({ children }: { children: ReactNode })
activeSocket = null
}
}
}, [playSoundInternal, speakIncoming, user?.token])
}, [playSoundInternal, speakWithCurrentPrefs, user?.token])
useEffect(() => () => {
cancelSpeech()
+18 -6
View File
@@ -219,6 +219,24 @@ const VisitorChat = ({
if (!Array.isArray(payload)) {
applySessionStatus(payload.session_status, payload.satisfaction_score)
if (payload.session_status === 'active') {
setAgentsOnline(true)
} else if (payload.session_status === 'waiting' && typeof payload.agents_online === 'boolean') {
setAgentsOnline(payload.agents_online)
}
if (typeof payload.offline_prompt === 'string' && payload.offline_prompt.trim()) {
setOfflinePrompt(payload.offline_prompt)
}
if (Array.isArray(payload.welcome_messages) || typeof payload.welcome_message === 'string') {
setWelcomeSegments(parseWelcomeSegments(payload))
}
if (typeof payload.display_name === 'string' && payload.display_name.trim()) {
setDisplayName(payload.display_name)
}
if (typeof payload.agent_nickname === 'string' && payload.agent_nickname.trim() &&
!agentNameRef.current) {
setAgentName(payload.agent_nickname)
}
}
if (list.length === 0) {
@@ -870,7 +888,6 @@ const VisitorChat = ({
}
const showWelcome = messages.length === 0 && !leaveSent
const showQuick = messages.length <= 1 && !sessionEnded && agentsOnline
const isFill = layout === 'fill'
const shellClass = isFill
? 'relative w-full h-full flex flex-col bg-white overflow-hidden'
@@ -894,13 +911,8 @@ const VisitorChat = ({
agentsOnline={agentsOnline}
welcomeSegments={welcomeSegments}
offlinePrompt={offlinePrompt}
showQuick={showQuick}
messages={messages}
agentTyping={agentTyping}
sessionId={sessionId}
sending={sending}
sessionEnded={sessionEnded}
onQuick={q => sendMessage(q)}
chatEndRef={chatEndRef}
/>
</section>
+2 -28
View File
@@ -3,34 +3,24 @@ import { CustomerServiceOutlined } from '@ant-design/icons'
import { ChatImage } from '@/components/common/ImagePreview'
import MarkdownBody from '@/components/common/MarkdownBody'
import SafeHtml, { looksLikeHtml } from '@/components/common/SafeHtml'
import { defaultQuickQuestions, type Message, type WelcomeSegment } from './types'
import type { Message, WelcomeSegment } from './types'
/** 消息区:欢迎语 / 快捷提问 / 消息气泡 / 输入中提示 */
/** 消息区:欢迎语 / 消息气泡 / 输入中提示 */
export function MessageList({
showWelcome,
agentsOnline,
welcomeSegments,
offlinePrompt,
showQuick,
messages,
agentTyping,
sessionId,
sending,
sessionEnded,
onQuick,
chatEndRef,
}: {
showWelcome: boolean
agentsOnline: boolean
welcomeSegments: WelcomeSegment[]
offlinePrompt: string
showQuick: boolean
messages: Message[]
agentTyping: boolean
sessionId: number | null
sending: boolean
sessionEnded: boolean
onQuick: (question: string) => void
chatEndRef: RefObject<HTMLDivElement | null>
}) {
return (
@@ -75,22 +65,6 @@ export function MessageList({
</div>
)}
{showQuick && (
<div className="flex flex-wrap gap-2 justify-center">
{defaultQuickQuestions.map((q, i) => (
<button
key={i}
type="button"
onClick={() => onQuick(q)}
disabled={!sessionId || sending || sessionEnded}
className="px-3 py-1 rounded-full border border-[#2563eb] bg-white text-[#2563eb] text-xs cursor-pointer whitespace-nowrap hover:bg-[#eff6ff] disabled:opacity-50"
>
{q}
</button>
))}
</div>
)}
{messages.map(msg => (
msg.sender === 'visitor' ? (
<div key={msg.id} className="flex flex-col items-end max-w-[85%] ml-auto gap-1">
-2
View File
@@ -8,8 +8,6 @@ export interface Message {
seq?: number
}
export const defaultQuickQuestions = ['查询订单状态', '退换货政策', '配送时效说明']
export type WelcomeSegment = { type: 'text' | 'image'; content: string }
export const defaultWelcomeSegments: WelcomeSegment[] = [