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
+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>
)
}