feat(widget): 优化访客在线轨迹、GeoIP与多站点渠道
CI / Frontend (typecheck + lint + build) (push) Successful in 2m5s
CI / Backend (go vet + test) (push) Failing after 5m20s

This commit is contained in:
yml2213
2026-11-06 18:21:30 +08:00
parent 24fac1bcf1
commit 38d6fc1684
37 changed files with 1071 additions and 123 deletions
+31 -2
View File
@@ -34,6 +34,7 @@
var lastSentKey = '';
var lastSentAt = 0;
var routeTimer = null;
var presenceTimer = null;
var btn = document.createElement('button');
btn.type = 'button';
@@ -104,7 +105,7 @@
* 避免轨迹标题永远慢一页(URL 已是新页、title 仍是上一页)。
*/
function schedulePostPage(force) {
if (!open && !force) return;
if (!iframe && !force) return;
if (routeTimer) clearTimeout(routeTimer);
routeTimer = setTimeout(function () {
routeTimer = null;
@@ -123,8 +124,20 @@
iframe.src = buildEmbedURL();
iframe.addEventListener('load', function () {
schedulePostPage(true);
postHostVisibility();
});
panel.appendChild(iframe);
if (presenceTimer == null) {
presenceTimer = window.setInterval(function () {
if (!iframe || !iframe.contentWindow) return;
try {
iframe.contentWindow.postMessage({
type: 'kefu-host-tick',
visible: document.visibilityState === 'visible',
}, '*');
} catch (e) { /* ignore */ }
}, 15000);
}
}
function setOpen(next) {
@@ -140,6 +153,16 @@
}
}
function postHostVisibility() {
if (!iframe || !iframe.contentWindow) return;
try {
iframe.contentWindow.postMessage({
type: 'kefu-host-visibility',
visible: document.visibilityState === 'visible',
}, '*');
} catch (e) { /* ignore */ }
}
function shouldAutoOpenOnce() {
var key = 'kefu_widget_auto_opened_' + channelKey;
try {
@@ -158,13 +181,14 @@
}
// iframe 就绪后可再次同步宿主页
if (event.data.type === 'kefu-widget-ready') {
postHostVisibility();
schedulePostPage(true);
}
});
// —— SPA / 浏览器导航监听 ——
function onRouteMaybeChanged() {
if (!open) return;
if (!iframe) return;
schedulePostPage(false);
}
@@ -185,6 +209,11 @@
window.addEventListener('popstate', onRouteMaybeChanged);
window.addEventListener('hashchange', onRouteMaybeChanged);
document.addEventListener('visibilitychange', function () {
postHostVisibility();
if (document.visibilityState === 'visible') schedulePostPage(true);
});
window.addEventListener('pagehide', postHostVisibility);
// 标题可能异步更新
try {
var titleEl = document.querySelector('title');
+53 -20
View File
@@ -19,8 +19,8 @@ import {
type Session, type SessionEvent, type VisitorPageView,
} from '@/services/api'
import {
channelLabel, formatOnlineDuration, listStatusMeta, maxMessageSeq, mergeMessagesBySeq, normalizeMessage,
relativeTime, shortPagePath,
channelLabel, formatSeconds, isVisitorOnline, listStatusMeta, maxMessageSeq, mergeMessagesBySeq,
normalizeMessage, relativeTime, shortPagePath,
type CustomerEditableField, type SessionDetail,
} from './dashboard/helpers'
import { CustomerInfoField, CustomerTagsEditor } from './dashboard/CustomerFields'
@@ -73,7 +73,7 @@ const Dashboard = () => {
const [savingCustomerField, setSavingCustomerField] = useState<CustomerEditableField | null>(null)
const [savingCustomerTags, setSavingCustomerTags] = useState(false)
/** 驱动在线读秒每秒刷新 */
const [clockTick, setClockTick] = useState(0)
const [, setClockTick] = useState(0)
const initialLoad = useRef(true)
const chatEndRef = useRef<HTMLDivElement>(null)
const fileInputRef = useRef<HTMLInputElement>(null)
@@ -426,6 +426,25 @@ const Dashboard = () => {
if (sameSession) void loadDetailRef.current(sid, false, { silent: true })
}
if (payload.type === 'visitor_presence') {
const presence = payload.data as unknown as {
visitor_online?: boolean
last_seen_at?: string | null
visitor_left_at?: string | null
}
setSessions(previous => previous.map(session =>
session.id === sid
? {
...session,
visitor_online: Boolean(presence.visitor_online),
last_seen_at: presence.last_seen_at ?? session.last_seen_at,
visitor_left_at: presence.visitor_left_at ?? null,
}
: session,
))
return
}
if (payload.type === 'page_view' && sameSession && payload.data) {
const raw = payload.data as unknown as VisitorPageView & { title_fix?: boolean }
if (raw?.url) {
@@ -663,19 +682,24 @@ const Dashboard = () => {
const selected = sessions.find(session => session.id === selectedId)
const selectedCustomer = selected ? customers[selected.customer_id] : null
const visitorOnline = selected ? isVisitorOnline(selected) : false
const canOperate = Boolean(selected && (isManager || selected.agent_id === user?.user_id) && selected.status === 'active')
// 进行中会话:在线时长每秒刷新;轨迹每 8s 静默拉一次(防 WS 漏推)
// 在线/离线超时和相对时间需要持续刷新。
useEffect(() => {
const t = window.setInterval(() => setClockTick(n => n + 1), 1000)
return () => clearInterval(t)
}, [])
// 进行中会话:轨迹每 8s 静默拉一次(防 WS 漏推)
useEffect(() => {
if (!selected || selected.status === 'ended' || selected.status === 'archived') return
const t = window.setInterval(() => setClockTick(n => n + 1), 1000)
const poll = window.setInterval(() => {
if (selectedIdRef.current != null) {
void loadDetailRef.current(selectedIdRef.current, false, { silent: true })
}
}, 8000)
return () => {
clearInterval(t)
clearInterval(poll)
}
}, [selected?.id, selected?.status])
@@ -1190,12 +1214,20 @@ const Dashboard = () => {
<div className="min-w-0 leading-tight">
<div className="flex items-center gap-2">
<span className="truncate font-bold text-[14px] text-slate-900">{selectedCustomer.name}</span>
<span className="inline-flex items-center px-2 py-0.5 rounded-md text-[11px] font-medium bg-cyan-50 text-cyan-700 border border-cyan-100">
{channelLabel(selectedCustomer.source)}
<span
className="inline-flex items-center max-w-[260px] truncate px-2 py-0.5 rounded-md text-[11px] font-medium bg-cyan-50 text-cyan-700 border border-cyan-100"
title={[selected.channel_name, selected.site_host || selected.channel_domain].filter(Boolean).join(' · ')}
>
{selected.channel_name || channelLabel(selectedCustomer.source)}
{selected.site_host ? ` · ${selected.site_host}` : ''}
</span>
<span className="inline-flex items-center gap-1 px-2 py-0.5 rounded-md text-[11px] font-medium bg-emerald-50 text-emerald-700 border border-emerald-100">
<span className="inline-block w-1.5 h-1.5 rounded-full bg-emerald-500" />
{selectedCustomer.status === 'online' ? '在线' : selectedCustomer.status === 'busy' ? '忙碌' : '离线'}
<span className={`inline-flex items-center gap-1 px-2 py-0.5 rounded-md text-[11px] font-medium border ${
visitorOnline
? 'bg-emerald-50 text-emerald-700 border-emerald-100'
: 'bg-slate-100 text-slate-500 border-slate-200'
}`}>
<span className={`inline-block w-1.5 h-1.5 rounded-full ${visitorOnline ? 'bg-emerald-500' : 'bg-slate-400'}`} />
{visitorOnline ? '在线' : '离线'}
</span>
</div>
{/* IP | 地区 | 在线时长 */}
@@ -1208,12 +1240,8 @@ const Dashboard = () => {
{selected.visitor_region || '未知地区'}
</span>
<span className="text-slate-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 className="tabular-nums shrink-0" title="访客最后心跳时间">
{visitorOnline ? '最后心跳' : '最后活跃'} {relativeTime(selected.last_seen_at) || '—'}
</span>
</div>
</div>
@@ -1561,9 +1589,9 @@ const Dashboard = () => {
<div className="flex items-center gap-1.5 mt-0.5 text-[11px] text-slate-400">
<span
className="inline-block w-1.5 h-1.5 rounded-full"
style={{ background: selectedCustomer.status === 'online' ? '#16a34a' : '#94a3b8' }}
style={{ background: visitorOnline ? '#16a34a' : '#94a3b8' }}
/>
{selectedCustomer.status === 'online' ? '在线' : selectedCustomer.status === 'busy' ? '忙碌' : '离线'}
{visitorOnline ? '在线' : '离线'}
{selectedCustomer.source ? (
<span className="text-slate-300">· {selectedCustomer.source}</span>
) : null}
@@ -1653,9 +1681,14 @@ const Dashboard = () => {
<span className="text-slate-400 tabular-nums shrink-0 text-[10px]">
{new Date(pv.entered_at).toLocaleTimeString('zh-CN', { hour: '2-digit', minute: '2-digit', second: '2-digit' })}
</span>
{idx === 0 && (
{idx === 0 && visitorOnline && !pv.exited_at && (
<span className="text-[10px] text-blue-600 font-semibold">当前</span>
)}
{!pv.exited_at && idx === 0 && visitorOnline ? null : (
<span className="text-[10px] text-slate-400">
{formatSeconds(pv.duration_seconds) || '—'}
</span>
)}
</div>
<a
href={pv.url}
+29 -12
View File
@@ -266,16 +266,28 @@ const Settings = () => {
}
}
const ensureWebChannel = async () => {
const addWebChannel = async () => {
const count = channels.filter(channel => channel.type === 'web').length + 1
try {
await createChannel({ type: 'web', name: '网页聊天' })
message.success('已创建网页渠道')
await createChannel({ type: 'web', name: `网站 ${count}` })
message.success('已添加网站渠道')
await loadChannels()
} catch (e) {
message.error(e instanceof Error ? e.message : '创建失败')
}
}
const updateChannelMeta = async (id: number, data: { name?: string; site_domain?: string }) => {
try {
const res = await updateChannel(id, data)
setChannels(prev => prev.map(channel => channel.id === id ? res.data : channel))
message.success('网站渠道已更新')
} catch (e) {
message.error(e instanceof Error ? e.message : '更新失败')
throw e
}
}
const addChannelType = async (t: string) => {
try {
await createChannel({ type: t, name: typeMeta[t].label })
@@ -288,14 +300,18 @@ const Settings = () => {
const missingTypes = Object.keys(typeMeta).filter(t => !channels.some(c => c.type === t))
const panelHeaderAction = activeTab === 'channels' && missingTypes.includes('web') ? (
<Button type="primary" size="small" icon={<PlusOutlined />} className="!h-8" onClick={ensureWebChannel}>
开通网页渠道
</Button>
) : activeTab === 'channels' && missingTypes.length > 0 ? (
<Button type="primary" size="small" icon={<PlusOutlined />} className="!h-8" onClick={() => addChannelType(missingTypes[0])}>
添加新渠道
</Button>
const otherMissingTypes = missingTypes.filter(t => t !== 'web')
const panelHeaderAction = activeTab === 'channels' ? (
<div className="flex items-center gap-2">
{otherMissingTypes.length > 0 && (
<Button size="small" icon={<PlusOutlined />} className="!h-8" onClick={() => addChannelType(otherMissingTypes[0])}>
添加{typeMeta[otherMissingTypes[0]].label}
</Button>
)}
<Button type="primary" size="small" icon={<PlusOutlined />} className="!h-8" onClick={addWebChannel}>
{channels.some(channel => channel.type === 'web') ? '添加网站' : '开通网页渠道'}
</Button>
</div>
) : null
const currentGroup = visibleTabGroups.find(g => g.items.some(i => i.key === activeTab))
@@ -391,8 +407,9 @@ const Settings = () => {
missingTypes={missingTypes}
togglingId={togglingId}
toggleChannel={toggleChannel}
ensureWebChannel={ensureWebChannel}
ensureWebChannel={addWebChannel}
addChannelType={addChannelType}
updateChannelMeta={updateChannelMeta}
/>
)}
+2 -2
View File
@@ -327,9 +327,9 @@ const Statistics = () => {
<div className="bg-white rounded-2xl border border-slate-200/80 shadow-xs p-5">
<div className="mb-4">
<h3 className="text-sm font-bold text-slate-800 m-0">渠道来源占比</h3>
<h3 className="text-sm font-bold text-slate-800 m-0">网站 / 渠道来源占比</h3>
<p className="text-xs text-slate-400 m-0 mt-0.5">
各渠道进线分布(共 {channelTotal} 次)
各网站与渠道的进线分布(共 {channelTotal} 次)
</p>
</div>
{channelData.length === 0 || channelTotal === 0 ? (
+20
View File
@@ -130,6 +130,19 @@ export interface SessionDetail {
pendingCount: number
}
export const VISITOR_ONLINE_TIMEOUT_MS = 45_000
export function isVisitorOnline(session: Session, nowMs = Date.now()): boolean {
if (session.status === 'ended' || session.status === 'archived' || !session.last_seen_at) return false
const lastSeen = new Date(session.last_seen_at).getTime()
if (!Number.isFinite(lastSeen) || nowMs - lastSeen > VISITOR_ONLINE_TIMEOUT_MS) return false
if (session.visitor_left_at) {
const leftAt = new Date(session.visitor_left_at).getTime()
if (Number.isFinite(leftAt) && leftAt >= lastSeen) return false
}
return true
}
export function formatOnlineDuration(startIso?: string | null, endIso?: string | null, nowMs?: number): string {
if (!startIso) return '—'
const start = new Date(startIso).getTime()
@@ -144,6 +157,13 @@ export function formatOnlineDuration(startIso?: string | null, endIso?: string |
return `${String(m).padStart(2, '0')}:${String(s).padStart(2, '0')}`
}
export function formatSeconds(seconds?: number | null): string {
if (seconds == null || !Number.isFinite(seconds) || seconds < 0) return ''
const minutes = Math.floor(seconds / 60)
const rest = Math.floor(seconds % 60)
return minutes > 0 ? `${minutes}分${rest}秒` : `${rest}秒`
}
export function shortPagePath(url?: string): string {
if (!url) return '—'
try {
@@ -1,5 +1,6 @@
import { Button, Empty, Spin, Switch, message } from 'antd'
import { CopyOutlined, LinkOutlined, PlusOutlined } from '@ant-design/icons'
import { useState } from 'react'
import { Button, Empty, Form, Input, Modal, Spin, Switch, message } from 'antd'
import { CopyOutlined, EditOutlined, LinkOutlined, PlusOutlined } from '@ant-design/icons'
import type { Channel } from '@/services/api'
import { typeMeta } from './channelMeta'
@@ -25,6 +26,7 @@ interface ChannelsSectionProps {
toggleChannel: (ch: Channel, enabled: boolean) => void
ensureWebChannel: () => void
addChannelType: (t: string) => void
updateChannelMeta: (id: number, data: { name?: string; site_domain?: string }) => Promise<void>
}
/** 渠道管理:接入渠道列表与嵌入代码 */
@@ -36,7 +38,32 @@ const ChannelsSection = ({
toggleChannel,
ensureWebChannel,
addChannelType,
updateChannelMeta,
}: ChannelsSectionProps) => {
const [form] = Form.useForm()
const [editing, setEditing] = useState<Channel | null>(null)
const [saving, setSaving] = useState(false)
const saveSite = async () => {
if (!editing) return
let values: { name?: string; site_domain?: string }
try {
values = await form.validateFields()
} catch {
return
}
setSaving(true)
try {
await updateChannelMeta(editing.id, {
name: String(values.name || '').trim(),
site_domain: String(values.site_domain || '').trim(),
})
setEditing(null)
} finally {
setSaving(false)
}
}
return (
<div>
{loadingChannels ? (
@@ -88,6 +115,23 @@ const ChannelsSection = ({
onChange={v => toggleChannel(ch, v)}
/>
</div>
{ch.type === 'web' && (
<div className="flex items-center gap-2 text-xs mb-2">
<span className="text-slate-400 font-medium">绑定域名</span>
<span className="text-slate-600">{ch.site_domain || '未绑定,可被任意网站使用'}</span>
<Button
type="text"
size="small"
icon={<EditOutlined />}
className="!h-6 !w-6 !p-0"
title="修改网站名称和域名"
onClick={() => {
setEditing(ch)
form.setFieldsValue({ name: ch.name, site_domain: ch.site_domain || '' })
}}
/>
</div>
)}
{meta.desc && (
<p className="text-xs text-slate-400 m-0 mb-3">{meta.desc}</p>
)}
@@ -145,6 +189,33 @@ const ChannelsSection = ({
</div>
</div>
)}
<Modal
title="网站渠道设置"
open={editing !== null}
onCancel={() => setEditing(null)}
onOk={() => { void saveSite() }}
confirmLoading={saving}
okText="保存"
cancelText="取消"
>
<Form form={form} layout="vertical" className="pt-3">
<Form.Item
name="name"
label="网站名称"
rules={[{ required: true, message: '请输入网站名称' }, { max: 50 }]}
>
<Input placeholder="如:官网、商城、活动落地页" />
</Form.Item>
<Form.Item
name="site_domain"
label="绑定域名"
extra="填写后,该渠道只能从对应域名初始化;留空时不限制域名。支持 example.com 或 www.example.com。"
>
<Input placeholder="www.example.com" />
</Form.Item>
</Form>
</Modal>
</div>
)
}
+13 -3
View File
@@ -20,6 +20,13 @@ export interface Session {
end_reason?: string
visitor_ip?: string
visitor_region?: string
visitor_country?: string
visitor_province?: string
visitor_city?: string
site_host?: string
/** 服务端按心跳动态计算 */
visitor_online?: boolean
visitor_left_at?: string | null
user_agent?: string
/** 访客设备指纹(用于设备拉黑) */
device_key?: string
@@ -39,6 +46,7 @@ export interface Session {
agent_name?: string
channel_name?: string
channel_type?: string
channel_domain?: string
}
export interface CustomerContact {
@@ -58,6 +66,8 @@ export interface VisitorPageView {
url: string
title: string
entered_at: string
exited_at?: string | null
duration_seconds?: number
}
export interface Message {
@@ -119,7 +129,7 @@ export interface KnowledgeEntry {
export interface Channel {
id: number; tenant_id: number; type: string; name: string; status: string
config: string; script_code: string; channel_key: string
config: string; script_code: string; channel_key: string; site_domain?: string
}
export interface Tenant {
@@ -567,8 +577,8 @@ export const importQuickReplies = (file: File, scope: QuickReplyScope, onConflic
// Channels
export const getChannels = () => get<Channel[]>('/channels')
export const createChannel = (data: { type: string; name?: string }) => post<Channel>('/channels', data)
export const updateChannel = (id: number, data: { name?: string; status?: string }) => put<Channel>(`/channels/${id}`, data)
export const createChannel = (data: { type: string; name?: string; site_domain?: string }) => post<Channel>('/channels', data)
export const updateChannel = (id: number, data: { name?: string; status?: string; site_domain?: string }) => put<Channel>(`/channels/${id}`, data)
// 坐席账号
export interface StaffUser {
+45 -9
View File
@@ -71,6 +71,10 @@ const VisitorChat = ({
const [pendingImage, setPendingImage] = useState<{ file: File; preview: string } | null>(null)
const [sendError, setSendError] = useState('')
const [agentsOnline, setAgentsOnline] = useState(true)
const [hostVisible, setHostVisible] = useState(
() => !embedded || typeof document === 'undefined' || document.visibilityState === 'visible',
)
const [presencePulse, setPresencePulse] = useState(0)
const [offlinePrompt, setOfflinePrompt] = useState('当前无客服在线,请留言并留下联系方式,我们上线后会尽快回复您。')
const [welcomeSegments, setWelcomeSegments] = useState<WelcomeSegment[]>(defaultWelcomeSegments)
const [displayName, setDisplayName] = useState('在线客服')
@@ -490,7 +494,17 @@ const VisitorChat = ({
} catch { /* ignore */ }
const onMsg = (event: MessageEvent) => {
const data = event?.data
if (!data || data.type !== 'kefu-host-page') return
if (!data) return
if (data.type === 'kefu-host-visibility') {
setHostVisible(Boolean(data.visible))
return
}
if (data.type === 'kefu-host-tick') {
setHostVisible(Boolean(data.visible))
setPresencePulse(value => value + 1)
return
}
if (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
@@ -502,6 +516,14 @@ const VisitorChat = ({
return () => window.removeEventListener('message', onMsg)
}, [embedded, reportPageView])
useEffect(() => {
if (embedded) return
const onVisibility = () => setHostVisible(document.visibilityState === 'visible')
onVisibility()
document.addEventListener('visibilitychange', onVisibility)
return () => document.removeEventListener('visibilitychange', onVisibility)
}, [embedded])
// 非嵌入预览:用当前页作为来源
useEffect(() => {
if (embedded || !sessionId || !visitorToken || sessionEnded) return
@@ -515,23 +537,37 @@ const VisitorChat = ({
void reportPageView(url, title)
}, [embedded, sessionId, visitorToken, sessionEnded, reportPageView])
// 心跳:刷新 last_seen_at,供客服端在线读秒
// 心跳:独立于聊天窗口展开状态,随宿主页可见性上报。
useEffect(() => {
if (!sessionId || !visitorToken || sessionEnded || !open) return
const beat = () => {
if (!sessionId || !visitorToken || sessionEnded) return
const sendPresence = (state: 'visible' | 'hidden' | 'leave', keepalive = false) => {
const body = JSON.stringify({ session_id: sessionId, visitor_token: visitorToken, state })
if (keepalive && typeof navigator.sendBeacon === 'function') {
navigator.sendBeacon('/api/widget/heartbeat', new Blob([body], { type: 'application/json' }))
return
}
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 }),
body,
keepalive,
}).catch(() => {})
}
beat()
const t = window.setInterval(beat, 20000)
return () => clearInterval(t)
}, [sessionId, visitorToken, sessionEnded, open])
sendPresence(hostVisible ? 'visible' : 'hidden')
const t = hostVisible ? window.setInterval(() => sendPresence('visible'), 15000) : null
const onPageHide = () => sendPresence('leave', true)
const onPageShow = () => { if (hostVisible) sendPresence('visible') }
window.addEventListener('pagehide', onPageHide)
window.addEventListener('pageshow', onPageShow)
return () => {
if (t != null) window.clearInterval(t)
window.removeEventListener('pagehide', onPageHide)
window.removeEventListener('pageshow', onPageShow)
}
}, [sessionId, visitorToken, sessionEnded, hostVisible, presencePulse])
// 兜底轮询:按 after_seq 增量对齐
useEffect(() => {