访客落地页、在线读秒与浏览轨迹
记录进线页面与换页路径,心跳刷新活跃时间;工作台展示落地页/当前页/轨迹时间线与在线时长,嵌入脚本同步宿主 SPA 路由。
This commit is contained in:
@@ -13,7 +13,7 @@ import {
|
||||
getQuickReplies, getSession, getSessionMessages, getSessions, markSessionRead, sendSessionMessage,
|
||||
suggestQuickReplies, transferSession, updateSessionPriority, uploadImage, useQuickReply,
|
||||
type AvailableAgent, type Customer, type KnowledgeEntry, type Message, type QuickReply,
|
||||
type Session, type SessionEvent,
|
||||
type Session, type SessionEvent, type VisitorPageView,
|
||||
} from '@/services/api'
|
||||
|
||||
/** 按 id 合并消息,再按 seq / id 排序 */
|
||||
@@ -114,9 +114,35 @@ function summarizeDevice(ua?: string) {
|
||||
interface SessionDetail {
|
||||
messages: Message[]
|
||||
events: SessionEvent[]
|
||||
pageViews: VisitorPageView[]
|
||||
pendingCount: number
|
||||
}
|
||||
|
||||
function formatOnlineDuration(startIso?: string | null, endIso?: string | null, nowMs?: number): string {
|
||||
if (!startIso) return '—'
|
||||
const start = new Date(startIso).getTime()
|
||||
if (!Number.isFinite(start)) return '—'
|
||||
const end = endIso ? new Date(endIso).getTime() : (nowMs ?? Date.now())
|
||||
let sec = Math.max(0, Math.floor((end - start) / 1000))
|
||||
const h = Math.floor(sec / 3600)
|
||||
sec %= 3600
|
||||
const m = Math.floor(sec / 60)
|
||||
const s = sec % 60
|
||||
if (h > 0) return `${String(h).padStart(2, '0')}:${String(m).padStart(2, '0')}:${String(s).padStart(2, '0')}`
|
||||
return `${String(m).padStart(2, '0')}:${String(s).padStart(2, '0')}`
|
||||
}
|
||||
|
||||
function shortPagePath(url?: string): string {
|
||||
if (!url) return '—'
|
||||
try {
|
||||
const u = new URL(url)
|
||||
const path = u.pathname + (u.search || '')
|
||||
return path.length > 48 ? `${path.slice(0, 46)}…` : path || '/'
|
||||
} catch {
|
||||
return url.length > 48 ? `${url.slice(0, 46)}…` : url
|
||||
}
|
||||
}
|
||||
|
||||
const Dashboard = () => {
|
||||
const { user } = useAuth()
|
||||
const [sessions, setSessions] = useState<Session[]>([])
|
||||
@@ -154,6 +180,8 @@ const Dashboard = () => {
|
||||
const [priorityFilter, setPriorityFilter] = useState<'all' | 'urgent'>('all')
|
||||
const [filterOpen, setFilterOpen] = useState(false)
|
||||
const [visitorTyping, setVisitorTyping] = useState(false)
|
||||
/** 驱动在线读秒每秒刷新 */
|
||||
const [clockTick, setClockTick] = useState(0)
|
||||
const initialLoad = useRef(true)
|
||||
const chatEndRef = useRef<HTMLDivElement>(null)
|
||||
const fileInputRef = useRef<HTMLInputElement>(null)
|
||||
@@ -228,7 +256,12 @@ const Dashboard = () => {
|
||||
const response = await getSession(id)
|
||||
const data = response.data
|
||||
const messages = data.messages || []
|
||||
setDetail({ messages, events: data.events || [], pendingCount: data.pending_count || 0 })
|
||||
setDetail({
|
||||
messages,
|
||||
events: data.events || [],
|
||||
pageViews: Array.isArray(data.page_views) ? data.page_views : [],
|
||||
pendingCount: data.pending_count || 0,
|
||||
})
|
||||
rememberMessagesSeq(id, messages)
|
||||
if (typeof data.max_seq === 'number') rememberSeq(id, data.max_seq)
|
||||
// 用详情接口的完整会话字段(含 IP/地区)回填列表,保证顶栏展示准确
|
||||
@@ -279,7 +312,7 @@ const Dashboard = () => {
|
||||
latest = batch[batch.length - 1]
|
||||
setDetail(previous => {
|
||||
if (selectedIdRef.current !== id) return previous
|
||||
if (!previous) return { messages: batch, events: [], pendingCount: 0 }
|
||||
if (!previous) return { messages: batch, events: [], pageViews: [], pendingCount: 0 }
|
||||
return { ...previous, messages: mergeMessagesBySeq(previous.messages, batch) }
|
||||
})
|
||||
rememberMessagesSeq(id, batch)
|
||||
@@ -329,7 +362,7 @@ const Dashboard = () => {
|
||||
if (selectedIdRef.current !== sessionId) return previous
|
||||
if (!previous) {
|
||||
appended = true
|
||||
return { messages: [msg], events: [], pendingCount: 0 }
|
||||
return { messages: [msg], events: [], pageViews: [], pendingCount: 0 }
|
||||
}
|
||||
if (previous.messages.some(item => item.id === msg.id)) {
|
||||
appended = true
|
||||
@@ -429,6 +462,27 @@ const Dashboard = () => {
|
||||
// 转接/分配/结束等会写 SessionEvent,需全量刷新详情(含 events)
|
||||
if (sameSession) void loadDetailRef.current(sid, false, { silent: true })
|
||||
}
|
||||
|
||||
if (payload.type === 'page_view' && sameSession && payload.data) {
|
||||
const pv = payload.data as unknown as VisitorPageView
|
||||
if (pv?.url) {
|
||||
setDetail(prev => {
|
||||
if (!prev) return prev
|
||||
if (prev.pageViews.some(p => p.id === pv.id)) return prev
|
||||
return { ...prev, pageViews: [...prev.pageViews, pv] }
|
||||
})
|
||||
setSessions(previous => previous.map(session =>
|
||||
session.id === sid
|
||||
? {
|
||||
...session,
|
||||
current_url: pv.url,
|
||||
current_title: pv.title || session.current_title,
|
||||
last_seen_at: pv.entered_at || session.last_seen_at,
|
||||
}
|
||||
: session,
|
||||
))
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
// ignore malformed frames
|
||||
}
|
||||
@@ -548,6 +602,13 @@ const Dashboard = () => {
|
||||
const selectedCustomer = selected ? customers[selected.customer_id] : null
|
||||
const canOperate = Boolean(selected && (isManager || selected.agent_id === user?.user_id) && selected.status === 'active')
|
||||
|
||||
// 进行中会话:在线时长每秒刷新
|
||||
useEffect(() => {
|
||||
if (!selected || selected.status === 'ended' || selected.status === 'archived') return
|
||||
const t = window.setInterval(() => setClockTick(n => n + 1), 1000)
|
||||
return () => clearInterval(t)
|
||||
}, [selected?.id, selected?.status])
|
||||
|
||||
useEffect(() => {
|
||||
if (!selectedCustomer) {
|
||||
setCustomerHistory([])
|
||||
@@ -1007,6 +1068,14 @@ const Dashboard = () => {
|
||||
<span className="truncate">
|
||||
{selected.visitor_region || '未知'}
|
||||
</span>
|
||||
<span className="shrink-0 text-neutral-300">|</span>
|
||||
<span className="tabular-nums shrink-0" title="访客在线时长(自进线起)">
|
||||
在线 {formatOnlineDuration(
|
||||
selected.created_at,
|
||||
selected.status === 'ended' || selected.status === 'archived' ? selected.ended_at : null,
|
||||
clockTick >= 0 ? Date.now() : Date.now(),
|
||||
)}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -1350,17 +1419,104 @@ const Dashboard = () => {
|
||||
<span className="shrink-0 text-neutral-400 min-w-12">渠道</span>
|
||||
<span className="text-neutral-800">{channelLabel(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">{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 className="flex items-start gap-2 min-w-0">
|
||||
<span className="shrink-0 text-neutral-400 min-w-12">落地页</span>
|
||||
{selected?.landing_url ? (
|
||||
<a
|
||||
href={selected.landing_url}
|
||||
target="_blank"
|
||||
rel="noreferrer"
|
||||
className="text-[#2563eb] hover:underline break-all min-w-0"
|
||||
title={selected.landing_title || selected.landing_url}
|
||||
>
|
||||
{selected.landing_title || shortPagePath(selected.landing_url)}
|
||||
</a>
|
||||
) : (
|
||||
<span className="text-neutral-400">—</span>
|
||||
)}
|
||||
</div>
|
||||
<div className="flex items-start gap-2 min-w-0">
|
||||
<span className="shrink-0 text-neutral-400 min-w-12">当前页</span>
|
||||
{selected?.current_url ? (
|
||||
<a
|
||||
href={selected.current_url}
|
||||
target="_blank"
|
||||
rel="noreferrer"
|
||||
className="text-neutral-800 hover:text-[#2563eb] hover:underline break-all min-w-0"
|
||||
title={selected.current_title || selected.current_url}
|
||||
>
|
||||
{selected.current_title || shortPagePath(selected.current_url)}
|
||||
</a>
|
||||
) : (
|
||||
<span className="text-neutral-400">—</span>
|
||||
)}
|
||||
</div>
|
||||
{selected?.referrer ? (
|
||||
<div className="flex items-start gap-2 min-w-0">
|
||||
<span className="shrink-0 text-neutral-400 min-w-12">来路</span>
|
||||
<a
|
||||
href={selected.referrer}
|
||||
target="_blank"
|
||||
rel="noreferrer"
|
||||
className="text-neutral-500 hover:underline break-all text-xs min-w-0"
|
||||
>
|
||||
{shortPagePath(selected.referrer)}
|
||||
</a>
|
||||
</div>
|
||||
) : null}
|
||||
<div className="flex items-start gap-2">
|
||||
<span className="shrink-0 text-neutral-400 min-w-12">在线</span>
|
||||
<span className="text-neutral-800 tabular-nums font-medium">
|
||||
{formatOnlineDuration(
|
||||
selected?.created_at,
|
||||
selected?.status === 'ended' || selected?.status === 'archived' ? selected?.ended_at : null,
|
||||
clockTick >= 0 ? Date.now() : Date.now(),
|
||||
)}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="mb-5">
|
||||
<div className="mb-2.5 text-xs font-semibold text-neutral-500 uppercase tracking-wider">浏览轨迹</div>
|
||||
{!detail?.pageViews?.length ? (
|
||||
<div className="text-xs text-neutral-400">暂无页面记录(需嵌入 widget 并打开会话)</div>
|
||||
) : (
|
||||
<div className="flex flex-col gap-0 max-h-48 overflow-y-auto rounded-lg border border-neutral-100">
|
||||
{detail.pageViews.map((pv, idx) => (
|
||||
<div
|
||||
key={pv.id || `${pv.url}-${pv.entered_at}`}
|
||||
className={`px-2.5 py-2 text-xs ${idx > 0 ? 'border-t border-neutral-50' : ''} ${
|
||||
idx === detail.pageViews.length - 1 ? 'bg-blue-50/50' : 'bg-white'
|
||||
}`}
|
||||
>
|
||||
<div className="flex items-center justify-between gap-2 mb-0.5">
|
||||
<span className="text-neutral-400 tabular-nums shrink-0">
|
||||
{new Date(pv.entered_at).toLocaleTimeString('zh-CN', { hour: '2-digit', minute: '2-digit', second: '2-digit' })}
|
||||
</span>
|
||||
{idx === detail.pageViews.length - 1 && (
|
||||
<span className="text-[10px] text-[#2563eb] font-medium">当前</span>
|
||||
)}
|
||||
</div>
|
||||
<a
|
||||
href={pv.url}
|
||||
target="_blank"
|
||||
rel="noreferrer"
|
||||
className="text-neutral-800 hover:text-[#2563eb] break-all leading-snug"
|
||||
title={pv.url}
|
||||
>
|
||||
{pv.title || shortPagePath(pv.url)}
|
||||
</a>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="mb-5">
|
||||
<div className="mb-2.5 text-xs font-semibold text-neutral-500 uppercase tracking-wider">历史会话</div>
|
||||
<div className="flex flex-col gap-2">
|
||||
|
||||
Reference in New Issue
Block a user