From 1f92c35963287c95d1f0ffdb4a710101779c57d7 Mon Sep 17 00:00:00 2001 From: yml2213 Date: Sun, 19 Jul 2026 00:43:55 +0800 Subject: [PATCH] =?UTF-8?q?=E6=96=B0=E5=A2=9E=E8=AE=BF=E5=AE=A2=E9=BB=91?= =?UTF-8?q?=E5=90=8D=E5=8D=95=EF=BC=9A=E6=94=AF=E6=8C=81=E6=8B=89=E9=BB=91?= =?UTF-8?q?=20IP/=E8=AE=BE=E5=A4=87=E5=B9=B6=E7=AE=A1=E7=90=86=E5=88=97?= =?UTF-8?q?=E8=A1=A8?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 工作台可按 IP 或设备拉黑并填写释放时间与原因;侧栏黑名单页查看与解除;进线自动拦截。 --- server/internal/handler/blacklist.go | 348 +++++++++++++++++++++ server/internal/handler/router.go | 7 + server/internal/handler/widget.go | 17 + server/internal/model/db.go | 1 + server/internal/model/models.go | 18 ++ web/src/components/layout/AgentSidebar.tsx | 3 +- web/src/pages/agent/Blacklist.tsx | 235 ++++++++++++++ web/src/pages/agent/Dashboard.tsx | 158 +++++++++- web/src/router/index.tsx | 2 + web/src/services/api.ts | 37 +++ web/src/widgets/VisitorChat.tsx | 17 + 11 files changed, 838 insertions(+), 5 deletions(-) create mode 100644 server/internal/handler/blacklist.go create mode 100644 web/src/pages/agent/Blacklist.tsx diff --git a/server/internal/handler/blacklist.go b/server/internal/handler/blacklist.go new file mode 100644 index 0000000..edbc07e --- /dev/null +++ b/server/internal/handler/blacklist.go @@ -0,0 +1,348 @@ +package handler + +import ( + "crypto/sha256" + "encoding/hex" + "fmt" + "net/http" + "strings" + "time" + "unicode/utf8" + + "github.com/gin-gonic/gin" + "kefu-cloud/server/internal/middleware" + "kefu-cloud/server/internal/model" + "kefu-cloud/server/internal/ws" +) + +type BlacklistHandler struct{} + +func NewBlacklistHandler() *BlacklistHandler { return &BlacklistHandler{} } + +// normalizeDeviceKey 规范化设备指纹:优先客户端 device_id;否则用 UA 摘要哈希兜底。 +func normalizeDeviceKey(deviceID, userAgent string) string { + id := strings.TrimSpace(deviceID) + if id != "" { + if utf8.RuneCountInString(id) > 64 { + id = string([]rune(id)[:64]) + } + return id + } + ua := strings.TrimSpace(userAgent) + if ua == "" { + return "" + } + sum := sha256.Sum256([]byte(strings.ToLower(ua))) + return hex.EncodeToString(sum[:])[:32] +} + +// isBlacklisted 检查租户下 IP/设备是否在有效黑名单中。 +func isBlacklisted(tenantID uint, kind, value string) (bool, *model.BlacklistEntry) { + value = strings.TrimSpace(value) + if value == "" || (kind != "ip" && kind != "device") { + return false, nil + } + now := time.Now() + var entry model.BlacklistEntry + err := model.DB. + Where("tenant_id = ? AND kind = ? AND value = ?", tenantID, kind, value). + Where("expires_at IS NULL OR expires_at > ?", now). + Order("id desc"). + First(&entry).Error + if err != nil { + return false, nil + } + return true, &entry +} + +// checkVisitorBlacklist 同时检查 IP 与设备。 +func checkVisitorBlacklist(tenantID uint, ip, deviceKey string) (blocked bool, kind string, reason string) { + if ok, e := isBlacklisted(tenantID, "ip", ip); ok { + return true, "ip", e.Reason + } + if ok, e := isBlacklisted(tenantID, "device", deviceKey); ok { + return true, "device", e.Reason + } + return false, "", "" +} + +type CreateBlacklistReq struct { + // SessionID 从会话一键拉黑时必填(自动带出 IP/设备) + SessionID *uint `json:"session_id"` + // Kind ip | device + Kind string `json:"kind" binding:"required"` + // Value 手动指定时使用;有 session_id 时可省略 + Value string `json:"value"` + // Duration 释放时长:1h | 1d | 7d | 30d | permanent + Duration string `json:"duration" binding:"required"` + Reason string `json:"reason" binding:"required"` + // EndSession 拉黑后是否结束当前会话 + EndSession bool `json:"end_session"` +} + +func parseBlacklistDuration(duration string) (*time.Time, error) { + duration = strings.TrimSpace(strings.ToLower(duration)) + if duration == "permanent" || duration == "forever" || duration == "长期" { + return nil, nil + } + now := time.Now() + var exp time.Time + switch duration { + case "1h", "1hour": + exp = now.Add(time.Hour) + case "6h": + exp = now.Add(6 * time.Hour) + case "1d", "1day", "24h": + exp = now.Add(24 * time.Hour) + case "7d", "7day": + exp = now.Add(7 * 24 * time.Hour) + case "30d", "30day": + exp = now.Add(30 * 24 * time.Hour) + default: + return nil, errInvalidDuration + } + return &exp, nil +} + +var errInvalidDuration = errStr("释放时间无效") + +type errStr string + +func (e errStr) Error() string { return string(e) } + +func durationLabel(duration string) string { + switch strings.TrimSpace(strings.ToLower(duration)) { + case "1h", "1hour": + return "1 小时" + case "6h": + return "6 小时" + case "1d", "1day", "24h": + return "1 天" + case "7d", "7day": + return "7 天" + case "30d", "30day": + return "30 天" + case "permanent", "forever", "长期": + return "长期" + default: + return duration + } +} + +// Create 创建黑名单(支持从会话拉黑 IP 或设备)。 +func (h *BlacklistHandler) Create(c *gin.Context) { + tenantID := middleware.GetTenantID(c) + var req CreateBlacklistReq + if err := c.ShouldBindJSON(&req); err != nil { + c.JSON(http.StatusBadRequest, gin.H{"code": 400, "message": "参数错误"}) + return + } + kind := strings.TrimSpace(req.Kind) + if kind != "ip" && kind != "device" { + c.JSON(http.StatusBadRequest, gin.H{"code": 400, "message": "类型仅支持 ip 或 device"}) + return + } + reason := strings.TrimSpace(req.Reason) + if reason == "" || utf8.RuneCountInString(reason) > 200 { + c.JSON(http.StatusBadRequest, gin.H{"code": 400, "message": "请填写拉黑原因(1-200 字)"}) + return + } + expiresAt, err := parseBlacklistDuration(req.Duration) + if err != nil { + c.JSON(http.StatusBadRequest, gin.H{"code": 400, "message": "释放时间无效,可选 1h/6h/1d/7d/30d/permanent"}) + return + } + + var session *model.Session + value := strings.TrimSpace(req.Value) + var sessionID *uint + var customerID *uint + + if req.SessionID != nil && *req.SessionID > 0 { + s, ok := loadTenantSession(c, fmt.Sprintf("%d", *req.SessionID)) + if !ok { + return + } + // 管理员/主管,或当前接待坐席可拉黑 + if !isTenantManager(c) { + if s.AgentID == nil || *s.AgentID != middleware.GetUserID(c) { + c.JSON(http.StatusForbidden, gin.H{"code": 403, "message": "仅接待坐席或管理员可拉黑该访客"}) + return + } + } + session = s + sid := s.ID + sessionID = &sid + cid := s.CustomerID + customerID = &cid + if kind == "ip" { + value = strings.TrimSpace(s.VisitorIP) + if value == "" { + c.JSON(http.StatusBadRequest, gin.H{"code": 400, "message": "该会话无有效 IP,无法拉黑"}) + return + } + } else { + value = strings.TrimSpace(s.DeviceKey) + if value == "" { + // 旧会话兜底:用 UA 哈希 + value = normalizeDeviceKey("", s.UserAgent) + } + if value == "" { + c.JSON(http.StatusBadRequest, gin.H{"code": 400, "message": "该会话无设备标识,无法按设备拉黑"}) + return + } + } + } + + if value == "" { + c.JSON(http.StatusBadRequest, gin.H{"code": 400, "message": "缺少拉黑目标"}) + return + } + if utf8.RuneCountInString(value) > 200 { + c.JSON(http.StatusBadRequest, gin.H{"code": 400, "message": "目标值过长"}) + return + } + + // 已有有效记录则更新原因/到期时间(幂等);否则新建 + now := time.Now() + operatorID := middleware.GetUserID(c) + var existing model.BlacklistEntry + found := model.DB. + Where("tenant_id = ? AND kind = ? AND value = ?", tenantID, kind, value). + Where("expires_at IS NULL OR expires_at > ?", now). + Order("id desc"). + First(&existing).Error == nil + + if found { + // map 更新可写 nil expires_at(长期) + if err := model.DB.Model(&existing).Updates(map[string]interface{}{ + "reason": reason, + "expires_at": expiresAt, + "operator_id": operatorID, + "session_id": sessionID, + "customer_id": customerID, + }).Error; err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"code": 500, "message": "更新黑名单失败"}) + return + } + // GORM Updates 对 nil pointer 有时跳过;长期时强制写 NULL + if expiresAt == nil { + _ = model.DB.Model(&existing).Update("expires_at", nil).Error + } + model.DB.First(&existing, existing.ID) + } else { + existing = model.BlacklistEntry{ + TenantID: tenantID, + Kind: kind, + Value: value, + Reason: reason, + ExpiresAt: expiresAt, + OperatorID: operatorID, + SessionID: sessionID, + CustomerID: customerID, + } + if err := model.DB.Create(&existing).Error; err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"code": 500, "message": "加入黑名单失败"}) + return + } + } + + // 会话事件 + 可选结束会话 + if session != nil { + kindLabel := "IP" + if kind == "device" { + kindLabel = "设备" + } + detail := userDisplayName(operatorID) + " 拉黑" + kindLabel + " " + maskBlacklistValue(kind, value) + + "(" + durationLabel(req.Duration) + "):" + reason + model.DB.Create(&model.SessionEvent{ + SessionID: session.ID, + OperatorID: operatorID, + Action: "blacklist", + Detail: detail, + }) + + if req.EndSession && session.Status != "ended" && session.Status != "archived" { + endNow := time.Now() + _ = model.DB.Model(session).Updates(map[string]interface{}{ + "status": "ended", + "end_reason": "other", + "ended_at": endNow, + }).Error + session.Status = "ended" + session.EndedAt = &endNow + model.DB.Create(&model.SessionEvent{ + SessionID: session.ID, + OperatorID: operatorID, + Action: "end", + Detail: "结束会话: 拉黑访客", + }) + broadcastSessionUpdate(session) + if payload, err := ws.NewEvent("session_updated", session.ID, session); err == nil { + ws.DefaultHub.BroadcastToSession(session.TenantID, session.ID, session.AgentID, payload) + } + } else { + broadcastSessionUpdate(session) + } + } + + middleware.JSON(c, existing) +} + +func maskBlacklistValue(kind, value string) string { + if kind == "ip" { + parts := strings.Split(value, ".") + if len(parts) == 4 { + return parts[0] + "." + parts[1] + ".***." + parts[3] + } + return value + } + if len(value) <= 8 { + return value + } + return value[:4] + "…" + value[len(value)-4:] +} + +// List 黑名单列表(有效 + 可选含已过期)。 +func (h *BlacklistHandler) List(c *gin.Context) { + tenantID := middleware.GetTenantID(c) + q := model.DB.Where("tenant_id = ?", tenantID) + if c.Query("active") != "0" { + q = q.Where("expires_at IS NULL OR expires_at > ?", time.Now()) + } + if kind := strings.TrimSpace(c.Query("kind")); kind == "ip" || kind == "device" { + q = q.Where("kind = ?", kind) + } + var list []model.BlacklistEntry + if err := q.Order("id desc").Limit(200).Find(&list).Error; err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"code": 500, "message": "查询失败"}) + return + } + middleware.JSON(c, list) +} + +// Delete 解除黑名单。 +func (h *BlacklistHandler) Delete(c *gin.Context) { + tenantID := middleware.GetTenantID(c) + id := c.Param("id") + var entry model.BlacklistEntry + if err := model.DB.Where("id = ? AND tenant_id = ?", id, tenantID).First(&entry).Error; err != nil { + c.JSON(http.StatusNotFound, gin.H{"code": 404, "message": "记录不存在"}) + return + } + // 通过将到期时间设为过去来“释放”,保留审计;若 force=1 则物理删除 + if c.Query("force") == "1" { + if err := model.DB.Delete(&entry).Error; err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"code": 500, "message": "删除失败"}) + return + } + } else { + past := time.Now().Add(-time.Second) + if err := model.DB.Model(&entry).Update("expires_at", past).Error; err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"code": 500, "message": "解除失败"}) + return + } + entry.ExpiresAt = &past + } + middleware.JSON(c, gin.H{"message": "已解除", "id": entry.ID}) +} diff --git a/server/internal/handler/router.go b/server/internal/handler/router.go index 94c8c2d..24da92a 100644 --- a/server/internal/handler/router.go +++ b/server/internal/handler/router.go @@ -20,6 +20,7 @@ func SetupRoutes(r *gin.Engine, store storage.ObjectStorage, storageCfg config.S channel := NewChannelHandler() settings := NewSettingsHandler() staff := NewStaffHandler() + blacklist := NewBlacklistHandler() wsHandler := NewWsHandler() widget := NewWidgetHandler() upload := NewUploadHandler(store, storageCfg) @@ -87,6 +88,12 @@ func SetupRoutes(r *gin.Engine, store storage.ObjectStorage, storageCfg config.S customers.PUT("/:id", customer.Update) customers.DELETE("/:id", customer.Delete) + // 黑名单(拉黑 IP / 设备) + bl := authRequired.Group("/blacklist") + bl.GET("", blacklist.List) + bl.POST("", blacklist.Create) + bl.DELETE("/:id", blacklist.Delete) + // 客户标签库(管理员维护,全员可读可选) ctags := authRequired.Group("/customer-tags") ctags.GET("", customerTag.List) diff --git a/server/internal/handler/widget.go b/server/internal/handler/widget.go index 96377bb..607b7e5 100644 --- a/server/internal/handler/widget.go +++ b/server/internal/handler/widget.go @@ -22,6 +22,8 @@ func NewWidgetHandler() *WidgetHandler { return &WidgetHandler{} } type WidgetInitReq struct { ChannelKey string `json:"channel_key" form:"channel_key"` VisitorName string `json:"visitor_name"` + // DeviceID 访客端持久设备指纹(localStorage),用于设备级拉黑 + DeviceID string `json:"device_id" form:"device_id"` // 宿主页信息(由 widget.js / 前端上报) PageURL string `json:"page_url" form:"page_url"` PageTitle string `json:"page_title" form:"page_title"` @@ -120,6 +122,20 @@ func (h *WidgetHandler) Init(c *gin.Context) { } visitorIP, visitorRegion, userAgent, _ := captureVisitorMeta(c) + deviceKey := normalizeDeviceKey(req.DeviceID, userAgent) + // 黑名单拦截(IP / 设备) + if blocked, blKind, blReason := checkVisitorBlacklist(channel.TenantID, visitorIP, deviceKey); blocked { + kindLabel := "IP" + if blKind == "device" { + kindLabel = "设备" + } + msg := "您暂时无法使用在线客服" + if strings.TrimSpace(blReason) != "" { + msg = msg + "(" + kindLabel + "限制)" + } + c.JSON(http.StatusForbidden, gin.H{"code": 403, "message": msg}) + return + } pageURL := sanitizePageURL(req.PageURL) if pageURL == "" { // query 兜底 @@ -144,6 +160,7 @@ func (h *WidgetHandler) Init(c *gin.Context) { VisitorIP: visitorIP, VisitorRegion: visitorRegion, UserAgent: userAgent, + DeviceKey: deviceKey, LandingURL: pageURL, LandingTitle: pageTitle, Referrer: referrer, diff --git a/server/internal/model/db.go b/server/internal/model/db.go index e46ca16..1460338 100644 --- a/server/internal/model/db.go +++ b/server/internal/model/db.go @@ -35,6 +35,7 @@ func Migrate(db *gorm.DB) error { &Customer{}, &CustomerTag{}, &CustomerContact{}, + &BlacklistEntry{}, &Session{}, &VisitorPageView{}, &Message{}, diff --git a/server/internal/model/models.go b/server/internal/model/models.go index f8798a2..3872cc7 100644 --- a/server/internal/model/models.go +++ b/server/internal/model/models.go @@ -76,6 +76,22 @@ type CustomerTag struct { UpdatedAt time.Time `json:"updated_at"` } +// BlacklistEntry 租户级访客黑名单(按 IP 或设备指纹拦截)。 +// ExpiresAt 为空表示长期有效;允许多条历史记录,拦截时取未过期的最新一条。 +type BlacklistEntry struct { + ID uint `gorm:"primaryKey" json:"id"` + TenantID uint `gorm:"index:idx_bl_lookup;not null" json:"tenant_id"` + Kind string `gorm:"size:20;index:idx_bl_lookup;not null" json:"kind"` // ip | device + Value string `gorm:"size:200;index:idx_bl_lookup;not null" json:"value"` + Reason string `gorm:"size:500" json:"reason"` + ExpiresAt *time.Time `json:"expires_at"` // nil = 长期 + OperatorID uint `gorm:"index" json:"operator_id"` + SessionID *uint `json:"session_id"` + CustomerID *uint `json:"customer_id"` + CreatedAt time.Time `json:"created_at"` + UpdatedAt time.Time `json:"updated_at"` +} + type Session struct { ID uint `gorm:"primaryKey" json:"id"` TenantID uint `gorm:"index;not null" json:"tenant_id"` @@ -86,6 +102,8 @@ type Session struct { VisitorIP string `gorm:"size:64" json:"visitor_ip"` VisitorRegion string `gorm:"size:100" json:"visitor_region"` UserAgent string `gorm:"size:500" json:"user_agent"` + // DeviceKey 访客端持久设备指纹(localStorage),用于设备级拉黑 + DeviceKey string `gorm:"size:64;index" json:"device_key"` // 落地页 / 当前页(访客浏览轨迹) LandingURL string `gorm:"size:1000" json:"landing_url"` LandingTitle string `gorm:"size:200" json:"landing_title"` diff --git a/web/src/components/layout/AgentSidebar.tsx b/web/src/components/layout/AgentSidebar.tsx index 6648205..8b83e3c 100644 --- a/web/src/components/layout/AgentSidebar.tsx +++ b/web/src/components/layout/AgentSidebar.tsx @@ -3,7 +3,7 @@ import { useLocation, useNavigate } from 'react-router-dom' import { AppstoreOutlined, MessageOutlined, HistoryOutlined, TeamOutlined, FileTextOutlined, BarChartOutlined, SettingOutlined, LogoutOutlined, - ThunderboltOutlined, DownOutlined, + ThunderboltOutlined, DownOutlined, StopOutlined, } from '@ant-design/icons' import { Dropdown, message as antMsg } from 'antd' import { useAuth } from '@/stores/auth' @@ -15,6 +15,7 @@ const menuItems = [ { key: '/agent/chat-history', icon: , label: '对话记录' }, { key: '/agent/knowledge', icon: , label: '知识库' }, { key: '/agent/quick-replies', icon: , label: '快捷回复' }, + { key: '/agent/blacklist', icon: , label: '黑名单' }, { key: '/agent/statistics', icon: , label: '数据统计' }, { key: '/agent/settings', icon: , label: '系统设置' }, ] diff --git a/web/src/pages/agent/Blacklist.tsx b/web/src/pages/agent/Blacklist.tsx new file mode 100644 index 0000000..628987e --- /dev/null +++ b/web/src/pages/agent/Blacklist.tsx @@ -0,0 +1,235 @@ +import { useCallback, useEffect, useMemo, useState } from 'react' +import { Button, Input, Popconfirm, Select, Spin, Table, Tag, message } from 'antd' +import type { ColumnsType } from 'antd/es/table' +import { ReloadOutlined, StopOutlined } from '@ant-design/icons' +import { getBlacklist, releaseBlacklist, type BlacklistEntry } from '@/services/api' + +function isActive(entry: BlacklistEntry) { + if (!entry.expires_at) return true + return new Date(entry.expires_at).getTime() > Date.now() +} + +function formatExpire(entry: BlacklistEntry) { + if (!entry.expires_at) return '长期' + const t = new Date(entry.expires_at) + if (Number.isNaN(t.getTime())) return '—' + const active = t.getTime() > Date.now() + const text = t.toLocaleString('zh-CN', { + year: 'numeric', month: '2-digit', day: '2-digit', + hour: '2-digit', minute: '2-digit', + }) + return active ? text : `${text}(已过期)` +} + +function maskValue(kind: string, value: string) { + if (kind === 'ip') { + const parts = value.split('.') + if (parts.length === 4) return `${parts[0]}.${parts[1]}.***.${parts[3]}` + return value + } + if (value.length <= 10) return value + return `${value.slice(0, 6)}…${value.slice(-4)}` +} + +const Blacklist = () => { + const [list, setList] = useState([]) + const [loading, setLoading] = useState(false) + const [kind, setKind] = useState() + const [showExpired, setShowExpired] = useState(false) + const [search, setSearch] = useState('') + const [releasingId, setReleasingId] = useState(null) + + const load = useCallback(async () => { + setLoading(true) + try { + const res = await getBlacklist({ + kind: kind || undefined, + active: showExpired ? false : true, + }) + setList(Array.isArray(res.data) ? res.data : []) + } catch (e) { + setList([]) + message.error(e instanceof Error ? e.message : '加载失败') + } finally { + setLoading(false) + } + }, [kind, showExpired]) + + useEffect(() => { + void load() + }, [load]) + + const filtered = useMemo(() => { + const q = search.trim().toLowerCase() + if (!q) return list + return list.filter(item => + item.value.toLowerCase().includes(q) + || (item.reason || '').toLowerCase().includes(q), + ) + }, [list, search]) + + const handleRelease = async (id: number) => { + setReleasingId(id) + try { + await releaseBlacklist(id) + message.success('已解除拉黑') + await load() + } catch (e) { + message.error(e instanceof Error ? e.message : '解除失败') + } finally { + setReleasingId(null) + } + } + + const columns: ColumnsType = [ + { + title: '类型', + dataIndex: 'kind', + width: 90, + render: (k: string) => ( + + {k === 'ip' ? 'IP' : k === 'device' ? '设备' : k} + + ), + }, + { + title: '目标', + dataIndex: 'value', + ellipsis: true, + render: (v: string, row) => ( + + {maskValue(row.kind, v)} + + ), + }, + { + title: '原因', + dataIndex: 'reason', + ellipsis: true, + render: (t: string) => t || '—', + }, + { + title: '状态', + width: 90, + render: (_, row) => ( + isActive(row) + ? 生效中 + : 已过期 + ), + }, + { + title: '释放时间', + width: 180, + render: (_, row) => ( + {formatExpire(row)} + ), + }, + { + title: '拉黑时间', + dataIndex: 'created_at', + width: 170, + render: (t?: string) => t + ? new Date(t).toLocaleString('zh-CN', { + year: 'numeric', month: '2-digit', day: '2-digit', + hour: '2-digit', minute: '2-digit', + }) + : '—', + }, + { + title: '操作', + width: 100, + fixed: 'right', + render: (_, row) => ( + isActive(row) ? ( + handleRelease(row.id)} + okText="解除" + cancelText="取消" + > + + + ) : ( + + ) + ), + }, + ] + + return ( +
+
+
+
+
+ +

