优化工作台输入与表情,并补充访客 IP 地区
- 输入框改为单层大尺寸 textarea,修复双层边框 - 新增表情选择器(工作台/Widget),插入 Unicode 表情 - 会话记录访客 IP/地区/UA,工作台顶栏与侧栏展示 - 去掉表情面板无用横向滚动条
This commit is contained in:
@@ -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<HTMLDivElement>(null)
|
||||
const fileInputRef = useRef<HTMLInputElement>(null)
|
||||
const messageInputRef = useRef<HTMLTextAreaElement>(null)
|
||||
const socketRef = useRef<WebSocket | null>(null)
|
||||
const lastTypingAt = useRef(0)
|
||||
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 loadAll = useCallback(async () => {
|
||||
@@ -590,9 +622,19 @@ const Dashboard = () => {
|
||||
{selectedCustomer.status === 'online' ? '在线' : selectedCustomer.status === 'busy' ? '忙碌' : '离线'}
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-2 mt-0.5 text-xs text-neutral-400">
|
||||
<span className="truncate">会话 #{selected.id}</span>
|
||||
{selectedCustomer.source && (
|
||||
<div className="flex items-center gap-2 mt-0.5 text-xs text-neutral-400 flex-wrap">
|
||||
{selected.visitor_ip ? (
|
||||
<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 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">
|
||||
<button type="button" className="w-8 h-8 rounded-md flex items-center justify-center text-neutral-500 hover:bg-neutral-100" title="表情">
|
||||
<SmileOutlined />
|
||||
</button>
|
||||
<EmojiPicker onSelect={insertEmoji} disabled={!canOperate} />
|
||||
<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 />
|
||||
</button>
|
||||
@@ -740,36 +780,34 @@ const Dashboard = () => {
|
||||
</button>
|
||||
</div>
|
||||
<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">
|
||||
<Input.TextArea
|
||||
autoSize={{ minRows: 1, maxRows: 4 }}
|
||||
variant="borderless"
|
||||
className="!bg-transparent !p-0 text-sm"
|
||||
placeholder="输入回复内容… Enter 发送,Shift+Enter 换行"
|
||||
value={messageInput}
|
||||
onChange={event => { 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)
|
||||
}
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
<textarea
|
||||
ref={messageInputRef}
|
||||
rows={3}
|
||||
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"
|
||||
placeholder="输入回复内容… Enter 发送,Shift+Enter 换行"
|
||||
value={messageInput}
|
||||
onChange={event => { 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)
|
||||
}
|
||||
}}
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
disabled={!messageInput.trim() || sending}
|
||||
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>
|
||||
</div>
|
||||
</>
|
||||
@@ -838,6 +876,14 @@ const Dashboard = () => {
|
||||
<span className="shrink-0 text-neutral-400 min-w-12">对话</span>
|
||||
<span className="truncate text-neutral-800">{selectedCustomer.conversation_count} 次</span>
|
||||
</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>
|
||||
|
||||
@@ -865,6 +911,10 @@ const Dashboard = () => {
|
||||
<span className="shrink-0 text-neutral-400 min-w-12">来源</span>
|
||||
<span className="text-neutral-800">{selectedCustomer.source || '直接访问'}</span>
|
||||
</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>
|
||||
|
||||
|
||||
Reference in New Issue
Block a user