From 85b407fddff75b8036cbcf2288efc55eda7a88a3 Mon Sep 17 00:00:00 2001 From: yml2213 Date: Wed, 15 Jul 2026 12:13:59 +0800 Subject: [PATCH] =?UTF-8?q?=E4=BC=98=E5=8C=96=E5=B7=A5=E4=BD=9C=E5=8F=B0?= =?UTF-8?q?=E8=BE=93=E5=85=A5=E4=B8=8E=E8=A1=A8=E6=83=85=EF=BC=8C=E5=B9=B6?= =?UTF-8?q?=E8=A1=A5=E5=85=85=E8=AE=BF=E5=AE=A2=20IP=20=E5=9C=B0=E5=8C=BA?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 输入框改为单层大尺寸 textarea,修复双层边框 - 新增表情选择器(工作台/Widget),插入 Unicode 表情 - 会话记录访客 IP/地区/UA,工作台顶栏与侧栏展示 - 去掉表情面板无用横向滚动条 --- server/internal/handler/visitor_meta.go | 133 ++++++++++++++++ server/internal/handler/visitor_meta_test.go | 42 ++++++ server/internal/handler/widget.go | 5 + server/internal/model/models.go | 3 + web/src/components/common/EmojiPicker.tsx | 151 +++++++++++++++++++ web/src/pages/agent/Dashboard.tsx | 114 ++++++++++---- web/src/services/api.ts | 3 + web/src/widgets/VisitorChat.tsx | 26 +++- 8 files changed, 441 insertions(+), 36 deletions(-) create mode 100644 server/internal/handler/visitor_meta.go create mode 100644 server/internal/handler/visitor_meta_test.go create mode 100644 web/src/components/common/EmojiPicker.tsx diff --git a/server/internal/handler/visitor_meta.go b/server/internal/handler/visitor_meta.go new file mode 100644 index 0000000..5796b98 --- /dev/null +++ b/server/internal/handler/visitor_meta.go @@ -0,0 +1,133 @@ +package handler + +import ( + "net" + "strings" + + "github.com/gin-gonic/gin" +) + +// resolveVisitorIP 优先信任反代头,回落到 Gin ClientIP。 +func resolveVisitorIP(c *gin.Context) string { + candidates := []string{ + c.GetHeader("CF-Connecting-IP"), + c.GetHeader("True-Client-IP"), + c.GetHeader("X-Real-IP"), + } + if xff := c.GetHeader("X-Forwarded-For"); xff != "" { + // 取最左侧第一个非空 IP + for _, part := range strings.Split(xff, ",") { + part = strings.TrimSpace(part) + if part != "" { + candidates = append(candidates, part) + break + } + } + } + for _, raw := range candidates { + ip := strings.TrimSpace(raw) + if ip == "" { + continue + } + // 去掉端口 + if host, _, err := net.SplitHostPort(ip); err == nil { + ip = host + } + if net.ParseIP(ip) != nil { + return ip + } + } + ip := c.ClientIP() + if host, _, err := net.SplitHostPort(ip); err == nil { + return host + } + return ip +} + +// resolveVisitorRegion 基于 IP 与常见 CDN 国家头做粗粒度地区,无需第三方库。 +// 私网/本机显示「内网」;有国家头则映射中文名;否则「未知」。 +func resolveVisitorRegion(c *gin.Context, ip string) string { + // Cloudflare / 部分网关国家码 + country := strings.ToUpper(strings.TrimSpace(c.GetHeader("CF-IPCountry"))) + if country == "" { + country = strings.ToUpper(strings.TrimSpace(c.GetHeader("X-AppEngine-Country"))) + } + if country == "" { + country = strings.ToUpper(strings.TrimSpace(c.GetHeader("CloudFront-Viewer-Country"))) + } + if country != "" && country != "XX" && country != "T1" { + if name, ok := countryNameCN(country); ok { + return name + } + return country + } + + parsed := net.ParseIP(ip) + if parsed == nil { + return "未知" + } + if parsed.IsLoopback() || parsed.IsPrivate() || parsed.IsLinkLocalUnicast() || parsed.IsLinkLocalMulticast() { + return "内网" + } + // 未接入 GeoIP 库时,公网 IP 地区暂标未知(可后续接入离线库) + return "未知" +} + +func countryNameCN(code string) (string, bool) { + names := map[string]string{ + "CN": "中国", "HK": "中国香港", "MO": "中国澳门", "TW": "中国台湾", + "US": "美国", "JP": "日本", "KR": "韩国", "SG": "新加坡", + "GB": "英国", "DE": "德国", "FR": "法国", "AU": "澳大利亚", + "CA": "加拿大", "RU": "俄罗斯", "IN": "印度", "BR": "巴西", + "MY": "马来西亚", "TH": "泰国", "VN": "越南", "PH": "菲律宾", + "ID": "印度尼西亚", "AE": "阿联酋", "SA": "沙特阿拉伯", + } + name, ok := names[code] + return name, ok +} + +// summarizeUserAgent 将 UA 压缩为可读设备摘要,如 "Chrome / macOS"。 +func summarizeUserAgent(ua string) string { + ua = strings.TrimSpace(ua) + if ua == "" { + return "" + } + browser := "浏览器" + osName := "未知系统" + switch { + case strings.Contains(ua, "Edg/"): + browser = "Edge" + case strings.Contains(ua, "Chrome/") && !strings.Contains(ua, "Edg/"): + browser = "Chrome" + case strings.Contains(ua, "Firefox/"): + browser = "Firefox" + case strings.Contains(ua, "Safari/") && !strings.Contains(ua, "Chrome/"): + browser = "Safari" + case strings.Contains(ua, "MicroMessenger"): + browser = "微信" + } + switch { + case strings.Contains(ua, "Windows"): + osName = "Windows" + case strings.Contains(ua, "Mac OS X") || strings.Contains(ua, "Macintosh"): + osName = "macOS" + case strings.Contains(ua, "Android"): + osName = "Android" + case strings.Contains(ua, "iPhone") || strings.Contains(ua, "iPad"): + osName = "iOS" + case strings.Contains(ua, "Linux"): + osName = "Linux" + } + return browser + " / " + osName +} + +func captureVisitorMeta(c *gin.Context) (ip, region, ua, device string) { + ip = resolveVisitorIP(c) + region = resolveVisitorRegion(c, ip) + ua = c.Request.UserAgent() + if len(ua) > 500 { + ua = ua[:500] + } + device = summarizeUserAgent(ua) + return +} diff --git a/server/internal/handler/visitor_meta_test.go b/server/internal/handler/visitor_meta_test.go new file mode 100644 index 0000000..e8e7e54 --- /dev/null +++ b/server/internal/handler/visitor_meta_test.go @@ -0,0 +1,42 @@ +package handler + +import ( + "net/http/httptest" + "testing" + + "github.com/gin-gonic/gin" +) + +func TestResolveVisitorIPAndRegion(t *testing.T) { + gin.SetMode(gin.TestMode) + + // 内网 IP + w := httptest.NewRecorder() + c, _ := gin.CreateTestContext(w) + c.Request = httptest.NewRequest("GET", "/", nil) + c.Request.RemoteAddr = "192.168.1.105:12345" + ip := resolveVisitorIP(c) + if ip != "192.168.1.105" { + t.Fatalf("内网 IP 解析错误: %s", ip) + } + if region := resolveVisitorRegion(c, ip); region != "内网" { + t.Fatalf("内网地区应为内网, got %s", region) + } + + // CF 国家头 + c2, _ := gin.CreateTestContext(httptest.NewRecorder()) + c2.Request = httptest.NewRequest("GET", "/", nil) + c2.Request.Header.Set("CF-Connecting-IP", "8.8.8.8") + c2.Request.Header.Set("CF-IPCountry", "US") + ip2 := resolveVisitorIP(c2) + if ip2 != "8.8.8.8" { + t.Fatalf("CF IP 解析错误: %s", ip2) + } + if region := resolveVisitorRegion(c2, ip2); region != "美国" { + t.Fatalf("期望美国, got %s", region) + } + + if summarizeUserAgent("Mozilla/5.0 (Macintosh; Intel Mac OS X) Chrome/120.0.0.0") != "Chrome / macOS" { + t.Fatalf("UA 摘要错误") + } +} diff --git a/server/internal/handler/widget.go b/server/internal/handler/widget.go index 2a6b9ae..bb1e338 100644 --- a/server/internal/handler/widget.go +++ b/server/internal/handler/widget.go @@ -103,12 +103,17 @@ func (h *WidgetHandler) Init(c *gin.Context) { return } + visitorIP, visitorRegion, userAgent, _ := captureVisitorMeta(c) + // 创建会话 session := model.Session{ TenantID: channel.TenantID, ChannelID: channel.ID, CustomerID: customer.ID, VisitorTokenHash: visitorTokenHash, + VisitorIP: visitorIP, + VisitorRegion: visitorRegion, + UserAgent: userAgent, Status: "waiting", Priority: "normal", } diff --git a/server/internal/model/models.go b/server/internal/model/models.go index 9e9cdbc..eb35866 100644 --- a/server/internal/model/models.go +++ b/server/internal/model/models.go @@ -70,6 +70,9 @@ type Session struct { CustomerID uint `gorm:"index" json:"customer_id"` AgentID *uint `gorm:"index" json:"agent_id"` VisitorTokenHash string `gorm:"size:64;index" json:"-"` + VisitorIP string `gorm:"size:64" json:"visitor_ip"` + VisitorRegion string `gorm:"size:100" json:"visitor_region"` + UserAgent string `gorm:"size:500" json:"user_agent"` 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"` diff --git a/web/src/components/common/EmojiPicker.tsx b/web/src/components/common/EmojiPicker.tsx new file mode 100644 index 0000000..0826e2e --- /dev/null +++ b/web/src/components/common/EmojiPicker.tsx @@ -0,0 +1,151 @@ +import { useState } from 'react' +import { Popover } from 'antd' +import { SmileOutlined } from '@ant-design/icons' + +/** 常用 Unicode 表情(无需额外依赖,文本消息可直接发送) */ +const EMOJI_GROUPS: { key: string; label: string; emojis: string[] }[] = [ + { + key: 'face', + label: '表情', + emojis: [ + '😀', '😁', '😂', '🤣', '😃', '😄', '😅', '😆', '😉', '😊', + '😋', '😎', '😍', '😘', '😗', '😙', '😚', '🙂', '🤗', '🤩', + '🤔', '🤨', '😐', '😑', '😶', '🙄', '😏', '😣', '😥', '😮', + '🤐', '😯', '😪', '😫', '🥱', '😴', '😌', '😛', '😜', '😝', + '🤤', '😒', '😓', '😔', '😕', '🙃', '🤑', '😲', '☹️', '🙁', + '😖', '😞', '😟', '😤', '😢', '😭', '😦', '😧', '😨', '😩', + '🤯', '😬', '😰', '😱', '🥵', '🥶', '😳', '🤪', '😵', '🥴', + '😠', '😡', '🤬', '😷', '🤒', '🤕', '🤢', '🤮', '🤧', '😇', + ], + }, + { + key: 'gesture', + label: '手势', + emojis: [ + '👍', '👎', '👌', '✌️', '🤞', '🤟', '🤘', '🤙', '👈', '👉', + '👆', '👇', '☝️', '✋', '🤚', '🖐', '🖖', '👋', '🤝', '👏', + '🙌', '👐', '🤲', '🙏', '💪', '🦾', '✍️', '💅', '🤳', '💃', + ], + }, + { + key: 'heart', + label: '符号', + emojis: [ + '❤️', '🧡', '💛', '💚', '💙', '💜', '🖤', '🤍', '🤎', '💔', + '❣️', '💕', '💞', '💓', '💗', '💖', '💘', '💝', '💟', '☮️', + '✅', '❌', '⭐', '🌟', '💫', '✨', '🔥', '💯', '🎉', '🎊', + '💐', '🌹', '🌺', '🌸', '🌼', '🌻', '🍀', '🌈', '☀️', '🌙', + ], + }, + { + key: 'work', + label: '工作', + emojis: [ + '📦', '📋', '📌', '📍', '📎', '🔗', '📝', '✏️', '📂', '📁', + '💼', '💻', '🖥️', '📱', '☎️', '📞', '📧', '📨', '📩', '🕐', + '⏰', '📅', '📊', '📈', '📉', '💡', '🔍', '🛠️', '⚙️', '🛒', + ], + }, +] + +export interface EmojiPickerProps { + onSelect: (emoji: string) => void + disabled?: boolean + /** 触发按钮 className */ + className?: string + title?: string + /** 弹出方向 */ + placement?: 'topLeft' | 'top' | 'topRight' | 'bottomLeft' | 'bottom' | 'bottomRight' +} + +/** + * 轻量表情选择器:点击插入 Unicode 表情到输入框,无需后端改造。 + */ +const EmojiPicker = ({ + onSelect, + disabled = false, + className = 'w-8 h-8 rounded-md flex items-center justify-center text-neutral-500 hover:bg-neutral-100 disabled:opacity-40', + title = '表情', + placement = 'topLeft', +}: EmojiPickerProps) => { + const [open, setOpen] = useState(false) + const [activeGroup, setActiveGroup] = useState(EMOJI_GROUPS[0].key) + + const group = EMOJI_GROUPS.find(g => g.key === activeGroup) || EMOJI_GROUPS[0] + + const panel = ( +
+
+ {EMOJI_GROUPS.map(g => ( + + ))} +
+ {/* 仅纵向滚动,禁止横向进度条 */} +
+ {group.emojis.map((emoji, i) => ( + + ))} +
+
+ ) + + return ( + { if (!disabled) setOpen(v) }} + content={panel} + trigger="click" + placement={placement} + arrow={false} + > + + + ) +} + +export default EmojiPicker + +/** 在 textarea/input 光标处插入文本,并返回新值与光标位置 */ +export function insertAtCursor( + el: HTMLTextAreaElement | HTMLInputElement | null, + value: string, + insert: string, +): { next: string; cursor: number } { + if (!el) { + return { next: value + insert, cursor: value.length + insert.length } + } + const start = el.selectionStart ?? value.length + const end = el.selectionEnd ?? value.length + const next = value.slice(0, start) + insert + value.slice(end) + const cursor = start + insert.length + return { next, cursor } +} diff --git a/web/src/pages/agent/Dashboard.tsx b/web/src/pages/agent/Dashboard.tsx index ff03006..78e252d 100644 --- a/web/src/pages/agent/Dashboard.tsx +++ b/web/src/pages/agent/Dashboard.tsx @@ -2,9 +2,10 @@ import { useState, useEffect, useRef, useCallback } from 'react' import { Button, Dropdown, Input, Modal, Select, Spin, message as antMsg, Popover } from 'antd' import { CheckCircleOutlined, FileTextOutlined, FlagOutlined, PaperClipOutlined, - SearchOutlined, SendOutlined, SmileOutlined, SwapOutlined, FilterOutlined, + SearchOutlined, SendOutlined, SwapOutlined, FilterOutlined, ExportOutlined, BookOutlined, PictureOutlined, } from '@ant-design/icons' +import EmojiPicker, { insertAtCursor } from '@/components/common/EmojiPicker' import { useAuth } from '@/stores/auth' import { addSessionNote, claimSession, endSession, getAvailableAgents, getCustomers, getKnowledgeEntries, @@ -66,6 +67,24 @@ function channelLabel(source?: string) { return source.length > 6 ? '网页' : source } +/** 从 UA 摘要设备信息,与后端 summarizeUserAgent 逻辑对齐 */ +function summarizeDevice(ua?: string) { + if (!ua) return '' + let browser = '浏览器' + let osName = '未知系统' + if (ua.includes('Edg/')) browser = 'Edge' + else if (ua.includes('Chrome/') && !ua.includes('Edg/')) browser = 'Chrome' + else if (ua.includes('Firefox/')) browser = 'Firefox' + else if (ua.includes('Safari/') && !ua.includes('Chrome/')) browser = 'Safari' + else if (ua.includes('MicroMessenger')) browser = '微信' + if (ua.includes('Windows')) osName = 'Windows' + else if (ua.includes('Mac OS X') || ua.includes('Macintosh')) osName = 'macOS' + else if (ua.includes('Android')) osName = 'Android' + else if (ua.includes('iPhone') || ua.includes('iPad')) osName = 'iOS' + else if (ua.includes('Linux')) osName = 'Linux' + return `${browser} / ${osName}` +} + interface SessionDetail { messages: Message[] events: SessionEvent[] @@ -103,10 +122,23 @@ const Dashboard = () => { const initialLoad = useRef(true) const chatEndRef = useRef(null) const fileInputRef = useRef(null) + const messageInputRef = useRef(null) const socketRef = useRef(null) const lastTypingAt = useRef(0) const visitorTypingTimer = useRef(null) + const insertEmoji = (emoji: string) => { + const { next, cursor } = insertAtCursor(messageInputRef.current, messageInput, emoji) + setMessageInput(next) + emitTyping() + requestAnimationFrame(() => { + const el = messageInputRef.current + if (!el) return + el.focus() + el.setSelectionRange(cursor, cursor) + }) + } + const isManager = user?.role === 'admin' || user?.role === 'supervisor' const loadAll = useCallback(async () => { @@ -590,9 +622,19 @@ const Dashboard = () => { {selectedCustomer.status === 'online' ? '在线' : selectedCustomer.status === 'busy' ? '忙碌' : '离线'} -
- 会话 #{selected.id} - {selectedCustomer.source && ( +
+ {selected.visitor_ip ? ( + IP: {selected.visitor_ip} + ) : ( + 会话 #{selected.id} + )} + {selected.visitor_region && ( + <> + | + {selected.visitor_region} + + )} + {!selected.visitor_ip && selectedCustomer.source && ( <> | {selectedCustomer.source} @@ -711,9 +753,7 @@ const Dashboard = () => { ) : ( <>
- + @@ -740,36 +780,34 @@ const Dashboard = () => {
-
- { setMessageInput(event.target.value); emitTyping() }} - onPaste={event => { - const image = Array.from(event.clipboardData.items).find(item => item.type.startsWith('image/')) - if (image) { - event.preventDefault() - handleImage(image.getAsFile() || undefined) - } - }} - onKeyDown={event => { - if (event.key === 'Enter' && !event.shiftKey) { - event.preventDefault() - sendMessage(messageInput) - } - }} - /> -
+