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

记录进线页面与换页路径,心跳刷新活跃时间;工作台展示落地页/当前页/轨迹时间线与在线时长,嵌入脚本同步宿主 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
+86 -1
View File
@@ -1,6 +1,8 @@
/**
* 客服云访客 Widget 嵌入脚本
* 用法: <script src="https://your-host/widget.js" data-id="WK_xxxx"></script>
*
* 向 iframe 同步宿主页 URL / 标题,并监听 SPA 路由变化,供客服端展示落地页与浏览轨迹。
*/
(function () {
if (window.__KEFU_WIDGET_LOADED__) return;
@@ -28,6 +30,8 @@
var open = false;
var iframe = null;
var lastSentURL = '';
var lastSentAt = 0;
var btn = document.createElement('button');
btn.type = 'button';
@@ -51,13 +55,56 @@
'display:none', 'background:#fff',
].join(';');
function hostPageInfo() {
var href = '';
var title = '';
var ref = '';
try {
href = String(window.location.href || '');
title = String(document.title || '');
ref = String(document.referrer || '');
} catch (e) { /* ignore */ }
return { url: href, title: title, referrer: ref };
}
function buildEmbedURL() {
var info = hostPageInfo();
var q =
'channel_key=' + encodeURIComponent(channelKey) +
'&embedded=1' +
'&page_url=' + encodeURIComponent(info.url) +
'&page_title=' + encodeURIComponent(info.title) +
'&referrer=' + encodeURIComponent(info.referrer);
return base + '/widget/embed?' + q;
}
function postPageToIframe(force) {
if (!iframe || !iframe.contentWindow) return;
var info = hostPageInfo();
var now = Date.now();
if (!force && info.url === lastSentURL && now - lastSentAt < 800) return;
lastSentURL = info.url;
lastSentAt = now;
try {
iframe.contentWindow.postMessage({
type: 'kefu-host-page',
url: info.url,
title: info.title,
referrer: info.referrer,
}, '*');
} catch (e) { /* ignore */ }
}
function ensureIframe() {
if (iframe) return;
iframe = document.createElement('iframe');
iframe.title = '在线客服';
iframe.allow = 'clipboard-write';
iframe.style.cssText = 'width:100%;height:100%;border:0;display:block;background:#fff;';
iframe.src = base + '/widget/embed?channel_key=' + encodeURIComponent(channelKey) + '&embedded=1';
iframe.src = buildEmbedURL();
iframe.addEventListener('load', function () {
postPageToIframe(true);
});
panel.appendChild(iframe);
}
@@ -67,6 +114,7 @@
ensureIframe();
panel.style.display = 'block';
btn.style.display = 'none';
postPageToIframe(true);
} else {
panel.style.display = 'none';
btn.style.display = 'flex';
@@ -80,8 +128,45 @@
if (event.data.type === 'kefu-widget-close' || event.data.type === 'kefu-widget-minimize') {
setOpen(false);
}
// iframe 就绪后可再次同步宿主页
if (event.data.type === 'kefu-widget-ready') {
postPageToIframe(true);
}
});
// —— SPA / 浏览器导航监听 ——
function onRouteMaybeChanged() {
if (!open) return;
postPageToIframe(false);
}
try {
var _push = history.pushState;
var _replace = history.replaceState;
history.pushState = function () {
var r = _push.apply(this, arguments);
onRouteMaybeChanged();
return r;
};
history.replaceState = function () {
var r = _replace.apply(this, arguments);
onRouteMaybeChanged();
return r;
};
} catch (e) { /* ignore */ }
window.addEventListener('popstate', onRouteMaybeChanged);
window.addEventListener('hashchange', onRouteMaybeChanged);
// 标题可能异步更新
try {
var titleEl = document.querySelector('title');
if (titleEl && typeof MutationObserver !== 'undefined') {
new MutationObserver(function () { onRouteMaybeChanged(); }).observe(titleEl, {
subtree: true, characterData: true, childList: true,
});
}
} catch (e) { /* ignore */ }
function mount() {
document.body.appendChild(btn);
document.body.appendChild(panel);
+6
View File
@@ -7,6 +7,11 @@ const WidgetEmbed = () => {
const [params] = useSearchParams()
const channelKey = useMemo(() => params.get('channel_key') || 'WK_8a3f2e', [params])
const embedded = params.get('embedded') === '1'
const initialPage = useMemo(() => ({
url: params.get('page_url') || undefined,
title: params.get('page_title') || undefined,
referrer: params.get('referrer') || undefined,
}), [params])
return (
<div className="h-screen w-screen overflow-hidden bg-transparent">
@@ -15,6 +20,7 @@ const WidgetEmbed = () => {
channelKey={channelKey}
embedded={embedded}
layout={embedded ? 'fill' : 'floating'}
initialPage={initialPage}
/>
</div>
)
+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">
+16
View File
@@ -12,6 +12,12 @@ export interface Session {
visitor_ip?: string
visitor_region?: string
user_agent?: string
landing_url?: string
landing_title?: string
referrer?: string
current_url?: string
current_title?: string
last_seen_at?: string | null
created_at: string; ended_at: string | null
/** 列表接口补全字段 */
message_count?: number
@@ -21,6 +27,15 @@ export interface Session {
channel_type?: string
}
export interface VisitorPageView {
id: number
session_id: number
tenant_id?: number
url: string
title: string
entered_at: string
}
export interface Message {
id: number; session_id: number; sender_type: 'visitor' | 'agent'; sender_id: number | null
content: string; type: 'text' | 'image'; seq: number; sent_at: string
@@ -178,6 +193,7 @@ export const getSession = (id: number) => get<{
session: Session
messages: Message[]
events: SessionEvent[]
page_views?: VisitorPageView[]
pending_count: number
max_seq?: number
}>(`/sessions/${id}`)
+101 -4
View File
@@ -48,12 +48,16 @@ function parseWelcomeSegments(data: {
type LayoutMode = 'floating' | 'fill'
export type HostPageInfo = { url?: string; title?: string; referrer?: string }
interface VisitorChatProps {
defaultOpen?: boolean
channelKey?: string
/** 是否在 iframe 嵌入模式(关闭/最小化会 postMessage 给宿主) */
embedded?: boolean
layout?: LayoutMode
/** 宿主页初始 URL(由 widget.js 经 query 传入) */
initialPage?: HostPageInfo
}
const VisitorChat = ({
@@ -61,6 +65,7 @@ const VisitorChat = ({
channelKey = 'WK_8a3f2e',
embedded = false,
layout = 'floating',
initialPage,
}: VisitorChatProps) => {
const storageKey = useMemo(() => `kefu_widget_session_${channelKey}`, [channelKey])
const tokenKey = useMemo(() => `kefu_widget_visitor_token_${channelKey}`, [channelKey])
@@ -124,6 +129,13 @@ const VisitorChat = ({
const agentNameRef = useRef(agentName)
const loadMessagesRef = useRef<(sid?: number, token?: string, opts?: { afterSeq?: number; full?: boolean }) => Promise<void>>(async () => {})
const msgsKeyRef = useRef(msgsKey)
/** 宿主页信息(嵌入时由 postMessage / 初始 query 更新) */
const hostPageRef = useRef<HostPageInfo>({
url: initialPage?.url || (typeof window !== 'undefined' ? window.location.href : ''),
title: initialPage?.title || (typeof document !== 'undefined' ? document.title : ''),
referrer: initialPage?.referrer || (typeof document !== 'undefined' ? document.referrer : ''),
})
const lastPageURLRef = useRef('')
/** 本地已同步到的最大 seq(从缓存消息初始化) */
const lastSeqRef = useRef((() => {
try {
@@ -300,12 +312,45 @@ const VisitorChat = ({
return true
}, [])
const reportPageView = useCallback(async (url: string, title: string) => {
const sid = sessionIdRef.current
const token = visitorTokenRef.current
if (!sid || !token || !url || sessionEnded) return
if (url === lastPageURLRef.current) return
lastPageURLRef.current = url
try {
await fetch('/api/widget/pageview', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'X-Visitor-Token': token,
},
body: JSON.stringify({
session_id: sid,
visitor_token: token,
page_url: url,
page_title: title || '',
}),
})
} catch {
/* 轨迹失败不影响会话 */
}
}, [sessionEnded])
/** 调用 Init 创建新会话并写入本地状态(不复用已结束会话) */
const bootstrapNewSession = useCallback(async () => {
const res = await fetch(
`/api/widget/init?channel_key=${encodeURIComponent(channelKey)}&visitor_name=${encodeURIComponent('访客')}`,
{ method: 'POST' },
)
const page = hostPageRef.current
const res = await fetch('/api/widget/init', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
channel_key: channelKey,
visitor_name: '访客',
page_url: page.url || '',
page_title: page.title || '',
referrer: page.referrer || '',
}),
})
const json = await res.json()
if (json.code !== 0) {
throw new Error(json.message || '初始化会话失败')
@@ -333,6 +378,7 @@ const VisitorChat = ({
localStorage.setItem(tokenKey, token)
localStorage.removeItem(msgsKey)
lastSeqRef.current = 0
if (page.url) lastPageURLRef.current = page.url
await loadMessages(sid, token, { full: true })
}, [channelKey, loadMessages, storageKey, tokenKey, msgsKey])
@@ -417,6 +463,57 @@ const VisitorChat = ({
if (open) initSession()
}, [open, initSession])
// 嵌入模式:通知宿主就绪,并接收宿主页 URL 变更
useEffect(() => {
if (!embedded) return
try {
window.parent.postMessage({ type: 'kefu-widget-ready' }, '*')
} catch { /* ignore */ }
const onMsg = (event: MessageEvent) => {
const data = event?.data
if (!data || data.type !== 'kefu-host-page') return
const url = typeof data.url === 'string' ? data.url : ''
const title = typeof data.title === 'string' ? data.title : ''
const referrer = typeof data.referrer === 'string' ? data.referrer : hostPageRef.current.referrer
if (!url) return
hostPageRef.current = { url, title, referrer }
void reportPageView(url, title)
}
window.addEventListener('message', onMsg)
return () => window.removeEventListener('message', onMsg)
}, [embedded, reportPageView])
// 非嵌入预览:用当前页作为来源
useEffect(() => {
if (embedded || !sessionId || !visitorToken || sessionEnded) return
const url = window.location.href
const title = document.title
hostPageRef.current = {
url,
title,
referrer: document.referrer || hostPageRef.current.referrer,
}
void reportPageView(url, title)
}, [embedded, sessionId, visitorToken, sessionEnded, reportPageView])
// 心跳:刷新 last_seen_at,供客服端在线读秒
useEffect(() => {
if (!sessionId || !visitorToken || sessionEnded || !open) return
const beat = () => {
void fetch('/api/widget/heartbeat', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'X-Visitor-Token': visitorToken,
},
body: JSON.stringify({ session_id: sessionId, visitor_token: visitorToken }),
}).catch(() => {})
}
beat()
const t = window.setInterval(beat, 20000)
return () => clearInterval(t)
}, [sessionId, visitorToken, sessionEnded, open])
// 兜底轮询:按 after_seq 增量对齐
useEffect(() => {
if (sessionId && visitorToken && open) {