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