feat(widget): 优化访客在线轨迹、GeoIP与多站点渠道
This commit is contained in:
+31
-2
@@ -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');
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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}
|
||||
/>
|
||||
)}
|
||||
|
||||
|
||||
@@ -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 ? (
|
||||
|
||||
@@ -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
@@ -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 {
|
||||
|
||||
@@ -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(() => {
|
||||
|
||||
Reference in New Issue
Block a user