支持访客实时输入草稿展示与联系方式自动识别

坐席输入框上方显示「对方正在输入」草稿;从草稿/消息提取手机微信邮箱QQ并追加入库,不覆盖已有联系方式。
This commit is contained in:
yml2213
2026-07-19 00:19:24 +08:00
parent fea5f4acf5
commit 6070cf47dc
11 changed files with 465 additions and 17 deletions
+177
View File
@@ -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 QQ123456"
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")
}
}
+6 -1
View File
@@ -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 -2
View File
@@ -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)
+17
View File
@@ -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})
} }
+1
View File
@@ -34,6 +34,7 @@ func Migrate(db *gorm.DB) error {
&Channel{}, &Channel{},
&Customer{}, &Customer{},
&CustomerTag{}, &CustomerTag{},
&CustomerContact{},
&Session{}, &Session{},
&VisitorPageView{}, &VisitorPageView{},
&Message{}, &Message{},
+18 -4
View File
@@ -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"`
+53 -5
View File
@@ -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))
+121 -2
View File
@@ -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
View File
@@ -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}`)
+23 -2
View File
@@ -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/'))