黑名单

+
+

+ 查看已拉黑的 IP / 设备,可手动解除。在工作台会话顶栏可拉黑当前访客。 +

+
+ +
+
+ setShowExpired(v === 'all')} + options={[ + { value: 'active', label: '仅生效中' }, + { value: 'all', label: '含已过期' }, + ]} + /> + setSearch(e.target.value)} + /> +
+
+ +
+
+ {loading && list.length === 0 ? ( +
+ ) : ( + `共 ${t} 条`, + showSizeChanger: false, + }} + locale={{ emptyText: '暂无黑名单记录' }} + scroll={{ x: 900 }} + /> + )} + + + + ) +} + +export default Blacklist diff --git a/web/src/pages/agent/Dashboard.tsx b/web/src/pages/agent/Dashboard.tsx index dd9919a..7679c70 100644 --- a/web/src/pages/agent/Dashboard.tsx +++ b/web/src/pages/agent/Dashboard.tsx @@ -1,19 +1,20 @@ import { useState, useEffect, useRef, useCallback } from 'react' -import { Button, Dropdown, Input, Modal, Select, Spin, message as antMsg, Popover } from 'antd' +import { Button, Checkbox, Dropdown, Input, Modal, Radio, Select, Spin, message as antMsg, Popover } from 'antd' import { CheckCircleOutlined, FileTextOutlined, FlagOutlined, PaperClipOutlined, SearchOutlined, SendOutlined, SwapOutlined, FilterOutlined, ExportOutlined, BookOutlined, PictureOutlined, ThunderboltOutlined, + StopOutlined, } from '@ant-design/icons' import EmojiPicker, { insertAtCursor } from '@/components/common/EmojiPicker' import { ChatImage } from '@/components/common/ImagePreview' import MarkdownBody, { stripMarkdown } from '@/components/common/MarkdownBody' import { useAuth } from '@/stores/auth' import { - addSessionNote, claimSession, endSession, getAvailableAgents, getCustomer, getCustomers, getKnowledgeEntries, + addSessionNote, claimSession, createBlacklist, endSession, getAvailableAgents, getCustomer, getCustomers, getKnowledgeEntries, getQuickReplies, getSession, getSessionMessages, getSessions, markSessionRead, sendSessionMessage, suggestQuickReplies, transferSession, updateCustomer, updateSessionPriority, uploadImage, useQuickReply, - type AvailableAgent, type Customer, type CustomerContact, type KnowledgeEntry, type Message, type QuickReply, + type AvailableAgent, type BlacklistDuration, type Customer, type CustomerContact, type KnowledgeEntry, type Message, type QuickReply, type Session, type SessionEvent, type VisitorPageView, } from '@/services/api' @@ -257,6 +258,12 @@ const Dashboard = () => { const [transferOpen, setTransferOpen] = useState(false) const [availableAgents, setAvailableAgents] = useState([]) const [targetAgentID, setTargetAgentID] = useState() + const [blacklistOpen, setBlacklistOpen] = useState(false) + const [blacklistKind, setBlacklistKind] = useState<'ip' | 'device'>('ip') + const [blacklistDuration, setBlacklistDuration] = useState('7d') + const [blacklistReason, setBlacklistReason] = useState('') + const [blacklistEndSession, setBlacklistEndSession] = useState(true) + const [blacklistSaving, setBlacklistSaving] = useState(false) const [endingOpen, setEndingOpen] = useState(false) const [endReason, setEndReason] = useState('resolved') const [knowledgeOpen, setKnowledgeOpen] = useState(false) @@ -857,7 +864,8 @@ const Dashboard = () => { || event.action === 'assign' || event.action === 'auto_assign' || event.action === 'end' - || event.action === 'offline_leave', + || event.action === 'offline_leave' + || event.action === 'blacklist', ) const notes = detail?.events .filter(event => @@ -866,6 +874,7 @@ const Dashboard = () => { || event.action === 'auto_assign' || event.action === 'assign' || event.action === 'transfer' + || event.action === 'blacklist' || event.action === 'end', ) .slice() @@ -890,6 +899,7 @@ const Dashboard = () => { const eventLabel = (action: string) => { switch (action) { case 'transfer': return '会话转接' + case 'blacklist': return '加入黑名单' case 'assign': return '人工分配' case 'auto_assign': return '自动分配' case 'end': return '结束会话' @@ -1007,6 +1017,55 @@ const Dashboard = () => { } } + const openBlacklist = () => { + if (!selected) return + setBlacklistKind(selected.visitor_ip ? 'ip' : 'device') + setBlacklistDuration('7d') + setBlacklistReason('') + setBlacklistEndSession(selected.status === 'active' || selected.status === 'waiting') + setBlacklistOpen(true) + } + + const handleBlacklist = async () => { + if (!selected) return + const reason = blacklistReason.trim() + if (!reason) { + antMsg.warning('请填写拉黑原因') + return + } + if (blacklistKind === 'ip' && !selected.visitor_ip) { + antMsg.warning('该会话无有效 IP,请改选「设备」') + return + } + if (blacklistKind === 'device' && !selected.device_key && !selected.user_agent) { + antMsg.warning('该会话无设备标识,请改选「IP」') + return + } + setBlacklistSaving(true) + try { + await createBlacklist({ + session_id: selected.id, + kind: blacklistKind, + duration: blacklistDuration, + reason, + end_session: blacklistEndSession, + }) + antMsg.success(blacklistKind === 'ip' ? '已拉黑该 IP' : '已拉黑该设备') + setBlacklistOpen(false) + if (blacklistEndSession) { + setSelectedId(null) + setDetail(null) + } else if (selectedId) { + await loadDetail(selectedId, false) + } + await loadAll() + } catch (error) { + antMsg.error(error instanceof Error ? error.message : '拉黑失败') + } finally { + setBlacklistSaving(false) + } + } + const handlePriority = async (priority: 'normal' | 'urgent') => { if (!selected) return try { @@ -1312,6 +1371,15 @@ const Dashboard = () => { + @@ -1965,6 +2033,88 @@ const Dashboard = () => {

请选择本次会话的结束原因。

setBlacklistDuration(v)} + options={[ + { value: '1h', label: '1 小时后自动解除' }, + { value: '6h', label: '6 小时后自动解除' }, + { value: '1d', label: '1 天后自动解除' }, + { value: '7d', label: '7 天后自动解除' }, + { value: '30d', label: '30 天后自动解除' }, + { value: 'permanent', label: '长期(不自动解除)' }, + ]} + /> + +
+
拉黑原因 *
+ setBlacklistReason(e.target.value)} + placeholder="例如:恶意骚扰、发送垃圾信息…" + maxLength={200} + showCount + rows={3} + /> +
+ setBlacklistEndSession(e.target.checked)} + disabled={selected?.status === 'ended' || selected?.status === 'archived'} + > + 拉黑后同时结束当前会话 + +
+ 拉黑后,该{blacklistKind === 'ip' ? ' IP ' : '设备'}再次访问在线客服将被拦截,直至到期或手动解除。 +
+ + import('@/pages/agent/ChatHistory')) const Customers = lazy(() => import('@/pages/agent/Customers')) const Knowledge = lazy(() => import('@/pages/agent/Knowledge')) const QuickReplies = lazy(() => import('@/pages/agent/QuickReplies')) +const Blacklist = lazy(() => import('@/pages/agent/Blacklist')) const Statistics = lazy(() => import('@/pages/agent/Statistics')) const Settings = lazy(() => import('@/pages/agent/Settings')) const AdminDashboard = lazy(() => import('@/pages/admin/Dashboard')) @@ -68,6 +69,7 @@ export const router = createBrowserRouter([ { path: 'customers', element: }, { path: 'knowledge', element: }, { path: 'quick-replies', element: }, + { path: 'blacklist', element: }, { element: , children: [{ path: 'statistics', element: }], diff --git a/web/src/services/api.ts b/web/src/services/api.ts index a587d2f..5232819 100644 --- a/web/src/services/api.ts +++ b/web/src/services/api.ts @@ -20,6 +20,8 @@ export interface Session { visitor_ip?: string visitor_region?: string user_agent?: string + /** 访客设备指纹(用于设备拉黑) */ + device_key?: string landing_url?: string landing_title?: string referrer?: string @@ -349,6 +351,41 @@ export const createCustomer = (data: Partial) => post('/cust export const updateCustomer = (id: number, data: Partial) => put(`/customers/${id}`, data) export const deleteCustomer = (id: number) => del(`/customers/${id}`) +// 黑名单 +export interface BlacklistEntry { + id: number + tenant_id: number + kind: 'ip' | 'device' | string + value: string + reason: string + expires_at?: string | null + operator_id?: number + session_id?: number | null + customer_id?: number | null + created_at?: string +} + +export type BlacklistDuration = '1h' | '6h' | '1d' | '7d' | '30d' | 'permanent' + +export const createBlacklist = (data: { + session_id?: number + kind: 'ip' | 'device' + value?: string + duration: BlacklistDuration | string + reason: string + end_session?: boolean +}) => post('/blacklist', data) + +export const getBlacklist = (params?: { kind?: string; active?: boolean }) => { + const search = new URLSearchParams() + if (params?.kind) search.set('kind', params.kind) + if (params?.active === false) search.set('active', '0') + const qs = search.toString() + return get(`/blacklist${qs ? `?${qs}` : ''}`) +} + +export const releaseBlacklist = (id: number) => del(`/blacklist/${id}`) + // 客户标签库(管理员维护,全员可选) export interface CustomerTag { id: number diff --git a/web/src/widgets/VisitorChat.tsx b/web/src/widgets/VisitorChat.tsx index 05ccba1..baa5a44 100644 --- a/web/src/widgets/VisitorChat.tsx +++ b/web/src/widgets/VisitorChat.tsx @@ -354,6 +354,22 @@ const VisitorChat = ({ } }, [sessionEnded]) + /** 访客端持久设备指纹(用于设备拉黑) */ + const getOrCreateDeviceId = () => { + const key = 'kefu_device_id' + try { + let id = localStorage.getItem(key) + if (id && id.length >= 8) return id + id = (typeof crypto !== 'undefined' && crypto.randomUUID) + ? crypto.randomUUID().replace(/-/g, '') + : `d${Date.now().toString(36)}${Math.random().toString(36).slice(2, 12)}` + localStorage.setItem(key, id) + return id + } catch { + return `d${Date.now().toString(36)}` + } + } + /** 调用 Init 创建新会话并写入本地状态(不复用已结束会话) */ const bootstrapNewSession = useCallback(async () => { const page = hostPageRef.current @@ -363,6 +379,7 @@ const VisitorChat = ({ body: JSON.stringify({ channel_key: channelKey, visitor_name: '访客', + device_id: getOrCreateDeviceId(), page_url: page.url || '', page_title: page.title || '', referrer: page.referrer || '',