访客落地页、在线读秒与浏览轨迹

记录进线页面与换页路径,心跳刷新活跃时间;工作台展示落地页/当前页/轨迹时间线与在线时长,嵌入脚本同步宿主 SPA 路由。
This commit is contained in:
yml2213
2026-07-18 22:19:50 +08:00
parent 7a08f5f729
commit 81852e0ddd
12 changed files with 656 additions and 13 deletions
+164 -8
View File
@@ -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">