优化工作台输入与表情,并补充访客 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
+151
View File
@@ -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 }
}
+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>
+3
View File
@@ -8,6 +8,9 @@ export interface Session {
status: string; priority: string; unread_count: number; satisfaction_score: number | null
last_message?: string; last_message_at?: string | null
satisfaction_text?: string
visitor_ip?: string
visitor_region?: string
user_agent?: string
created_at: string; ended_at: string | null
}
+22 -4
View File
@@ -1,8 +1,9 @@
import { useState, useEffect, useRef, useCallback, useMemo } from 'react'
import {
CloseOutlined, MessageOutlined, SmileOutlined, SendOutlined,
CloseOutlined, MessageOutlined, SendOutlined,
StarFilled, MinusOutlined, PictureOutlined, CustomerServiceOutlined,
} from '@ant-design/icons'
import EmojiPicker, { insertAtCursor } from '@/components/common/EmojiPicker'
interface Message {
id: number
@@ -68,9 +69,22 @@ const VisitorChat = ({
const typingTimerRef = useRef<number | null>(null)
const chatEndRef = useRef<HTMLDivElement>(null)
const fileInputRef = useRef<HTMLInputElement>(null)
const textInputRef = useRef<HTMLInputElement>(null)
const socketRef = useRef<WebSocket | null>(null)
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 s = sid || sessionId
const visitorCredential = token || visitorToken || localStorage.getItem(tokenKey) || ''
@@ -536,9 +550,12 @@ const VisitorChat = ({
>
<PictureOutlined className="text-base" />
</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="表情">
<SmileOutlined className="text-base" />
</button>
<EmojiPicker
onSelect={insertEmoji}
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
ref={fileInputRef}
type="file"
@@ -551,6 +568,7 @@ const VisitorChat = ({
<div className="flex items-center gap-2">
<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]"
placeholder={
sessionEnded