优化工作台输入与表情,并补充访客 IP 地区
- 输入框改为单层大尺寸 textarea,修复双层边框 - 新增表情选择器(工作台/Widget),插入 Unicode 表情 - 会话记录访客 IP/地区/UA,工作台顶栏与侧栏展示 - 去掉表情面板无用横向滚动条
This commit is contained in:
@@ -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
|
||||||
|
}
|
||||||
@@ -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 摘要错误")
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -103,12 +103,17 @@ func (h *WidgetHandler) Init(c *gin.Context) {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
|
visitorIP, visitorRegion, userAgent, _ := captureVisitorMeta(c)
|
||||||
|
|
||||||
// 创建会话
|
// 创建会话
|
||||||
session := model.Session{
|
session := model.Session{
|
||||||
TenantID: channel.TenantID,
|
TenantID: channel.TenantID,
|
||||||
ChannelID: channel.ID,
|
ChannelID: channel.ID,
|
||||||
CustomerID: customer.ID,
|
CustomerID: customer.ID,
|
||||||
VisitorTokenHash: visitorTokenHash,
|
VisitorTokenHash: visitorTokenHash,
|
||||||
|
VisitorIP: visitorIP,
|
||||||
|
VisitorRegion: visitorRegion,
|
||||||
|
UserAgent: userAgent,
|
||||||
Status: "waiting",
|
Status: "waiting",
|
||||||
Priority: "normal",
|
Priority: "normal",
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -70,6 +70,9 @@ type Session struct {
|
|||||||
CustomerID uint `gorm:"index" json:"customer_id"`
|
CustomerID uint `gorm:"index" json:"customer_id"`
|
||||||
AgentID *uint `gorm:"index" json:"agent_id"`
|
AgentID *uint `gorm:"index" json:"agent_id"`
|
||||||
VisitorTokenHash string `gorm:"size:64;index" json:"-"`
|
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"`
|
LastReadSeq int `gorm:"default:0" json:"last_read_seq"`
|
||||||
Status string `gorm:"size:20;default:waiting" json:"status"`
|
Status string `gorm:"size:20;default:waiting" json:"status"`
|
||||||
Priority string `gorm:"size:20;default:normal" json:"priority"`
|
Priority string `gorm:"size:20;default:normal" json:"priority"`
|
||||||
|
|||||||
@@ -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 = (
|
||||||
|
<div className="w-[272px] max-w-[calc(100vw-32px)] overflow-hidden">
|
||||||
|
<div className="flex gap-1 mb-2 border-b border-neutral-100 pb-2">
|
||||||
|
{EMOJI_GROUPS.map(g => (
|
||||||
|
<button
|
||||||
|
key={g.key}
|
||||||
|
type="button"
|
||||||
|
onClick={() => setActiveGroup(g.key)}
|
||||||
|
className={`flex-1 min-w-0 text-xs py-1 rounded-md transition-colors ${
|
||||||
|
activeGroup === g.key
|
||||||
|
? 'bg-blue-50 text-blue-600 font-medium'
|
||||||
|
: 'text-neutral-500 hover:bg-neutral-50'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
{g.label}
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
{/* 仅纵向滚动,禁止横向进度条 */}
|
||||||
|
<div className="grid grid-cols-8 gap-0.5 max-h-[200px] overflow-y-auto overflow-x-hidden overscroll-contain">
|
||||||
|
{group.emojis.map((emoji, i) => (
|
||||||
|
<button
|
||||||
|
key={`${group.key}-${i}`}
|
||||||
|
type="button"
|
||||||
|
className="aspect-square w-full min-w-0 text-base leading-none rounded hover:bg-neutral-100 flex items-center justify-center p-0"
|
||||||
|
onClick={() => {
|
||||||
|
onSelect(emoji)
|
||||||
|
setOpen(false)
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{emoji}
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Popover
|
||||||
|
open={open && !disabled}
|
||||||
|
onOpenChange={v => { if (!disabled) setOpen(v) }}
|
||||||
|
content={panel}
|
||||||
|
trigger="click"
|
||||||
|
placement={placement}
|
||||||
|
arrow={false}
|
||||||
|
>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className={className}
|
||||||
|
title={title}
|
||||||
|
aria-label={title}
|
||||||
|
disabled={disabled}
|
||||||
|
>
|
||||||
|
<SmileOutlined />
|
||||||
|
</button>
|
||||||
|
</Popover>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
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 }
|
||||||
|
}
|
||||||
@@ -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 { Button, Dropdown, Input, Modal, Select, Spin, message as antMsg, Popover } from 'antd'
|
||||||
import {
|
import {
|
||||||
CheckCircleOutlined, FileTextOutlined, FlagOutlined, PaperClipOutlined,
|
CheckCircleOutlined, FileTextOutlined, FlagOutlined, PaperClipOutlined,
|
||||||
SearchOutlined, SendOutlined, SmileOutlined, SwapOutlined, FilterOutlined,
|
SearchOutlined, SendOutlined, SwapOutlined, FilterOutlined,
|
||||||
ExportOutlined, BookOutlined, PictureOutlined,
|
ExportOutlined, BookOutlined, PictureOutlined,
|
||||||
} from '@ant-design/icons'
|
} from '@ant-design/icons'
|
||||||
|
import EmojiPicker, { insertAtCursor } from '@/components/common/EmojiPicker'
|
||||||
import { useAuth } from '@/stores/auth'
|
import { useAuth } from '@/stores/auth'
|
||||||
import {
|
import {
|
||||||
addSessionNote, claimSession, endSession, getAvailableAgents, getCustomers, getKnowledgeEntries,
|
addSessionNote, claimSession, endSession, getAvailableAgents, getCustomers, getKnowledgeEntries,
|
||||||
@@ -66,6 +67,24 @@ function channelLabel(source?: string) {
|
|||||||
return source.length > 6 ? '网页' : source
|
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 {
|
interface SessionDetail {
|
||||||
messages: Message[]
|
messages: Message[]
|
||||||
events: SessionEvent[]
|
events: SessionEvent[]
|
||||||
@@ -103,10 +122,23 @@ const Dashboard = () => {
|
|||||||
const initialLoad = useRef(true)
|
const initialLoad = useRef(true)
|
||||||
const chatEndRef = useRef<HTMLDivElement>(null)
|
const chatEndRef = useRef<HTMLDivElement>(null)
|
||||||
const fileInputRef = useRef<HTMLInputElement>(null)
|
const fileInputRef = useRef<HTMLInputElement>(null)
|
||||||
|
const messageInputRef = useRef<HTMLTextAreaElement>(null)
|
||||||
const socketRef = useRef<WebSocket | null>(null)
|
const socketRef = useRef<WebSocket | null>(null)
|
||||||
const lastTypingAt = useRef(0)
|
const lastTypingAt = useRef(0)
|
||||||
const visitorTypingTimer = useRef<number | null>(null)
|
const visitorTypingTimer = useRef<number | null>(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 isManager = user?.role === 'admin' || user?.role === 'supervisor'
|
||||||
|
|
||||||
const loadAll = useCallback(async () => {
|
const loadAll = useCallback(async () => {
|
||||||
@@ -590,9 +622,19 @@ const Dashboard = () => {
|
|||||||
{selectedCustomer.status === 'online' ? '在线' : selectedCustomer.status === 'busy' ? '忙碌' : '离线'}
|
{selectedCustomer.status === 'online' ? '在线' : selectedCustomer.status === 'busy' ? '忙碌' : '离线'}
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex items-center gap-2 mt-0.5 text-xs text-neutral-400">
|
<div className="flex items-center gap-2 mt-0.5 text-xs text-neutral-400 flex-wrap">
|
||||||
<span className="truncate">会话 #{selected.id}</span>
|
{selected.visitor_ip ? (
|
||||||
{selectedCustomer.source && (
|
<span className="truncate">IP: {selected.visitor_ip}</span>
|
||||||
|
) : (
|
||||||
|
<span className="truncate">会话 #{selected.id}</span>
|
||||||
|
)}
|
||||||
|
{selected.visitor_region && (
|
||||||
|
<>
|
||||||
|
<span>|</span>
|
||||||
|
<span className="truncate">{selected.visitor_region}</span>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
{!selected.visitor_ip && selectedCustomer.source && (
|
||||||
<>
|
<>
|
||||||
<span>|</span>
|
<span>|</span>
|
||||||
<span className="truncate">{selectedCustomer.source}</span>
|
<span className="truncate">{selectedCustomer.source}</span>
|
||||||
@@ -711,9 +753,7 @@ const Dashboard = () => {
|
|||||||
) : (
|
) : (
|
||||||
<>
|
<>
|
||||||
<div className="flex items-center gap-1 px-4 pt-2.5 pb-1">
|
<div className="flex items-center gap-1 px-4 pt-2.5 pb-1">
|
||||||
<button type="button" className="w-8 h-8 rounded-md flex items-center justify-center text-neutral-500 hover:bg-neutral-100" title="表情">
|
<EmojiPicker onSelect={insertEmoji} disabled={!canOperate} />
|
||||||
<SmileOutlined />
|
|
||||||
</button>
|
|
||||||
<button type="button" className="w-8 h-8 rounded-md flex items-center justify-center text-neutral-500 hover:bg-neutral-100" title="图片" onClick={() => fileInputRef.current?.click()}>
|
<button type="button" className="w-8 h-8 rounded-md flex items-center justify-center text-neutral-500 hover:bg-neutral-100" title="图片" onClick={() => fileInputRef.current?.click()}>
|
||||||
<PictureOutlined />
|
<PictureOutlined />
|
||||||
</button>
|
</button>
|
||||||
@@ -740,36 +780,34 @@ const Dashboard = () => {
|
|||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex items-end gap-2.5 px-4 pb-3 pt-1">
|
<div className="flex items-end gap-2.5 px-4 pb-3 pt-1">
|
||||||
<div className="flex-1 min-w-0 rounded-lg px-3.5 py-2.5 bg-neutral-50 border border-neutral-200">
|
<textarea
|
||||||
<Input.TextArea
|
ref={messageInputRef}
|
||||||
autoSize={{ minRows: 1, maxRows: 4 }}
|
rows={3}
|
||||||
variant="borderless"
|
className="flex-1 min-w-0 min-h-[88px] max-h-[160px] rounded-xl px-3.5 py-3 bg-neutral-50 border border-neutral-200 text-sm leading-6 text-neutral-800 placeholder:text-neutral-400 outline-none resize-y focus:border-[#2563eb] transition-colors"
|
||||||
className="!bg-transparent !p-0 text-sm"
|
placeholder="输入回复内容… Enter 发送,Shift+Enter 换行"
|
||||||
placeholder="输入回复内容… Enter 发送,Shift+Enter 换行"
|
value={messageInput}
|
||||||
value={messageInput}
|
onChange={event => { setMessageInput(event.target.value); emitTyping() }}
|
||||||
onChange={event => { setMessageInput(event.target.value); emitTyping() }}
|
onPaste={event => {
|
||||||
onPaste={event => {
|
const image = Array.from(event.clipboardData.items).find(item => item.type.startsWith('image/'))
|
||||||
const image = Array.from(event.clipboardData.items).find(item => item.type.startsWith('image/'))
|
if (image) {
|
||||||
if (image) {
|
event.preventDefault()
|
||||||
event.preventDefault()
|
handleImage(image.getAsFile() || undefined)
|
||||||
handleImage(image.getAsFile() || undefined)
|
}
|
||||||
}
|
}}
|
||||||
}}
|
onKeyDown={event => {
|
||||||
onKeyDown={event => {
|
if (event.key === 'Enter' && !event.shiftKey) {
|
||||||
if (event.key === 'Enter' && !event.shiftKey) {
|
event.preventDefault()
|
||||||
event.preventDefault()
|
sendMessage(messageInput)
|
||||||
sendMessage(messageInput)
|
}
|
||||||
}
|
}}
|
||||||
}}
|
/>
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
disabled={!messageInput.trim() || sending}
|
disabled={!messageInput.trim() || sending}
|
||||||
onClick={() => sendMessage(messageInput)}
|
onClick={() => sendMessage(messageInput)}
|
||||||
className="w-10 h-10 rounded-lg bg-[#2563eb] hover:bg-[#1d4ed8] disabled:opacity-40 text-white flex items-center justify-center shrink-0 shadow-sm"
|
className="w-11 h-11 rounded-xl bg-[#2563eb] hover:bg-[#1d4ed8] disabled:opacity-40 text-white flex items-center justify-center shrink-0 shadow-sm"
|
||||||
>
|
>
|
||||||
{sending ? <Spin size="small" /> : <SendOutlined />}
|
{sending ? <Spin size="small" /> : <SendOutlined className="text-base" />}
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</>
|
</>
|
||||||
@@ -838,6 +876,14 @@ 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.conversation_count} 次</span>
|
<span className="truncate text-neutral-800">{selectedCustomer.conversation_count} 次</span>
|
||||||
</div>
|
</div>
|
||||||
|
<div className="flex items-start gap-2">
|
||||||
|
<span className="shrink-0 text-neutral-400 min-w-12">地区</span>
|
||||||
|
<span className="truncate text-neutral-800">{selected?.visitor_region || '—'}</span>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-start gap-2">
|
||||||
|
<span className="shrink-0 text-neutral-400 min-w-12">IP</span>
|
||||||
|
<span className="truncate text-neutral-800 font-mono text-xs">{selected?.visitor_ip || '—'}</span>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -865,6 +911,10 @@ 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="text-neutral-800">{selectedCustomer.source || '直接访问'}</span>
|
<span className="text-neutral-800">{selectedCustomer.source || '直接访问'}</span>
|
||||||
</div>
|
</div>
|
||||||
|
<div className="flex items-start gap-2">
|
||||||
|
<span className="shrink-0 text-neutral-400 min-w-12">设备</span>
|
||||||
|
<span className="text-neutral-800">{summarizeDevice(selected?.user_agent) || '—'}</span>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|||||||
@@ -8,6 +8,9 @@ export interface Session {
|
|||||||
status: string; priority: string; unread_count: number; satisfaction_score: number | null
|
status: string; priority: string; unread_count: number; satisfaction_score: number | null
|
||||||
last_message?: string; last_message_at?: string | null
|
last_message?: string; last_message_at?: string | null
|
||||||
satisfaction_text?: string
|
satisfaction_text?: string
|
||||||
|
visitor_ip?: string
|
||||||
|
visitor_region?: string
|
||||||
|
user_agent?: string
|
||||||
created_at: string; ended_at: string | null
|
created_at: string; ended_at: string | null
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,8 +1,9 @@
|
|||||||
import { useState, useEffect, useRef, useCallback, useMemo } from 'react'
|
import { useState, useEffect, useRef, useCallback, useMemo } from 'react'
|
||||||
import {
|
import {
|
||||||
CloseOutlined, MessageOutlined, SmileOutlined, SendOutlined,
|
CloseOutlined, MessageOutlined, SendOutlined,
|
||||||
StarFilled, MinusOutlined, PictureOutlined, CustomerServiceOutlined,
|
StarFilled, MinusOutlined, PictureOutlined, CustomerServiceOutlined,
|
||||||
} from '@ant-design/icons'
|
} from '@ant-design/icons'
|
||||||
|
import EmojiPicker, { insertAtCursor } from '@/components/common/EmojiPicker'
|
||||||
|
|
||||||
interface Message {
|
interface Message {
|
||||||
id: number
|
id: number
|
||||||
@@ -68,9 +69,22 @@ const VisitorChat = ({
|
|||||||
const typingTimerRef = useRef<number | null>(null)
|
const typingTimerRef = useRef<number | null>(null)
|
||||||
const chatEndRef = useRef<HTMLDivElement>(null)
|
const chatEndRef = useRef<HTMLDivElement>(null)
|
||||||
const fileInputRef = useRef<HTMLInputElement>(null)
|
const fileInputRef = useRef<HTMLInputElement>(null)
|
||||||
|
const textInputRef = useRef<HTMLInputElement>(null)
|
||||||
const socketRef = useRef<WebSocket | null>(null)
|
const socketRef = useRef<WebSocket | null>(null)
|
||||||
const lastTypingAt = useRef(0)
|
const lastTypingAt = useRef(0)
|
||||||
|
|
||||||
|
const insertEmoji = (emoji: string) => {
|
||||||
|
const { next, cursor } = insertAtCursor(textInputRef.current, input, emoji)
|
||||||
|
setInput(next)
|
||||||
|
if (agentsOnline) emitTyping()
|
||||||
|
requestAnimationFrame(() => {
|
||||||
|
const el = textInputRef.current
|
||||||
|
if (!el) return
|
||||||
|
el.focus()
|
||||||
|
el.setSelectionRange(cursor, cursor)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
const loadMessages = useCallback(async (sid?: number, token?: string) => {
|
const loadMessages = useCallback(async (sid?: number, token?: string) => {
|
||||||
const s = sid || sessionId
|
const s = sid || sessionId
|
||||||
const visitorCredential = token || visitorToken || localStorage.getItem(tokenKey) || ''
|
const visitorCredential = token || visitorToken || localStorage.getItem(tokenKey) || ''
|
||||||
@@ -536,9 +550,12 @@ const VisitorChat = ({
|
|||||||
>
|
>
|
||||||
<PictureOutlined className="text-base" />
|
<PictureOutlined className="text-base" />
|
||||||
</button>
|
</button>
|
||||||
<button type="button" className="w-8 h-8 rounded-md border-0 bg-transparent cursor-pointer flex items-center justify-center text-neutral-400 hover:bg-neutral-50" aria-label="表情">
|
<EmojiPicker
|
||||||
<SmileOutlined className="text-base" />
|
onSelect={insertEmoji}
|
||||||
</button>
|
disabled={sessionEnded || !sessionId || sending}
|
||||||
|
className="w-8 h-8 rounded-md border-0 bg-transparent cursor-pointer flex items-center justify-center text-neutral-400 hover:bg-neutral-50 disabled:opacity-40"
|
||||||
|
placement="topLeft"
|
||||||
|
/>
|
||||||
<input
|
<input
|
||||||
ref={fileInputRef}
|
ref={fileInputRef}
|
||||||
type="file"
|
type="file"
|
||||||
@@ -551,6 +568,7 @@ const VisitorChat = ({
|
|||||||
|
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
<input
|
<input
|
||||||
|
ref={textInputRef}
|
||||||
className="flex-1 min-w-0 h-10 px-3 rounded-xl border border-neutral-200 bg-neutral-50 text-[13px] text-neutral-800 outline-none focus:border-[#2563eb]"
|
className="flex-1 min-w-0 h-10 px-3 rounded-xl border border-neutral-200 bg-neutral-50 text-[13px] text-neutral-800 outline-none focus:border-[#2563eb]"
|
||||||
placeholder={
|
placeholder={
|
||||||
sessionEnded
|
sessionEnded
|
||||||
|
|||||||
Reference in New Issue
Block a user