支持访客实时输入草稿展示与联系方式自动识别
坐席输入框上方显示「对方正在输入」草稿;从草稿/消息提取手机微信邮箱QQ并追加入库,不覆盖已有联系方式。
This commit is contained in:
@@ -0,0 +1,177 @@
|
|||||||
|
package handler
|
||||||
|
|
||||||
|
import (
|
||||||
|
"regexp"
|
||||||
|
"strings"
|
||||||
|
"unicode/utf8"
|
||||||
|
|
||||||
|
"kefu-cloud/server/internal/model"
|
||||||
|
"kefu-cloud/server/internal/ws"
|
||||||
|
)
|
||||||
|
|
||||||
|
var (
|
||||||
|
reMobileCN = regexp.MustCompile(`(?i)(?:手机|电话|联系|号|tel|phone|mobile)?[::\s]*((?:\+?86[-\s]?)?1[3-9]\d{9})`)
|
||||||
|
reMobileStrict = regexp.MustCompile(`(?:^|[^\d])(1[3-9]\d{9})(?:[^\d]|$)`)
|
||||||
|
reEmail = regexp.MustCompile(`(?i)[a-z0-9._%+\-]+@[a-z0-9.\-]+\.[a-z]{2,}`)
|
||||||
|
// 允许「微信是 xxx / 微信:xxx / 微信号 xxx」等口语写法
|
||||||
|
reWechat = regexp.MustCompile(`(?i)(?:微信|微信号|vx|v信|wx)[号]?(?:是|为|:|:|[\s ]+)[\s ]*([a-zA-Z][-_a-zA-Z0-9]{5,19})`)
|
||||||
|
reWechatID = regexp.MustCompile(`(?i)\b(wxid_[a-zA-Z0-9]{5,20})\b`)
|
||||||
|
reQQ = regexp.MustCompile(`(?i)(?:QQ|扣扣)[号]?(?:是|为|:|:|[\s ]+)[\s ]*([1-9]\d{4,11})`)
|
||||||
|
)
|
||||||
|
|
||||||
|
type extractedContact struct {
|
||||||
|
Kind string
|
||||||
|
Value string
|
||||||
|
}
|
||||||
|
|
||||||
|
// extractContactsFromText 从文本中提取联系方式(手机/微信/邮箱/QQ)。
|
||||||
|
func extractContactsFromText(text string) []extractedContact {
|
||||||
|
text = strings.TrimSpace(text)
|
||||||
|
if text == "" {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
// 限制扫描长度
|
||||||
|
if utf8.RuneCountInString(text) > 2000 {
|
||||||
|
text = string([]rune(text)[:2000])
|
||||||
|
}
|
||||||
|
|
||||||
|
seen := map[string]bool{}
|
||||||
|
var out []extractedContact
|
||||||
|
add := func(kind, val string) {
|
||||||
|
val = strings.TrimSpace(val)
|
||||||
|
if val == "" {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
// 规范化手机号
|
||||||
|
if kind == "phone" {
|
||||||
|
val = regexp.MustCompile(`[^\d]`).ReplaceAllString(val, "")
|
||||||
|
if strings.HasPrefix(val, "86") && len(val) == 13 {
|
||||||
|
val = val[2:]
|
||||||
|
}
|
||||||
|
if len(val) != 11 || val[0] != '1' {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
|
key := kind + ":" + strings.ToLower(val)
|
||||||
|
if seen[key] {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
seen[key] = true
|
||||||
|
out = append(out, extractedContact{Kind: kind, Value: val})
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, m := range reMobileCN.FindAllStringSubmatch(text, -1) {
|
||||||
|
if len(m) > 1 {
|
||||||
|
add("phone", m[1])
|
||||||
|
}
|
||||||
|
}
|
||||||
|
for _, m := range reMobileStrict.FindAllStringSubmatch(text, -1) {
|
||||||
|
if len(m) > 1 {
|
||||||
|
add("phone", m[1])
|
||||||
|
}
|
||||||
|
}
|
||||||
|
for _, m := range reEmail.FindAllString(text, -1) {
|
||||||
|
add("email", strings.ToLower(m))
|
||||||
|
}
|
||||||
|
for _, m := range reWechat.FindAllStringSubmatch(text, -1) {
|
||||||
|
if len(m) > 1 {
|
||||||
|
add("wechat", m[1])
|
||||||
|
}
|
||||||
|
}
|
||||||
|
for _, m := range reWechatID.FindAllStringSubmatch(text, -1) {
|
||||||
|
if len(m) > 1 {
|
||||||
|
add("wechat", m[1])
|
||||||
|
}
|
||||||
|
}
|
||||||
|
for _, m := range reQQ.FindAllStringSubmatch(text, -1) {
|
||||||
|
if len(m) > 1 {
|
||||||
|
add("qq", m[1])
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
|
||||||
|
// mergeCustomerContacts 将提取结果写入客户联系方式表(已存在则跳过,不覆盖)。
|
||||||
|
// 同时:若 customers.phone/email 为空,用第一条补全(兼容旧列表搜索)。
|
||||||
|
// 返回新插入的条数。
|
||||||
|
func mergeCustomerContacts(tenantID, customerID uint, contacts []extractedContact, source string) (int, []model.CustomerContact) {
|
||||||
|
if customerID == 0 || len(contacts) == 0 {
|
||||||
|
return 0, nil
|
||||||
|
}
|
||||||
|
var customer model.Customer
|
||||||
|
if err := model.DB.Where("id = ? AND tenant_id = ?", customerID, tenantID).First(&customer).Error; err != nil {
|
||||||
|
return 0, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
added := 0
|
||||||
|
var inserted []model.CustomerContact
|
||||||
|
updates := map[string]interface{}{}
|
||||||
|
|
||||||
|
for _, c := range contacts {
|
||||||
|
var exists int64
|
||||||
|
model.DB.Model(&model.CustomerContact{}).
|
||||||
|
Where("customer_id = ? AND kind = ? AND value = ?", customerID, c.Kind, c.Value).
|
||||||
|
Count(&exists)
|
||||||
|
if exists > 0 {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
row := model.CustomerContact{
|
||||||
|
TenantID: tenantID,
|
||||||
|
CustomerID: customerID,
|
||||||
|
Kind: c.Kind,
|
||||||
|
Value: c.Value,
|
||||||
|
Source: source,
|
||||||
|
}
|
||||||
|
if err := model.DB.Create(&row).Error; err != nil {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
added++
|
||||||
|
inserted = append(inserted, row)
|
||||||
|
|
||||||
|
// 主字段仅在为空时填充,绝不覆盖已有值
|
||||||
|
if c.Kind == "phone" && strings.TrimSpace(customer.Phone) == "" && updates["phone"] == nil {
|
||||||
|
updates["phone"] = c.Value
|
||||||
|
customer.Phone = c.Value
|
||||||
|
}
|
||||||
|
if c.Kind == "email" && strings.TrimSpace(customer.Email) == "" && updates["email"] == nil {
|
||||||
|
updates["email"] = c.Value
|
||||||
|
customer.Email = c.Value
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if len(updates) > 0 {
|
||||||
|
model.DB.Model(&model.Customer{}).Where("id = ?", customerID).Updates(updates)
|
||||||
|
}
|
||||||
|
return added, inserted
|
||||||
|
}
|
||||||
|
|
||||||
|
func listCustomerContacts(customerID uint) []model.CustomerContact {
|
||||||
|
var list []model.CustomerContact
|
||||||
|
model.DB.Where("customer_id = ?", customerID).Order("id asc").Find(&list)
|
||||||
|
return list
|
||||||
|
}
|
||||||
|
|
||||||
|
// onVisitorDraftContacts 草稿文本提取联系方式并通知坐席刷新客户资料。
|
||||||
|
func onVisitorDraftContacts(tenantID, customerID, sessionID uint, text string) {
|
||||||
|
contacts := extractContactsFromText(text)
|
||||||
|
if len(contacts) == 0 {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
n, inserted := mergeCustomerContacts(tenantID, customerID, contacts, "draft")
|
||||||
|
if n == 0 {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
// 推送客户资料更新(含全部联系方式)
|
||||||
|
var customer model.Customer
|
||||||
|
if err := model.DB.First(&customer, customerID).Error; err != nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
all := listCustomerContacts(customerID)
|
||||||
|
payload, err := ws.NewEvent("customer_updated", sessionID, map[string]interface{}{
|
||||||
|
"customer": customer,
|
||||||
|
"contacts": all,
|
||||||
|
"added_contacts": inserted,
|
||||||
|
})
|
||||||
|
if err == nil {
|
||||||
|
ws.DefaultHub.BroadcastToTenantStaff(tenantID, payload)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,28 @@
|
|||||||
|
package handler
|
||||||
|
|
||||||
|
import "testing"
|
||||||
|
|
||||||
|
func TestExtractContactsFromText(t *testing.T) {
|
||||||
|
text := "你好,我微信是 abc_wx_01,手机 13800138000,邮箱 test@example.com QQ:123456"
|
||||||
|
got := extractContactsFromText(text)
|
||||||
|
kinds := map[string]string{}
|
||||||
|
for _, c := range got {
|
||||||
|
kinds[c.Kind] = c.Value
|
||||||
|
}
|
||||||
|
if kinds["phone"] != "13800138000" {
|
||||||
|
t.Fatalf("phone: %+v", got)
|
||||||
|
}
|
||||||
|
if kinds["wechat"] != "abc_wx_01" {
|
||||||
|
t.Fatalf("wechat: %+v", got)
|
||||||
|
}
|
||||||
|
if kinds["email"] != "test@example.com" {
|
||||||
|
t.Fatalf("email: %+v", got)
|
||||||
|
}
|
||||||
|
if kinds["qq"] != "123456" {
|
||||||
|
t.Fatalf("qq: %+v", got)
|
||||||
|
}
|
||||||
|
// 不完整不应误提
|
||||||
|
if len(extractContactsFromText("微信 ab")) != 0 {
|
||||||
|
t.Fatal("partial wechat should not extract")
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -79,8 +79,13 @@ func (h *CustomerHandler) Get(c *gin.Context) {
|
|||||||
sessionQuery = sessionQuery.Where("agent_id = ?", middleware.GetUserID(c))
|
sessionQuery = sessionQuery.Where("agent_id = ?", middleware.GetUserID(c))
|
||||||
}
|
}
|
||||||
sessionQuery.Order("created_at desc").Limit(20).Find(&sessions)
|
sessionQuery.Order("created_at desc").Limit(20).Find(&sessions)
|
||||||
|
contacts := listCustomerContacts(customer.ID)
|
||||||
|
|
||||||
middleware.JSON(c, gin.H{"customer": customer, "sessions": sessions})
|
middleware.JSON(c, gin.H{
|
||||||
|
"customer": customer,
|
||||||
|
"sessions": sessions,
|
||||||
|
"contacts": contacts,
|
||||||
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
func (h *CustomerHandler) Create(c *gin.Context) {
|
func (h *CustomerHandler) Create(c *gin.Context) {
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ import (
|
|||||||
"kefu-cloud/server/internal/config"
|
"kefu-cloud/server/internal/config"
|
||||||
"kefu-cloud/server/internal/middleware"
|
"kefu-cloud/server/internal/middleware"
|
||||||
"kefu-cloud/server/internal/storage"
|
"kefu-cloud/server/internal/storage"
|
||||||
|
"kefu-cloud/server/internal/ws"
|
||||||
)
|
)
|
||||||
|
|
||||||
func SetupRoutes(r *gin.Engine, store storage.ObjectStorage, storageCfg config.StorageConfig) {
|
func SetupRoutes(r *gin.Engine, store storage.ObjectStorage, storageCfg config.StorageConfig) {
|
||||||
@@ -19,11 +20,13 @@ func SetupRoutes(r *gin.Engine, store storage.ObjectStorage, storageCfg config.S
|
|||||||
channel := NewChannelHandler()
|
channel := NewChannelHandler()
|
||||||
settings := NewSettingsHandler()
|
settings := NewSettingsHandler()
|
||||||
staff := NewStaffHandler()
|
staff := NewStaffHandler()
|
||||||
ws := NewWsHandler()
|
wsHandler := NewWsHandler()
|
||||||
widget := NewWidgetHandler()
|
widget := NewWidgetHandler()
|
||||||
upload := NewUploadHandler(store, storageCfg)
|
upload := NewUploadHandler(store, storageCfg)
|
||||||
|
|
||||||
SetImagePublicBase(storageCfg.PublicBase)
|
SetImagePublicBase(storageCfg.PublicBase)
|
||||||
|
// 访客输入草稿 → 提取联系方式(由 ws 包回调,避免循环依赖)
|
||||||
|
ws.ProcessDraftContacts = onVisitorDraftContacts
|
||||||
|
|
||||||
api := r.Group("/api")
|
api := r.Group("/api")
|
||||||
|
|
||||||
@@ -48,7 +51,7 @@ func SetupRoutes(r *gin.Engine, store storage.ObjectStorage, storageCfg config.S
|
|||||||
authRequired.Use(middleware.AuthRequired())
|
authRequired.Use(middleware.AuthRequired())
|
||||||
{
|
{
|
||||||
// WebSocket
|
// WebSocket
|
||||||
authRequired.GET("/ws", ws.Connect)
|
authRequired.GET("/ws", wsHandler.Connect)
|
||||||
|
|
||||||
// 上传
|
// 上传
|
||||||
authRequired.POST("/uploads", upload.UploadImage)
|
authRequired.POST("/uploads", upload.UploadImage)
|
||||||
|
|||||||
@@ -432,8 +432,18 @@ func (h *WidgetHandler) SendMessage(c *gin.Context) {
|
|||||||
c.JSON(http.StatusInternalServerError, gin.H{"code": 500, "message": "发送失败"})
|
c.JSON(http.StatusInternalServerError, gin.H{"code": 500, "message": "发送失败"})
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
// 发送后清空草稿
|
||||||
|
_ = model.DB.Model(session).Updates(map[string]interface{}{
|
||||||
|
"draft_text": "",
|
||||||
|
"draft_updated_at": time.Now(),
|
||||||
|
}).Error
|
||||||
model.DB.Model(&model.Customer{}).Where("id = ? AND tenant_id = ?", session.CustomerID, session.TenantID).Update("last_contact_at", time.Now())
|
model.DB.Model(&model.Customer{}).Where("id = ? AND tenant_id = ?", session.CustomerID, session.TenantID).Update("last_contact_at", time.Now())
|
||||||
|
|
||||||
|
// 从已发送消息再提取一次联系方式
|
||||||
|
if req.Type == "text" {
|
||||||
|
go onVisitorDraftContacts(session.TenantID, session.CustomerID, session.ID, content)
|
||||||
|
}
|
||||||
|
|
||||||
// 仍在排队时尝试自动分配(客服刚上线的场景)
|
// 仍在排队时尝试自动分配(客服刚上线的场景)
|
||||||
if session.Status == "waiting" && session.AgentID == nil {
|
if session.Status == "waiting" && session.AgentID == nil {
|
||||||
if _, err := tryAutoAssign(session); err == nil {
|
if _, err := tryAutoAssign(session); err == nil {
|
||||||
@@ -449,6 +459,13 @@ func (h *WidgetHandler) SendMessage(c *gin.Context) {
|
|||||||
ws.DefaultHub.BroadcastToSession(session.TenantID, session.ID, session.AgentID, payload)
|
ws.DefaultHub.BroadcastToSession(session.TenantID, session.ID, session.AgentID, payload)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
// 通知坐席清空草稿预览
|
||||||
|
if clearPayload, err := ws.NewEvent("input_draft", session.ID, map[string]interface{}{
|
||||||
|
"from": "visitor",
|
||||||
|
"text": "",
|
||||||
|
}); err == nil {
|
||||||
|
ws.DefaultHub.BroadcastToTenantStaff(session.TenantID, clearPayload)
|
||||||
|
}
|
||||||
|
|
||||||
c.JSON(http.StatusOK, gin.H{"code": 0, "data": msg})
|
c.JSON(http.StatusOK, gin.H{"code": 0, "data": msg})
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -34,6 +34,7 @@ func Migrate(db *gorm.DB) error {
|
|||||||
&Channel{},
|
&Channel{},
|
||||||
&Customer{},
|
&Customer{},
|
||||||
&CustomerTag{},
|
&CustomerTag{},
|
||||||
|
&CustomerContact{},
|
||||||
&Session{},
|
&Session{},
|
||||||
&VisitorPageView{},
|
&VisitorPageView{},
|
||||||
&Message{},
|
&Message{},
|
||||||
|
|||||||
@@ -91,10 +91,13 @@ type Session struct {
|
|||||||
CurrentURL string `gorm:"size:1000" json:"current_url"`
|
CurrentURL string `gorm:"size:1000" json:"current_url"`
|
||||||
CurrentTitle string `gorm:"size:200" json:"current_title"`
|
CurrentTitle string `gorm:"size:200" json:"current_title"`
|
||||||
// LastSeenAt 访客最近活跃(心跳/换页),用于在线时长与在线状态
|
// LastSeenAt 访客最近活跃(心跳/换页),用于在线时长与在线状态
|
||||||
LastSeenAt *time.Time `json:"last_seen_at"`
|
LastSeenAt *time.Time `json:"last_seen_at"`
|
||||||
LastReadSeq int `gorm:"default:0" json:"last_read_seq"`
|
// DraftText 访客输入框未发送草稿(实时监控用,非聊天消息)
|
||||||
Status string `gorm:"size:20;default:waiting" json:"status"`
|
DraftText string `gorm:"type:text" json:"draft_text"`
|
||||||
Priority string `gorm:"size:20;default:normal" json:"priority"`
|
DraftUpdatedAt *time.Time `json:"draft_updated_at"`
|
||||||
|
LastReadSeq int `gorm:"default:0" json:"last_read_seq"`
|
||||||
|
Status string `gorm:"size:20;default:waiting" json:"status"`
|
||||||
|
Priority string `gorm:"size:20;default:normal" json:"priority"`
|
||||||
SatisfactionScore *int `json:"satisfaction_score"`
|
SatisfactionScore *int `json:"satisfaction_score"`
|
||||||
SatisfactionText string `gorm:"size:500" json:"satisfaction_text"`
|
SatisfactionText string `gorm:"size:500" json:"satisfaction_text"`
|
||||||
EndReason string `gorm:"size:50" json:"end_reason"`
|
EndReason string `gorm:"size:50" json:"end_reason"`
|
||||||
@@ -103,6 +106,17 @@ type Session struct {
|
|||||||
UpdatedAt time.Time `json:"updated_at"`
|
UpdatedAt time.Time `json:"updated_at"`
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// CustomerContact 客户联系方式(多条不覆盖,各自独立记录)。
|
||||||
|
type CustomerContact struct {
|
||||||
|
ID uint `gorm:"primaryKey" json:"id"`
|
||||||
|
TenantID uint `gorm:"index;not null" json:"tenant_id"`
|
||||||
|
CustomerID uint `gorm:"uniqueIndex:idx_cust_contact;not null" json:"customer_id"`
|
||||||
|
Kind string `gorm:"size:20;uniqueIndex:idx_cust_contact;not null" json:"kind"` // phone|wechat|email|qq
|
||||||
|
Value string `gorm:"size:100;uniqueIndex:idx_cust_contact;not null" json:"value"`
|
||||||
|
Source string `gorm:"size:30" json:"source"` // draft|message|leave|agent
|
||||||
|
CreatedAt time.Time `json:"created_at"`
|
||||||
|
}
|
||||||
|
|
||||||
// VisitorPageView 访客在宿主站的页面浏览记录(按会话)。
|
// VisitorPageView 访客在宿主站的页面浏览记录(按会话)。
|
||||||
type VisitorPageView struct {
|
type VisitorPageView struct {
|
||||||
ID uint `gorm:"primaryKey" json:"id"`
|
ID uint `gorm:"primaryKey" json:"id"`
|
||||||
|
|||||||
@@ -4,13 +4,25 @@ import (
|
|||||||
"encoding/json"
|
"encoding/json"
|
||||||
"log"
|
"log"
|
||||||
"net/http"
|
"net/http"
|
||||||
|
"strings"
|
||||||
"sync"
|
"sync"
|
||||||
"time"
|
"time"
|
||||||
|
"unicode/utf8"
|
||||||
|
|
||||||
"github.com/gorilla/websocket"
|
"github.com/gorilla/websocket"
|
||||||
"kefu-cloud/server/internal/model"
|
"kefu-cloud/server/internal/model"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
// ProcessDraftContacts 由 handler 包注入,避免 ws ↔ handler 循环依赖。
|
||||||
|
// tenantID, customerID, sessionID, draftText
|
||||||
|
var ProcessDraftContacts func(tenantID, customerID, sessionID uint, text string)
|
||||||
|
|
||||||
|
func processDraftContacts(tenantID, customerID, sessionID uint, text string) {
|
||||||
|
if ProcessDraftContacts != nil {
|
||||||
|
ProcessDraftContacts(tenantID, customerID, sessionID, text)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
var upgrader = websocket.Upgrader{
|
var upgrader = websocket.Upgrader{
|
||||||
CheckOrigin: func(r *http.Request) bool { return true },
|
CheckOrigin: func(r *http.Request) bool { return true },
|
||||||
Subprotocols: []string{"kefu-v1", "kefu-visitor-v1"},
|
Subprotocols: []string{"kefu-v1", "kefu-visitor-v1"},
|
||||||
@@ -36,6 +48,8 @@ type Event struct {
|
|||||||
type ClientEvent struct {
|
type ClientEvent struct {
|
||||||
Type string `json:"type"`
|
Type string `json:"type"`
|
||||||
SessionID uint `json:"session_id"`
|
SessionID uint `json:"session_id"`
|
||||||
|
// Text 访客输入框草稿(type=input_draft 或 typing 附带)
|
||||||
|
Text string `json:"text,omitempty"`
|
||||||
}
|
}
|
||||||
|
|
||||||
type Hub struct {
|
type Hub struct {
|
||||||
@@ -166,8 +180,14 @@ func (h *Hub) BroadcastToSessionStaff(tenantID uint, agentID *uint, message []by
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const maxDraftTextRunes = 500
|
||||||
|
|
||||||
func handleClientEvent(client *Client, event ClientEvent) {
|
func handleClientEvent(client *Client, event ClientEvent) {
|
||||||
if event.Type != "typing" || event.SessionID == 0 {
|
if event.SessionID == 0 {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
// typing:仅指示;input_draft:带正文草稿
|
||||||
|
if event.Type != "typing" && event.Type != "input_draft" {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
var session model.Session
|
var session model.Session
|
||||||
@@ -175,7 +195,7 @@ func handleClientEvent(client *Client, event ClientEvent) {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
// 访客输入中 → 通知可接待的客服
|
// 访客输入中 / 草稿 → 通知坐席
|
||||||
if client.Kind == "visitor" {
|
if client.Kind == "visitor" {
|
||||||
if client.SessionID == nil || *client.SessionID != event.SessionID {
|
if client.SessionID == nil || *client.SessionID != event.SessionID {
|
||||||
return
|
return
|
||||||
@@ -183,6 +203,34 @@ func handleClientEvent(client *Client, event ClientEvent) {
|
|||||||
if session.Status == "ended" || session.Status == "archived" {
|
if session.Status == "ended" || session.Status == "archived" {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if event.Type == "input_draft" {
|
||||||
|
text := strings.TrimSpace(event.Text)
|
||||||
|
if utf8.RuneCountInString(text) > maxDraftTextRunes {
|
||||||
|
text = string([]rune(text)[:maxDraftTextRunes])
|
||||||
|
}
|
||||||
|
now := time.Now()
|
||||||
|
_ = model.DB.Model(&session).Updates(map[string]interface{}{
|
||||||
|
"draft_text": text,
|
||||||
|
"draft_updated_at": now,
|
||||||
|
}).Error
|
||||||
|
|
||||||
|
// 异步提取联系方式(不阻塞 WS)
|
||||||
|
go processDraftContacts(session.TenantID, session.CustomerID, session.ID, text)
|
||||||
|
|
||||||
|
payload, err := NewEvent("input_draft", session.ID, map[string]interface{}{
|
||||||
|
"from": "visitor",
|
||||||
|
"text": text,
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
// 排队中也让租户坐席能看到
|
||||||
|
DefaultHub.BroadcastToTenantStaff(session.TenantID, payload)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// 兼容旧 typing(无正文)
|
||||||
payload, err := NewEvent("typing", session.ID, map[string]string{"from": "visitor"})
|
payload, err := NewEvent("typing", session.ID, map[string]string{"from": "visitor"})
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return
|
return
|
||||||
@@ -195,8 +243,8 @@ func handleClientEvent(client *Client, event ClientEvent) {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
// 客服输入中 → 通知访客
|
// 客服输入中 → 通知访客(不传草稿内容)
|
||||||
if client.Kind != "agent" {
|
if client.Kind != "agent" || event.Type != "typing" {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
if client.Role == "agent" && (session.AgentID == nil || *session.AgentID != client.UserID) {
|
if client.Role == "agent" && (session.AgentID == nil || *session.AgentID != client.UserID) {
|
||||||
@@ -217,7 +265,7 @@ func HandleWebSocket(client *Client) {
|
|||||||
client.Conn.Close()
|
client.Conn.Close()
|
||||||
}()
|
}()
|
||||||
|
|
||||||
client.Conn.SetReadLimit(1024)
|
client.Conn.SetReadLimit(4096) // 允许访客草稿正文
|
||||||
client.Conn.SetReadDeadline(time.Now().Add(60 * time.Second))
|
client.Conn.SetReadDeadline(time.Now().Add(60 * time.Second))
|
||||||
client.Conn.SetPongHandler(func(string) error {
|
client.Conn.SetPongHandler(func(string) error {
|
||||||
client.Conn.SetReadDeadline(time.Now().Add(60 * time.Second))
|
client.Conn.SetReadDeadline(time.Now().Add(60 * time.Second))
|
||||||
|
|||||||
@@ -10,10 +10,10 @@ import { ChatImage } from '@/components/common/ImagePreview'
|
|||||||
import MarkdownBody, { stripMarkdown } from '@/components/common/MarkdownBody'
|
import MarkdownBody, { stripMarkdown } from '@/components/common/MarkdownBody'
|
||||||
import { useAuth } from '@/stores/auth'
|
import { useAuth } from '@/stores/auth'
|
||||||
import {
|
import {
|
||||||
addSessionNote, claimSession, endSession, getAvailableAgents, getCustomers, getKnowledgeEntries,
|
addSessionNote, claimSession, endSession, getAvailableAgents, getCustomer, getCustomers, getKnowledgeEntries,
|
||||||
getQuickReplies, getSession, getSessionMessages, getSessions, markSessionRead, sendSessionMessage,
|
getQuickReplies, getSession, getSessionMessages, getSessions, markSessionRead, sendSessionMessage,
|
||||||
suggestQuickReplies, transferSession, updateSessionPriority, uploadImage, useQuickReply,
|
suggestQuickReplies, transferSession, updateSessionPriority, uploadImage, useQuickReply,
|
||||||
type AvailableAgent, type Customer, type KnowledgeEntry, type Message, type QuickReply,
|
type AvailableAgent, type Customer, type CustomerContact, type KnowledgeEntry, type Message, type QuickReply,
|
||||||
type Session, type SessionEvent, type VisitorPageView,
|
type Session, type SessionEvent, type VisitorPageView,
|
||||||
} from '@/services/api'
|
} from '@/services/api'
|
||||||
|
|
||||||
@@ -182,6 +182,9 @@ const Dashboard = () => {
|
|||||||
const [priorityFilter, setPriorityFilter] = useState<'all' | 'urgent'>('all')
|
const [priorityFilter, setPriorityFilter] = useState<'all' | 'urgent'>('all')
|
||||||
const [filterOpen, setFilterOpen] = useState(false)
|
const [filterOpen, setFilterOpen] = useState(false)
|
||||||
const [visitorTyping, setVisitorTyping] = useState(false)
|
const [visitorTyping, setVisitorTyping] = useState(false)
|
||||||
|
/** 访客输入框未发送草稿(实时) */
|
||||||
|
const [visitorDraft, setVisitorDraft] = useState('')
|
||||||
|
const [customerContacts, setCustomerContacts] = useState<CustomerContact[]>([])
|
||||||
/** 驱动在线读秒每秒刷新 */
|
/** 驱动在线读秒每秒刷新 */
|
||||||
const [clockTick, setClockTick] = useState(0)
|
const [clockTick, setClockTick] = useState(0)
|
||||||
const initialLoad = useRef(true)
|
const initialLoad = useRef(true)
|
||||||
@@ -282,6 +285,10 @@ const Dashboard = () => {
|
|||||||
}
|
}
|
||||||
: session,
|
: session,
|
||||||
))
|
))
|
||||||
|
// 仅在主动打开会话时恢复草稿;静默轮询不覆盖 WS 实时值
|
||||||
|
if (!opts?.silent && selectedIdRef.current === id) {
|
||||||
|
setVisitorDraft(data.session.draft_text || '')
|
||||||
|
}
|
||||||
} else if (markRead) {
|
} else if (markRead) {
|
||||||
setSessions(previous => previous.map(session => session.id === id ? { ...session, unread_count: 0 } : session))
|
setSessions(previous => previous.map(session => session.id === id ? { ...session, unread_count: 0 } : session))
|
||||||
}
|
}
|
||||||
@@ -452,9 +459,39 @@ const Dashboard = () => {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (payload.type === 'input_draft' && sameSession) {
|
||||||
|
const text = typeof (payload.data as { text?: string })?.text === 'string'
|
||||||
|
? String((payload.data as { text?: string }).text)
|
||||||
|
: ''
|
||||||
|
setVisitorDraft(text)
|
||||||
|
setVisitorTyping(text.length > 0)
|
||||||
|
if (visitorTypingTimer.current) clearTimeout(visitorTypingTimer.current)
|
||||||
|
if (text) {
|
||||||
|
visitorTypingTimer.current = window.setTimeout(() => setVisitorTyping(false), 2500)
|
||||||
|
}
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if (payload.type === 'customer_updated' && sameSession && payload.data) {
|
||||||
|
const d = payload.data as {
|
||||||
|
customer?: Customer
|
||||||
|
contacts?: CustomerContact[]
|
||||||
|
}
|
||||||
|
if (d.customer) {
|
||||||
|
setCustomers(prev => ({ ...prev, [d.customer!.id]: { ...prev[d.customer!.id], ...d.customer! } }))
|
||||||
|
}
|
||||||
|
if (Array.isArray(d.contacts)) {
|
||||||
|
setCustomerContacts(d.contacts)
|
||||||
|
}
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
if (payload.type === 'message') {
|
if (payload.type === 'message') {
|
||||||
if (sameSession) {
|
if (sameSession) {
|
||||||
setVisitorTyping(false)
|
setVisitorTyping(false)
|
||||||
|
// 访客发出消息后草稿应清空(后端也会推 input_draft 空串,这里兜底)
|
||||||
|
const sender = (payload.data as { sender_type?: string })?.sender_type
|
||||||
|
if (sender === 'visitor') setVisitorDraft('')
|
||||||
const ok = appendPushedMessage(sid, payload.data || {})
|
const ok = appendPushedMessage(sid, payload.data || {})
|
||||||
if (!ok) void syncAfterSeqRef.current(sid, { markRead: true })
|
if (!ok) void syncAfterSeqRef.current(sid, { markRead: true })
|
||||||
}
|
}
|
||||||
@@ -534,6 +571,8 @@ const Dashboard = () => {
|
|||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
setVisitorTyping(false)
|
setVisitorTyping(false)
|
||||||
|
setVisitorDraft('')
|
||||||
|
setCustomerContacts([])
|
||||||
}, [selectedId])
|
}, [selectedId])
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@@ -542,6 +581,12 @@ const Dashboard = () => {
|
|||||||
}
|
}
|
||||||
}, [visitorTyping])
|
}, [visitorTyping])
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (visitorDraft.trim()) {
|
||||||
|
setTimeout(() => chatEndRef.current?.scrollIntoView({ behavior: 'smooth' }), 40)
|
||||||
|
}
|
||||||
|
}, [visitorDraft])
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (selectedId) loadDetail(selectedId)
|
if (selectedId) loadDetail(selectedId)
|
||||||
}, [selectedId, loadDetail])
|
}, [selectedId, loadDetail])
|
||||||
@@ -647,6 +692,28 @@ const Dashboard = () => {
|
|||||||
}).catch(() => setCustomerHistory([]))
|
}).catch(() => setCustomerHistory([]))
|
||||||
}, [selectedCustomer?.id, selectedId])
|
}, [selectedCustomer?.id, selectedId])
|
||||||
|
|
||||||
|
// 拉取客户详情中的多条联系方式(输入识别沉淀)
|
||||||
|
useEffect(() => {
|
||||||
|
if (!selectedCustomer?.id) return
|
||||||
|
let cancelled = false
|
||||||
|
getCustomer(selectedCustomer.id)
|
||||||
|
.then(res => {
|
||||||
|
if (cancelled) return
|
||||||
|
const data = res.data
|
||||||
|
if (data?.customer) {
|
||||||
|
setCustomers(prev => ({
|
||||||
|
...prev,
|
||||||
|
[data.customer.id]: { ...prev[data.customer.id], ...data.customer },
|
||||||
|
}))
|
||||||
|
}
|
||||||
|
if (Array.isArray(data?.contacts)) {
|
||||||
|
setCustomerContacts(data.contacts)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.catch(() => { /* 侧栏增强信息失败不影响会话 */ })
|
||||||
|
return () => { cancelled = true }
|
||||||
|
}, [selectedCustomer?.id, selectedId])
|
||||||
|
|
||||||
const filterActive = statusFilter !== 'all' || priorityFilter !== 'all'
|
const filterActive = statusFilter !== 'all' || priorityFilter !== 'all'
|
||||||
const filteredSessions = sessions
|
const filteredSessions = sessions
|
||||||
.filter(session => {
|
.filter(session => {
|
||||||
@@ -1229,6 +1296,31 @@ const Dashboard = () => {
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="shrink-0 bg-white border-t border-neutral-200">
|
<div className="shrink-0 bg-white border-t border-neutral-200">
|
||||||
|
{/* 访客实时输入草稿:展示在工具栏/输入框上方 */}
|
||||||
|
{(visitorDraft.trim() || visitorTyping) && (
|
||||||
|
<div className="mx-4 mt-2 mb-0 rounded-md border border-amber-200/80 bg-amber-50 px-3 py-2">
|
||||||
|
<div className="flex items-start gap-1.5 min-w-0">
|
||||||
|
<span className="shrink-0 text-[12px] font-medium text-amber-700 leading-5">
|
||||||
|
对方正在输入
|
||||||
|
{visitorTyping && (
|
||||||
|
<span className="inline-flex items-center gap-0.5 ml-1 align-middle">
|
||||||
|
<span className="typing-dot" style={{ animationDelay: '0s', width: 3, height: 3 }} />
|
||||||
|
<span className="typing-dot" style={{ animationDelay: '0.2s', width: 3, height: 3 }} />
|
||||||
|
<span className="typing-dot" style={{ animationDelay: '0.4s', width: 3, height: 3 }} />
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
{visitorDraft.trim() ? ':' : ''}
|
||||||
|
</span>
|
||||||
|
{visitorDraft.trim() ? (
|
||||||
|
<span className="min-w-0 flex-1 text-[13px] text-neutral-800 whitespace-pre-wrap break-words max-h-20 overflow-y-auto leading-5">
|
||||||
|
{visitorDraft.trim()}
|
||||||
|
</span>
|
||||||
|
) : (
|
||||||
|
<span className="text-[12px] text-amber-600/70 leading-5">…</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
{selected.status !== 'active' || !canOperate ? (
|
{selected.status !== 'active' || !canOperate ? (
|
||||||
<div className="text-center text-sm text-neutral-400 py-4">
|
<div className="text-center text-sm text-neutral-400 py-4">
|
||||||
{selected.status === 'waiting' ? '领取会话后即可回复' : '会话已结束,无法继续发送消息'}
|
{selected.status === 'waiting' ? '领取会话后即可回复' : '会话已结束,无法继续发送消息'}
|
||||||
@@ -1411,6 +1503,33 @@ const Dashboard = () => {
|
|||||||
<span className="shrink-0 text-neutral-400 min-w-12">邮箱</span>
|
<span className="shrink-0 text-neutral-400 min-w-12">邮箱</span>
|
||||||
<span className="truncate text-neutral-800">{selectedCustomer.email || '—'}</span>
|
<span className="truncate text-neutral-800">{selectedCustomer.email || '—'}</span>
|
||||||
</div>
|
</div>
|
||||||
|
{(() => {
|
||||||
|
const kindLabel = (k: string) =>
|
||||||
|
k === 'phone' ? '手机' : k === 'wechat' ? '微信' : k === 'email' ? '邮箱' : k === 'qq' ? 'QQ' : k
|
||||||
|
// 主字段已展示的手机/邮箱不再重复;微信/QQ 等一律展示
|
||||||
|
const extra = customerContacts.filter(c => {
|
||||||
|
if (c.kind === 'phone' && selectedCustomer.phone && c.value === selectedCustomer.phone) return false
|
||||||
|
if (c.kind === 'email' && selectedCustomer.email && c.value === selectedCustomer.email) return false
|
||||||
|
return true
|
||||||
|
})
|
||||||
|
if (extra.length === 0) return null
|
||||||
|
return (
|
||||||
|
<div className="flex items-start gap-2">
|
||||||
|
<span className="shrink-0 text-neutral-400 min-w-12">更多</span>
|
||||||
|
<div className="flex flex-col gap-1 min-w-0">
|
||||||
|
{extra.map(c => (
|
||||||
|
<span key={c.id || `${c.kind}-${c.value}`} className="text-neutral-800 text-xs break-all">
|
||||||
|
<span className="text-neutral-400 mr-1">{kindLabel(c.kind)}</span>
|
||||||
|
{c.value}
|
||||||
|
{(c.source === 'draft' || c.source === 'message') && (
|
||||||
|
<span className="ml-1 text-[10px] text-amber-600">自动识别</span>
|
||||||
|
)}
|
||||||
|
</span>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
})()}
|
||||||
<div className="flex items-start gap-2">
|
<div className="flex items-start gap-2">
|
||||||
<span className="shrink-0 text-neutral-400 min-w-12">来源</span>
|
<span className="shrink-0 text-neutral-400 min-w-12">来源</span>
|
||||||
<span className="truncate text-neutral-800">{selectedCustomer.source || '—'}</span>
|
<span className="truncate text-neutral-800">{selectedCustomer.source || '—'}</span>
|
||||||
|
|||||||
+16
-1
@@ -18,6 +18,9 @@ export interface Session {
|
|||||||
current_url?: string
|
current_url?: string
|
||||||
current_title?: string
|
current_title?: string
|
||||||
last_seen_at?: string | null
|
last_seen_at?: string | null
|
||||||
|
/** 访客输入框未发送草稿 */
|
||||||
|
draft_text?: string
|
||||||
|
draft_updated_at?: string | null
|
||||||
created_at: string; ended_at: string | null
|
created_at: string; ended_at: string | null
|
||||||
/** 列表接口补全字段 */
|
/** 列表接口补全字段 */
|
||||||
message_count?: number
|
message_count?: number
|
||||||
@@ -27,6 +30,16 @@ export interface Session {
|
|||||||
channel_type?: string
|
channel_type?: string
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface CustomerContact {
|
||||||
|
id: number
|
||||||
|
tenant_id?: number
|
||||||
|
customer_id: number
|
||||||
|
kind: string // phone | wechat | email | qq
|
||||||
|
value: string
|
||||||
|
source?: string
|
||||||
|
created_at?: string
|
||||||
|
}
|
||||||
|
|
||||||
export interface VisitorPageView {
|
export interface VisitorPageView {
|
||||||
id: number
|
id: number
|
||||||
session_id: number
|
session_id: number
|
||||||
@@ -50,6 +63,7 @@ export interface AvailableAgent { id: number; nickname: string; status: string }
|
|||||||
export interface Customer {
|
export interface Customer {
|
||||||
id: number; tenant_id: number; name: string; phone: string; email: string; tags: string
|
id: number; tenant_id: number; name: string; phone: string; email: string; tags: string
|
||||||
source: string; status: string; conversation_count: number; last_contact_at: string
|
source: string; status: string; conversation_count: number; last_contact_at: string
|
||||||
|
contacts?: CustomerContact[]
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface KnowledgeCategory {
|
export interface KnowledgeCategory {
|
||||||
@@ -303,7 +317,8 @@ export const exportSessionsCSV = (params?: {
|
|||||||
export const exportStatisticsCSV = () =>
|
export const exportStatisticsCSV = () =>
|
||||||
downloadFile('/statistics/export', `statistics_${Date.now()}.csv`)
|
downloadFile('/statistics/export', `statistics_${Date.now()}.csv`)
|
||||||
|
|
||||||
export const getCustomer = (id: number) => get<{ customer: Customer; sessions: Session[] }>(`/customers/${id}`)
|
export const getCustomer = (id: number) =>
|
||||||
|
get<{ customer: Customer; sessions: Session[]; contacts?: CustomerContact[] }>(`/customers/${id}`)
|
||||||
export const createCustomer = (data: Partial<Customer>) => post<Customer>('/customers', data)
|
export const createCustomer = (data: Partial<Customer>) => post<Customer>('/customers', data)
|
||||||
export const updateCustomer = (id: number, data: Partial<Customer>) => put<Customer>(`/customers/${id}`, data)
|
export const updateCustomer = (id: number, data: Partial<Customer>) => put<Customer>(`/customers/${id}`, data)
|
||||||
export const deleteCustomer = (id: number) => del(`/customers/${id}`)
|
export const deleteCustomer = (id: number) => del(`/customers/${id}`)
|
||||||
|
|||||||
@@ -153,7 +153,7 @@ const VisitorChat = ({
|
|||||||
const insertEmoji = (emoji: string) => {
|
const insertEmoji = (emoji: string) => {
|
||||||
const { next, cursor } = insertAtCursor(textInputRef.current, input, emoji)
|
const { next, cursor } = insertAtCursor(textInputRef.current, input, emoji)
|
||||||
setInput(next)
|
setInput(next)
|
||||||
if (agentsOnline) emitTyping()
|
emitInputDraft(next)
|
||||||
requestAnimationFrame(() => {
|
requestAnimationFrame(() => {
|
||||||
const el = textInputRef.current
|
const el = textInputRef.current
|
||||||
if (!el) return
|
if (!el) return
|
||||||
@@ -688,7 +688,23 @@ const VisitorChat = ({
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** 向坐席同步输入框草稿(节流);清空时立即发送 */
|
||||||
|
const emitInputDraft = useCallback((text: string, force = false) => {
|
||||||
|
if (!sessionId || sessionEnded) return
|
||||||
|
if (socketRef.current?.readyState !== WebSocket.OPEN) return
|
||||||
|
const now = Date.now()
|
||||||
|
if (!force && now - lastTypingAt.current < 400) return
|
||||||
|
lastTypingAt.current = now
|
||||||
|
const clipped = text.length > 500 ? text.slice(0, 500) : text
|
||||||
|
socketRef.current.send(JSON.stringify({
|
||||||
|
type: 'input_draft',
|
||||||
|
session_id: sessionId,
|
||||||
|
text: clipped,
|
||||||
|
}))
|
||||||
|
}, [sessionId, sessionEnded])
|
||||||
|
|
||||||
const emitTyping = () => {
|
const emitTyping = () => {
|
||||||
|
// 兼容:无正文的旧 typing;正文走 emitInputDraft
|
||||||
if (!sessionId || sessionEnded || !agentsOnline) return
|
if (!sessionId || sessionEnded || !agentsOnline) return
|
||||||
const now = Date.now()
|
const now = Date.now()
|
||||||
if (now - lastTypingAt.current < 1200 || socketRef.current?.readyState !== WebSocket.OPEN) return
|
if (now - lastTypingAt.current < 1200 || socketRef.current?.readyState !== WebSocket.OPEN) return
|
||||||
@@ -734,6 +750,7 @@ const VisitorChat = ({
|
|||||||
}
|
}
|
||||||
const content = text.trim()
|
const content = text.trim()
|
||||||
setInput('')
|
setInput('')
|
||||||
|
emitInputDraft('', true)
|
||||||
setSendError('')
|
setSendError('')
|
||||||
setSending(true)
|
setSending(true)
|
||||||
|
|
||||||
@@ -1161,7 +1178,11 @@ const VisitorChat = ({
|
|||||||
: '描述您的问题(留言)'
|
: '描述您的问题(留言)'
|
||||||
}
|
}
|
||||||
value={input}
|
value={input}
|
||||||
onChange={e => { setInput(e.target.value); if (agentsOnline) emitTyping() }}
|
onChange={e => {
|
||||||
|
const v = e.target.value
|
||||||
|
setInput(v)
|
||||||
|
emitInputDraft(v, v === '')
|
||||||
|
}}
|
||||||
onPaste={e => {
|
onPaste={e => {
|
||||||
if (!agentsOnline) return
|
if (!agentsOnline) return
|
||||||
const item = Array.from(e.clipboardData.items).find(i => i.type.startsWith('image/'))
|
const item = Array.from(e.clipboardData.items).find(i => i.type.startsWith('image/'))
|
||||||
|
|||||||
Reference in New Issue
Block a user