优化工作台输入与表情,并补充访客 IP 地区

- 输入框改为单层大尺寸 textarea,修复双层边框
- 新增表情选择器(工作台/Widget),插入 Unicode 表情
- 会话记录访客 IP/地区/UA,工作台顶栏与侧栏展示
- 去掉表情面板无用横向滚动条
This commit is contained in:
yml2213
2026-07-15 12:13:59 +08:00
parent 318ba8a563
commit 85b407fddf
8 changed files with 441 additions and 36 deletions
+82 -32
View File
@@ -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>