fix(widget): 完善自动回复与新会话进线提醒
This commit is contained in:
@@ -32,10 +32,14 @@
|
||||
|
||||
### 接入效果
|
||||
引入脚本后,页面右下角将自动挂载悬浮客服图标:
|
||||
* **首次自动展开**:每个浏览器标签页首次加载时自动弹出聊天窗口并创建会话;同一标签页后续刷新不重复弹窗。
|
||||
* **点击展开**:弹出响应式聊天窗口(默认 400×600px,移动端浏览器自动适配全屏)。
|
||||
* **自动提醒**:客服回复时,访客端自动显示未读消息红点徽标与声音提示。
|
||||
* **最小化与恢复**:访客可随时收起或重新唤起聊天窗,聊天记录在同一浏览器内自动保留。
|
||||
|
||||
如果希望访客进站时只显示悬浮按钮、不自动展开,可在脚本标签增加
|
||||
`data-auto-open="false"`。
|
||||
|
||||
---
|
||||
|
||||
## 3. 自定义触发按钮(用您网页中的按钮打开客服)
|
||||
|
||||
@@ -56,12 +56,12 @@ func SetupRoutes(r *gin.Engine, store storage.ObjectStorage, storageCfg config.S
|
||||
authRequired := api.Group("")
|
||||
authRequired.Use(middleware.AuthRequired())
|
||||
{
|
||||
// 当前用户
|
||||
authRequired.GET("/me", auth.Me)
|
||||
authRequired.GET("/me/permissions", auth.MePermissions)
|
||||
authRequired.PUT("/me/status", auth.UpdatePresence)
|
||||
// 令牌续期(前端定时调用,防止长挂机后过期掉线)
|
||||
authRequired.POST("/auth/refresh", auth.Refresh)
|
||||
// 当前用户
|
||||
authRequired.GET("/me", auth.Me)
|
||||
authRequired.GET("/me/permissions", auth.MePermissions)
|
||||
authRequired.PUT("/me/status", auth.UpdatePresence)
|
||||
// 令牌续期(前端定时调用,防止长挂机后过期掉线)
|
||||
authRequired.POST("/auth/refresh", auth.Refresh)
|
||||
|
||||
// WebSocket
|
||||
authRequired.GET("/ws", wsHandler.Connect)
|
||||
@@ -143,9 +143,13 @@ func SetupRoutes(r *gin.Engine, store storage.ObjectStorage, storageCfg config.S
|
||||
// 租户系统设置
|
||||
settingsGroup := authRequired.Group("/settings")
|
||||
settingsGroup.GET("", middleware.RequirePermission(
|
||||
"settings.basic", "settings.assign_rule", "settings.auto_reply", "settings.worktime", "settings.notification",
|
||||
"settings.basic", "settings.channel", "settings.staff", "settings.assign_rule",
|
||||
"settings.customer_tag", "settings.auto_reply", "settings.worktime", "settings.notification",
|
||||
), settings.Get)
|
||||
settingsGroup.PUT("", middleware.RequirePermission("settings.basic"), settings.Update)
|
||||
settingsGroup.PUT("", middleware.RequirePermission(
|
||||
"settings.basic", "settings.channel", "settings.staff", "settings.assign_rule",
|
||||
"settings.customer_tag", "settings.auto_reply", "settings.worktime", "settings.notification",
|
||||
), settings.Update)
|
||||
|
||||
// 坐席账号(租户内)
|
||||
staffGroup := authRequired.Group("/staff")
|
||||
|
||||
@@ -810,6 +810,33 @@ func TestSettingsAndExportsHonorFineGrainedPermissions(t *testing.T) {
|
||||
t.Fatalf("缺少分配规则权限仍可修改设置: %d %s", settings.Code, settings.Body.String())
|
||||
}
|
||||
|
||||
autoReplyRoleRec := httptest.NewRecorder()
|
||||
autoReplyRoleBody := []byte(`{"name":"自动回复主管","permissions":["settings.auto_reply"],"data_scopes":{}}`)
|
||||
router.ServeHTTP(autoReplyRoleRec, bearerRequest(t, http.MethodPost, "/api/roles", autoReplyRoleBody, admin))
|
||||
if autoReplyRoleRec.Code != http.StatusOK {
|
||||
t.Fatalf("创建自动回复角色失败: %d %s", autoReplyRoleRec.Code, autoReplyRoleRec.Body.String())
|
||||
}
|
||||
var autoReplyRoleResp struct {
|
||||
Data model.Role `json:"data"`
|
||||
}
|
||||
if err := json.Unmarshal(autoReplyRoleRec.Body.Bytes(), &autoReplyRoleResp); err != nil {
|
||||
t.Fatalf("解析自动回复角色失败: %v", err)
|
||||
}
|
||||
autoReplyUser := createUser(t, tenant.ID, "auto-reply-supervisor", autoReplyRoleResp.Data.Code)
|
||||
|
||||
autoReplySave := httptest.NewRecorder()
|
||||
autoReplyBody := []byte(`{"welcome_messages":[{"type":"text","content":"主管配置的欢迎语"}],"offline_prompt":"主管配置的离线提示"}`)
|
||||
router.ServeHTTP(autoReplySave, bearerRequest(t, http.MethodPut, "/api/settings", autoReplyBody, autoReplyUser))
|
||||
if autoReplySave.Code != http.StatusOK {
|
||||
t.Fatalf("自动回复权限应允许保存: %d %s", autoReplySave.Code, autoReplySave.Body.String())
|
||||
}
|
||||
|
||||
basicSettingsSave := httptest.NewRecorder()
|
||||
router.ServeHTTP(basicSettingsSave, bearerRequest(t, http.MethodPut, "/api/settings", []byte(`{"display_name":"越权修改"}`), autoReplyUser))
|
||||
if basicSettingsSave.Code != http.StatusForbidden {
|
||||
t.Fatalf("自动回复权限不应允许修改基本设置: %d %s", basicSettingsSave.Code, basicSettingsSave.Body.String())
|
||||
}
|
||||
|
||||
customerOne := model.Customer{TenantID: tenant.ID, Name: "客户一"}
|
||||
customerTwo := model.Customer{TenantID: tenant.ID, Name: "客户二"}
|
||||
if err := model.DB.Create(&customerOne).Error; err != nil {
|
||||
@@ -1037,6 +1064,8 @@ func TestTenantSettingsPersistAndAffectWidgetPrompt(t *testing.T) {
|
||||
}
|
||||
var initResp struct {
|
||||
Data struct {
|
||||
SessionID uint `json:"session_id"`
|
||||
VisitorToken string `json:"visitor_token"`
|
||||
WelcomeMessage string `json:"welcome_message"`
|
||||
WelcomeMessages []struct {
|
||||
Type string `json:"type"`
|
||||
@@ -1058,4 +1087,40 @@ func TestTenantSettingsPersistAndAffectWidgetPrompt(t *testing.T) {
|
||||
if len(initResp.Data.WelcomeMessages) != 2 {
|
||||
t.Fatalf("widget 未返回多段欢迎语: %+v", initResp.Data.WelcomeMessages)
|
||||
}
|
||||
|
||||
// 已存在的会话重新拉消息时,应同步最新的自动回复配置。
|
||||
updateRec := httptest.NewRecorder()
|
||||
updateBody := `{"welcome_messages":[{"type":"text","content":"刷新后的欢迎语"}],"offline_prompt":"刷新后的离线提示"}`
|
||||
router.ServeHTTP(updateRec, bearerRequest(t, http.MethodPut, "/api/settings", []byte(updateBody), admin))
|
||||
if updateRec.Code != http.StatusOK {
|
||||
t.Fatalf("更新自动回复失败: %s", updateRec.Body.String())
|
||||
}
|
||||
|
||||
messageReq := httptest.NewRequest(http.MethodGet, fmt.Sprintf("/api/widget/messages?session_id=%d", initResp.Data.SessionID), nil)
|
||||
messageReq.Header.Set("X-Visitor-Token", initResp.Data.VisitorToken)
|
||||
messageRec := httptest.NewRecorder()
|
||||
router.ServeHTTP(messageRec, messageReq)
|
||||
if messageRec.Code != http.StatusOK {
|
||||
t.Fatalf("读取已有会话消息失败: %s", messageRec.Body.String())
|
||||
}
|
||||
var messageResp struct {
|
||||
Data struct {
|
||||
WelcomeMessage string `json:"welcome_message"`
|
||||
WelcomeMessages []struct {
|
||||
Type string `json:"type"`
|
||||
Content string `json:"content"`
|
||||
} `json:"welcome_messages"`
|
||||
OfflinePrompt string `json:"offline_prompt"`
|
||||
AgentsOnline bool `json:"agents_online"`
|
||||
} `json:"data"`
|
||||
}
|
||||
if err := json.Unmarshal(messageRec.Body.Bytes(), &messageResp); err != nil {
|
||||
t.Fatalf("解析已有会话消息响应失败: %v", err)
|
||||
}
|
||||
if messageResp.Data.WelcomeMessage != "刷新后的欢迎语" ||
|
||||
len(messageResp.Data.WelcomeMessages) != 1 ||
|
||||
messageResp.Data.OfflinePrompt != "刷新后的离线提示" ||
|
||||
messageResp.Data.AgentsOnline {
|
||||
t.Fatalf("已有会话未同步最新自动回复: %+v", messageResp.Data)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -27,10 +27,6 @@ func (h *SettingsHandler) Get(c *gin.Context) {
|
||||
}
|
||||
|
||||
func (h *SettingsHandler) Update(c *gin.Context) {
|
||||
if !middleware.HasPermission(c, "settings.basic") {
|
||||
c.JSON(http.StatusForbidden, gin.H{"code": 403, "message": "仅租户管理员可修改设置"})
|
||||
return
|
||||
}
|
||||
tenantID := middleware.GetTenantID(c)
|
||||
if tenantID == 0 {
|
||||
c.JSON(http.StatusForbidden, gin.H{"code": 403, "message": "仅租户账号可修改设置"})
|
||||
@@ -47,6 +43,10 @@ func (h *SettingsHandler) Update(c *gin.Context) {
|
||||
return
|
||||
}
|
||||
// 设置权限按字段分区校验,避免仅拥有基本设置权限即可修改分配、工作时间或通知策略。
|
||||
if req.TouchesBasic() && !middleware.HasPermission(c, "settings.basic") {
|
||||
c.JSON(http.StatusForbidden, gin.H{"code": 403, "message": "无权修改基本设置"})
|
||||
return
|
||||
}
|
||||
if req.TouchesAssignRule() && !middleware.HasPermission(c, "settings.assign_rule") {
|
||||
c.JSON(http.StatusForbidden, gin.H{"code": 403, "message": "无权修改客服分配规则"})
|
||||
return
|
||||
|
||||
@@ -51,6 +51,15 @@ type WelcomeSegment struct {
|
||||
Content string `json:"content"`
|
||||
}
|
||||
|
||||
// WidgetReplyConfig 是访客端展示自动回复所需的租户配置。
|
||||
type WidgetReplyConfig struct {
|
||||
WelcomeMessage string
|
||||
WelcomeMessages []WelcomeSegment
|
||||
OfflinePrompt string
|
||||
AgentNickname string
|
||||
DisplayName string
|
||||
}
|
||||
|
||||
var weekdayKeys = []string{"monday", "tuesday", "wednesday", "thursday", "friday", "saturday", "sunday"}
|
||||
|
||||
func defaultWelcomeSegments() []WelcomeSegment {
|
||||
@@ -112,6 +121,37 @@ func ResolveWelcomeSegments(setting *model.TenantSetting) []WelcomeSegment {
|
||||
return defaultWelcomeSegments()
|
||||
}
|
||||
|
||||
// ResolveWidgetReplyConfig 将租户设置转换为访客端可直接使用的自动回复配置。
|
||||
func ResolveWidgetReplyConfig(setting *model.TenantSetting, withinWorkHours bool) WidgetReplyConfig {
|
||||
config := WidgetReplyConfig{
|
||||
WelcomeMessage: DefaultWelcomeMessage,
|
||||
WelcomeMessages: defaultWelcomeSegments(),
|
||||
OfflinePrompt: DefaultOfflinePrompt,
|
||||
AgentNickname: DefaultAgentNickname,
|
||||
DisplayName: "在线客服",
|
||||
}
|
||||
if setting != nil {
|
||||
config.WelcomeMessages = ResolveWelcomeSegments(setting)
|
||||
config.WelcomeMessage = FirstWelcomeText(config.WelcomeMessages)
|
||||
if setting.OfflinePrompt != "" {
|
||||
config.OfflinePrompt = setting.OfflinePrompt
|
||||
}
|
||||
if setting.AgentNickname != "" {
|
||||
config.AgentNickname = setting.AgentNickname
|
||||
}
|
||||
if setting.DisplayName != "" {
|
||||
config.DisplayName = setting.DisplayName
|
||||
}
|
||||
}
|
||||
if !withinWorkHours {
|
||||
config.OfflinePrompt = DefaultWorktimePrompt
|
||||
if setting != nil && setting.WorktimePrompt != "" {
|
||||
config.OfflinePrompt = setting.WorktimePrompt
|
||||
}
|
||||
}
|
||||
return config
|
||||
}
|
||||
|
||||
// NormalizeWelcomeSegments 校验并规范化欢迎语段落;返回规范化列表与兼容用首段文本。
|
||||
func NormalizeWelcomeSegments(input []WelcomeSegment) ([]WelcomeSegment, string, error) {
|
||||
if len(input) == 0 {
|
||||
@@ -343,6 +383,10 @@ type SettingsUpdateReq struct {
|
||||
|
||||
// TouchesAssignRule / TouchesAutoReply / TouchesWorktime / TouchesNotification
|
||||
// 供 handler 按字段分区校验权限。
|
||||
func (r *SettingsUpdateReq) TouchesBasic() bool {
|
||||
return r.DisplayName != nil || r.AgentNickname != nil || r.Timezone != nil
|
||||
}
|
||||
|
||||
func (r *SettingsUpdateReq) TouchesAssignRule() bool {
|
||||
return r.AssignStrategy != nil || r.MaxActivePerAgent != nil
|
||||
}
|
||||
|
||||
@@ -152,31 +152,14 @@ func WidgetInit(in WidgetInitInput) (map[string]interface{}, error) {
|
||||
if payload, err := ws.NewEvent("session_created", session.ID, session); err == nil {
|
||||
ws.DefaultHub.BroadcastToTenantStaff(session.TenantID, payload)
|
||||
}
|
||||
if setting == nil || setting.NotifyNewSession {
|
||||
notice := map[string]string{"visitor_name": customer.Name}
|
||||
if payload, err := ws.NewEvent("session_created_notify", session.ID, notice); err == nil {
|
||||
ws.DefaultHub.BroadcastToTenantStaff(session.TenantID, payload)
|
||||
}
|
||||
}
|
||||
|
||||
welcomeSegs := ResolveWelcomeSegments(setting)
|
||||
welcome := FirstWelcomeText(welcomeSegs)
|
||||
offlinePrompt := DefaultOfflinePrompt
|
||||
agentNickname := DefaultAgentNickname
|
||||
displayName := "在线客服"
|
||||
worktimePrompt := DefaultWorktimePrompt
|
||||
if setting != nil {
|
||||
if setting.OfflinePrompt != "" {
|
||||
offlinePrompt = setting.OfflinePrompt
|
||||
}
|
||||
if setting.AgentNickname != "" {
|
||||
agentNickname = setting.AgentNickname
|
||||
}
|
||||
if setting.DisplayName != "" {
|
||||
displayName = setting.DisplayName
|
||||
}
|
||||
if setting.WorktimePrompt != "" {
|
||||
worktimePrompt = setting.WorktimePrompt
|
||||
}
|
||||
}
|
||||
// 非工作时间优先展示非工作时间提示
|
||||
if !withinWorkHours {
|
||||
offlinePrompt = worktimePrompt
|
||||
}
|
||||
replyConfig := ResolveWidgetReplyConfig(setting, withinWorkHours)
|
||||
|
||||
resp := map[string]interface{}{
|
||||
"session_id": session.ID,
|
||||
@@ -188,19 +171,19 @@ func WidgetInit(in WidgetInitInput) (map[string]interface{}, error) {
|
||||
"online_agent_count": onlineCount,
|
||||
"within_work_hours": withinWorkHours,
|
||||
"session_status": session.Status,
|
||||
"welcome_message": welcome,
|
||||
"welcome_messages": welcomeSegs,
|
||||
"display_name": displayName,
|
||||
"agent_nickname": agentNickname,
|
||||
"welcome_message": replyConfig.WelcomeMessage,
|
||||
"welcome_messages": replyConfig.WelcomeMessages,
|
||||
"display_name": replyConfig.DisplayName,
|
||||
"agent_nickname": replyConfig.AgentNickname,
|
||||
}
|
||||
if !canServeNow {
|
||||
resp["offline_prompt"] = offlinePrompt
|
||||
resp["offline_prompt"] = replyConfig.OfflinePrompt
|
||||
}
|
||||
if assignedAgent != nil {
|
||||
resp["agent_id"] = assignedAgent.ID
|
||||
resp["agent_name"] = assignedAgent.Nickname
|
||||
} else if canServeNow {
|
||||
resp["agent_name"] = agentNickname
|
||||
resp["agent_name"] = replyConfig.AgentNickname
|
||||
}
|
||||
resp["landing_url"] = session.LandingURL
|
||||
resp["current_url"] = session.CurrentURL
|
||||
@@ -501,12 +484,27 @@ func WidgetMessages(session *model.Session, afterSeq, limit int) (map[string]int
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return map[string]interface{}{
|
||||
setting, _ := GetOrCreateTenantSettings(session.TenantID)
|
||||
withinWorkHours := IsWithinWorkHours(setting, time.Now())
|
||||
onlineCount, _ := CountOnlineAgents(session.TenantID)
|
||||
canServeNow := session.Status == "active" || (onlineCount > 0 && withinWorkHours)
|
||||
replyConfig := ResolveWidgetReplyConfig(setting, withinWorkHours)
|
||||
|
||||
result := map[string]interface{}{
|
||||
"messages": page.Messages,
|
||||
"after_seq": page.AfterSeq,
|
||||
"max_seq": page.MaxSeq,
|
||||
"has_more": page.HasMore,
|
||||
"session_status": session.Status,
|
||||
"satisfaction_score": session.SatisfactionScore,
|
||||
}, nil
|
||||
"welcome_message": replyConfig.WelcomeMessage,
|
||||
"welcome_messages": replyConfig.WelcomeMessages,
|
||||
"display_name": replyConfig.DisplayName,
|
||||
"agent_nickname": replyConfig.AgentNickname,
|
||||
"agents_online": canServeNow,
|
||||
}
|
||||
if !canServeNow {
|
||||
result["offline_prompt"] = replyConfig.OfflinePrompt
|
||||
}
|
||||
return result, nil
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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,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 /> },
|
||||
],
|
||||
},
|
||||
]
|
||||
|
||||
@@ -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> },
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -8,8 +8,6 @@ export interface Message {
|
||||
seq?: number
|
||||
}
|
||||
|
||||
export const defaultQuickQuestions = ['查询订单状态', '退换货政策', '配送时效说明']
|
||||
|
||||
export type WelcomeSegment = { type: 'text' | 'image'; content: string }
|
||||
|
||||
export const defaultWelcomeSegments: WelcomeSegment[] = [
|
||||
|
||||
Reference in New Issue
Block a user