-
渠道来源占比
+
网站 / 渠道来源占比
- 各渠道进线分布(共 {channelTotal} 次)
+ 各网站与渠道的进线分布(共 {channelTotal} 次)
{channelData.length === 0 || channelTotal === 0 ? (
diff --git a/web/src/pages/agent/dashboard/helpers.ts b/web/src/pages/agent/dashboard/helpers.ts
index 47ce025..7634df5 100644
--- a/web/src/pages/agent/dashboard/helpers.ts
+++ b/web/src/pages/agent/dashboard/helpers.ts
@@ -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 {
diff --git a/web/src/pages/agent/settings/ChannelsSection.tsx b/web/src/pages/agent/settings/ChannelsSection.tsx
index bbf483f..85ff3a9 100644
--- a/web/src/pages/agent/settings/ChannelsSection.tsx
+++ b/web/src/pages/agent/settings/ChannelsSection.tsx
@@ -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
}
/** 渠道管理:接入渠道列表与嵌入代码 */
@@ -36,7 +38,32 @@ const ChannelsSection = ({
toggleChannel,
ensureWebChannel,
addChannelType,
+ updateChannelMeta,
}: ChannelsSectionProps) => {
+ const [form] = Form.useForm()
+ const [editing, setEditing] = useState(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 (
{loadingChannels ? (
@@ -88,6 +115,23 @@ const ChannelsSection = ({
onChange={v => toggleChannel(ch, v)}
/>
+ {ch.type === 'web' && (
+
+ 绑定域名
+ {ch.site_domain || '未绑定,可被任意网站使用'}
+ }
+ className="!h-6 !w-6 !p-0"
+ title="修改网站名称和域名"
+ onClick={() => {
+ setEditing(ch)
+ form.setFieldsValue({ name: ch.name, site_domain: ch.site_domain || '' })
+ }}
+ />
+
+ )}
{meta.desc && (
{meta.desc}
)}
@@ -145,6 +189,33 @@ const ChannelsSection = ({
)}
+
+