feat(admin): 时间线业务事件徽章+询盘漏斗卡+付费社交渠道标签(含server时间线安全字段)
Co-Authored-By: Claude Code <noreply@anthropic.com>
This commit is contained in:
@@ -57,3 +57,21 @@ export interface OverviewResp {
|
||||
/** 总览数据(site 传 site_id 可选;range 默认 7d) */
|
||||
export const getOverview = (params: { site?: string; range?: OverviewRange } = {}) =>
|
||||
request.get<OverviewResp>({ url: '/admin/dashboard/overview', params })
|
||||
|
||||
/** 漏斗级行(label 为服务端基词;末级「邮件已发」前端追加「(未验证)」限定语——T3 审查备忘) */
|
||||
export interface FunnelLevel {
|
||||
/** 级别事件名:page_view/contact_click/form_start/form_submit/form_submit_result */
|
||||
key: string
|
||||
label: string
|
||||
/** 窗口内发生过该事件的 distinct 会话数(五级逐级独立口径,非嵌套) */
|
||||
count: number
|
||||
}
|
||||
|
||||
/** 询盘漏斗响应(五级,无百分比——前端只做计数条渲染) */
|
||||
export interface FunnelSummaryResp {
|
||||
levels: FunnelLevel[]
|
||||
}
|
||||
|
||||
/** 询盘漏斗(days=1 当日 / 7 近 7 天,上海自然日窗口;排除爬虫;site 可选) */
|
||||
export const getFunnelSummary = (days: number, site?: string) =>
|
||||
request.get<FunnelSummaryResp>({ url: '/admin/funnel-summary', params: { days, site } })
|
||||
|
||||
@@ -59,7 +59,7 @@ export interface SessionSummary {
|
||||
|
||||
/** 单条来源识别(visitors.py `_classification`:classify() 结果 + 规则版本/计算时间) */
|
||||
export interface AttributionInfo {
|
||||
/** CHANNELS 枚举:natural_search/paid_search/ai_referral/social/in_app/referral/direct/unknown */
|
||||
/** CHANNELS 枚举:natural_search/paid_search/paid_social/ai_referral/social/in_app/referral/direct/unknown */
|
||||
channel: string
|
||||
/** 平台显示名(Google/ChatGPT/微信…;referral 渠道为来源域名) */
|
||||
platform: string
|
||||
@@ -116,7 +116,8 @@ export interface VisitorDetail extends VisitorRow {
|
||||
sessions: SessionSummary[]
|
||||
}
|
||||
|
||||
/** 时间线单事件(`_merge_page_stay` 白名单字段,不透出 properties 原文) */
|
||||
/** 时间线单事件(`_merge_page_stay` 白名单字段,不透出 properties 原文;
|
||||
* 业务事件额外携带 `_safe_fields` 安全字段——均为可选,旧数据/无该字段时不出现) */
|
||||
export interface TimelineEvent {
|
||||
time: string
|
||||
event_name: string
|
||||
@@ -124,6 +125,22 @@ export interface TimelineEvent {
|
||||
title: string
|
||||
/** page_view 合并后为累计毫秒(初始 0);独立成条的 page_stay 为毫秒;其余事件为 null */
|
||||
active_ms: number | null
|
||||
/** contact_click 咨询渠道:whatsapp / phone / email */
|
||||
channel?: string
|
||||
/** form_start / form_submit 表单标识 */
|
||||
form_id?: string
|
||||
/** form_submit 提交批次号前 8 位(服务端截断,非全量 ID) */
|
||||
submission_id?: string
|
||||
/** form_error 错误码(如 validation_failed) */
|
||||
error_code?: string
|
||||
/** form_error 未通过的表单字段数 */
|
||||
field_count?: number
|
||||
/** form_submit_result 投递结果:mail_sent / mail_failed */
|
||||
result_code?: string
|
||||
/** product_view 产品标识 */
|
||||
product_id?: string
|
||||
/** product_view 是否 URL 推导(非埋点显式上报) */
|
||||
inferred?: boolean
|
||||
}
|
||||
|
||||
/** 时间线单会话分组(session 为 NULL 的孤立事件并成一组:session_id/started_at 均为 null) */
|
||||
@@ -191,7 +208,7 @@ export interface VisitorListParams {
|
||||
show_bot?: '1'
|
||||
/** visitor_id / last_ip 前缀模糊匹配 */
|
||||
search?: string
|
||||
/** 渠道筛选(CHANNELS 枚举:natural_search/paid_search/ai_referral/social/in_app/referral/direct/unknown,
|
||||
/** 渠道筛选(CHANNELS 枚举:natural_search/paid_search/paid_social/ai_referral/social/in_app/referral/direct/unknown,
|
||||
* 按访客 first_touch 分类过滤;无分类行的历史访客不属于任何渠道桶) */
|
||||
channel?: string
|
||||
/** YYYY-MM-DD(非法值后端宽松忽略) */
|
||||
|
||||
@@ -43,6 +43,7 @@ export const countryFlag = (country?: string | null): string =>
|
||||
export const CHANNEL_LABELS: Record<string, { label: string; icon: string; color: string }> = {
|
||||
natural_search: { label: '自然搜索', icon: '🔍', color: '#409EFF' },
|
||||
paid_search: { label: '付费广告', icon: '💰', color: '#F56C6C' },
|
||||
paid_social: { label: '付费社交', icon: '📣', color: '#F759AB' }, // 归因 v2:fbclid / fb 家族+付费 medium
|
||||
ai_referral: { label: 'AI 引用', icon: '🤖', color: '#9B59B6' },
|
||||
social: { label: '社交媒体', icon: '💬', color: '#E6A23C' },
|
||||
in_app: { label: '应用内', icon: '📱', color: '#67C23A' },
|
||||
|
||||
@@ -86,6 +86,30 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 询盘漏斗卡:五级独立会话计数条(无百分比;末级「未验证」限定语前端追加) -->
|
||||
<div v-loading="funnelLoading" class="vd-card db-funnel-card">
|
||||
<div class="db-card-head">
|
||||
<div class="db-card-title">
|
||||
<ElIcon :size="16"><FilterIcon /></ElIcon>
|
||||
<span>询盘漏斗</span>
|
||||
</div>
|
||||
<ElSelect v-model="funnelDays" style="width: 104px" @change="loadFunnel">
|
||||
<ElOption label="当日" :value="1" />
|
||||
<ElOption label="近 7 天" :value="7" />
|
||||
</ElSelect>
|
||||
</div>
|
||||
<div class="db-funnel-body">
|
||||
<div v-for="lv in funnelLevels" :key="lv.key" class="db-funnel-row">
|
||||
<span class="db-funnel-label">{{ lv.label }}</span>
|
||||
<div class="db-funnel-track">
|
||||
<div class="db-funnel-bar" :style="{ width: `${(lv.count / funnelMax) * 100}%` }" />
|
||||
</div>
|
||||
<span class="db-funnel-count">{{ fmtInt(lv.count) }}</span>
|
||||
</div>
|
||||
<div v-if="!funnelLevels.length" class="db-funnel-empty">暂无数据</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 最近访问表 -->
|
||||
<div class="vd-card db-table-card">
|
||||
<div class="db-card-head">
|
||||
@@ -139,6 +163,7 @@
|
||||
import { markRaw } from 'vue'
|
||||
import {
|
||||
Clock as ClockIcon,
|
||||
Filter as FilterIcon,
|
||||
Histogram as HistogramIcon,
|
||||
Timer as TimerIcon,
|
||||
TrendCharts as TrendChartsIcon,
|
||||
@@ -150,7 +175,9 @@
|
||||
import { useChart } from '@/hooks/core/useChart'
|
||||
import { getSites, type SiteRow } from '@/api/tracker/sites'
|
||||
import {
|
||||
getFunnelSummary,
|
||||
getOverview,
|
||||
type FunnelSummaryResp,
|
||||
type OverviewRange,
|
||||
type OverviewResp
|
||||
} from '@/api/tracker/dashboard'
|
||||
@@ -209,9 +236,10 @@
|
||||
}
|
||||
}
|
||||
|
||||
// 站点切换:总览与最近访问都按站点过滤,需同时重拉
|
||||
// 站点切换:总览/漏斗与最近访问都按站点过滤,需同时重拉
|
||||
const onSiteChange = () => {
|
||||
loadOverview()
|
||||
loadFunnel()
|
||||
loadRecentVisitors()
|
||||
}
|
||||
|
||||
@@ -383,6 +411,41 @@
|
||||
const channelLabel = (channel: string): string =>
|
||||
CHANNEL_LABELS[channel]?.label ?? channel
|
||||
|
||||
// ---------- 询盘漏斗(五级独立 distinct 会话计数,无百分比;沿周期卡切换模式) ----------
|
||||
const funnelDays = ref<number>(7) // 1=当日 / 7=近 7 天(上海自然日窗口,与总览同口径)
|
||||
const funnel = ref<FunnelSummaryResp | null>(null)
|
||||
const funnelLoading = ref(false)
|
||||
let funnelSeq = 0
|
||||
|
||||
const loadFunnel = async () => {
|
||||
const seq = ++funnelSeq
|
||||
funnelLoading.value = true
|
||||
try {
|
||||
const resp = await getFunnelSummary(
|
||||
funnelDays.value,
|
||||
selectedSiteId.value === ALL_SITES ? undefined : selectedSiteId.value
|
||||
)
|
||||
if (seq === funnelSeq) funnel.value = resp
|
||||
} catch {
|
||||
// 失败已由请求层统一 ElMessage 提示;funnel 置空走空态
|
||||
if (seq === funnelSeq) funnel.value = null
|
||||
} finally {
|
||||
if (seq === funnelSeq) funnelLoading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
/** 漏斗级行:末级追加「(未验证)」限定语——服务端基词只到「邮件已发」,
|
||||
* CF7 投递结果无回执验证,前端如实标注(T3 审查备忘) */
|
||||
const funnelLevels = computed(() =>
|
||||
(funnel.value?.levels ?? []).map((l) => ({
|
||||
...l,
|
||||
label: l.key === 'form_submit_result' ? `${l.label}(未验证)` : l.label
|
||||
}))
|
||||
)
|
||||
|
||||
/** 条长基准:窗口内最大级计数(全零取 1 防除零——条全空,计数照常显示 0) */
|
||||
const funnelMax = computed(() => Math.max(1, ...funnelLevels.value.map((l) => l.count)))
|
||||
|
||||
// 数据到位后重绘两张图(nextTick 等容器就绪;卸载销毁由 useChart 的 onUnmounted 负责)
|
||||
watch(overview, () => {
|
||||
nextTick(() => {
|
||||
@@ -432,6 +495,7 @@
|
||||
booted = true
|
||||
loadSites()
|
||||
loadOverview()
|
||||
loadFunnel()
|
||||
loadRecentVisitors()
|
||||
}
|
||||
|
||||
@@ -442,6 +506,7 @@
|
||||
return
|
||||
}
|
||||
loadOverview()
|
||||
loadFunnel()
|
||||
loadRecentVisitors()
|
||||
})
|
||||
</script>
|
||||
@@ -648,6 +713,65 @@
|
||||
padding: 12px 16px;
|
||||
}
|
||||
|
||||
// 询盘漏斗卡:五级横向计数条(条长按各级计数相对最大级;无百分比文案)
|
||||
.db-funnel-card {
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
|
||||
.db-funnel-body {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
padding: 18px 20px;
|
||||
gap: 14px;
|
||||
}
|
||||
|
||||
.db-funnel-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.db-funnel-label {
|
||||
width: 128px;
|
||||
flex: none;
|
||||
font-size: 13px;
|
||||
color: $vd-text-2;
|
||||
text-align: right;
|
||||
}
|
||||
|
||||
.db-funnel-track {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
height: 14px;
|
||||
overflow: hidden;
|
||||
background: var(--el-fill-color-light);
|
||||
border-radius: 7px;
|
||||
}
|
||||
|
||||
.db-funnel-bar {
|
||||
height: 100%;
|
||||
background: $vd-primary;
|
||||
border-radius: 7px;
|
||||
transition: width 0.3s ease;
|
||||
}
|
||||
|
||||
.db-funnel-count {
|
||||
width: 48px;
|
||||
flex: none;
|
||||
font-size: 13px;
|
||||
font-weight: 700;
|
||||
color: $vd-text;
|
||||
text-align: right;
|
||||
font-variant-numeric: tabular-nums;
|
||||
}
|
||||
|
||||
.db-funnel-empty {
|
||||
padding: 20px 0;
|
||||
font-size: 13px;
|
||||
color: $vd-text-3;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
// 最近访问表卡
|
||||
.db-table-card {
|
||||
padding-bottom: 12px;
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
<!-- 访客统计 · 事件时间线(详情页访问时间线与会话抽屉共用) -->
|
||||
<!-- 事件行三分支:page_view(浏览+停留)/ page_stay 独立条目 / sdk_loaded 及自定义事件 -->
|
||||
<!-- 事件行三分支:page_view(浏览+停留)/ page_stay 独立条目 / sdk_loaded 及自定义事件; -->
|
||||
<!-- 阶段B 业务事件(咨询点击/表单状态机/产品查看)带白名单安全字段副行(服务端 _safe_fields) -->
|
||||
<!-- 视觉:客迹 V1——灰色时间 + 36px 浅蓝图标块 + 粗体标题 + 灰色 path + 停留时长,左侧 2px 连接线贯穿 -->
|
||||
<template>
|
||||
<div class="evt-timeline">
|
||||
@@ -11,14 +12,16 @@
|
||||
</ElIcon>
|
||||
</div>
|
||||
<div class="evt-content">
|
||||
<!-- 标题行:事件名粗体(page_stay 独立条目带时长) -->
|
||||
<!-- 标题行:事件名粗体(page_stay 独立条目带时长;form_submit_result 按 result_code 分支) -->
|
||||
<div class="evt-title">
|
||||
{{ eventMeta(e.event_name).label }}
|
||||
{{ eventLabel(e) }}
|
||||
<span v-if="e.event_name === 'page_stay'" class="evt-stay">
|
||||
<span class="evt-stay-label">停留</span>
|
||||
{{ formatStay(e.active_ms ?? 0) }}
|
||||
</span>
|
||||
</div>
|
||||
<!-- 业务事件副行:服务端白名单安全字段(渠道/表单标识/错误码等) -->
|
||||
<div v-if="eventSubline(e)" class="evt-subline">{{ eventSubline(e) }}</div>
|
||||
<div v-if="e.path" class="evt-path">{{ e.path }}</div>
|
||||
<div v-if="e.title" class="evt-page-title">{{ e.title }}</div>
|
||||
<!-- page_view 合并停留:下一行「停留」灰字 + 时长深色粗体 -->
|
||||
@@ -33,7 +36,18 @@
|
||||
|
||||
<script setup lang="ts">
|
||||
import { markRaw, type Component } from 'vue'
|
||||
import { Cpu, InfoFilled, Timer, View } from '@element-plus/icons-vue'
|
||||
import {
|
||||
ChatDotRound,
|
||||
Cpu,
|
||||
DocumentChecked,
|
||||
EditPen,
|
||||
Goods,
|
||||
InfoFilled,
|
||||
Message,
|
||||
Timer,
|
||||
View,
|
||||
WarningFilled
|
||||
} from '@element-plus/icons-vue'
|
||||
import type { TimelineEvent } from '@/api/tracker/visitors'
|
||||
import { formatStay } from '@/utils/tracker'
|
||||
|
||||
@@ -47,14 +61,56 @@
|
||||
/** ISO(后端上海时间)→ HH:mm */
|
||||
const formatTime = (iso?: string | null): string => (iso ? iso.slice(11, 16) : '—')
|
||||
|
||||
/** 事件行图标/标题:page_view=浏览页面、page_stay=页面停留、sdk_loaded=SDK 加载、其余兜底 */
|
||||
/** 事件行图标/标题:页面三件套 + 阶段B 业务六事件,其余(含未知自定义事件)兜底原事件名 */
|
||||
const EVENT_META: Record<string, { icon: Component; label: string }> = {
|
||||
page_view: { icon: markRaw(View), label: '浏览页面' },
|
||||
page_stay: { icon: markRaw(Timer), label: '页面停留' },
|
||||
sdk_loaded: { icon: markRaw(Cpu), label: 'SDK 加载' }
|
||||
sdk_loaded: { icon: markRaw(Cpu), label: 'SDK 加载' },
|
||||
contact_click: { icon: markRaw(ChatDotRound), label: '咨询点击' },
|
||||
form_start: { icon: markRaw(EditPen), label: '表单开始' },
|
||||
form_submit: { icon: markRaw(DocumentChecked), label: '表单提交' },
|
||||
form_error: { icon: markRaw(WarningFilled), label: '表单校验未过' },
|
||||
form_submit_result: { icon: markRaw(Message), label: '邮件发送结果' },
|
||||
product_view: { icon: markRaw(Goods), label: '产品查看' }
|
||||
}
|
||||
const eventMeta = (name: string): { icon: Component; label: string } =>
|
||||
EVENT_META[name] ?? { icon: markRaw(InfoFilled), label: name }
|
||||
|
||||
/** 事件行标题:form_submit_result 按 result_code 分支(server 只给基词,限定语前端补) */
|
||||
const eventLabel = (e: TimelineEvent): string => {
|
||||
if (e.event_name === 'form_submit_result') {
|
||||
if (e.result_code === 'mail_sent') return '邮件已发(未验证)'
|
||||
if (e.result_code === 'mail_failed') return '发送失败'
|
||||
}
|
||||
return eventMeta(e.event_name).label
|
||||
}
|
||||
|
||||
/** contact_click 咨询渠道中文(未枚举值原样显示) */
|
||||
const CONTACT_CHANNEL_LABELS: Record<string, string> = {
|
||||
whatsapp: 'WhatsApp',
|
||||
phone: '电话',
|
||||
email: '邮件'
|
||||
}
|
||||
|
||||
/** 业务事件副行:服务端白名单安全字段拼显示串(空串 → 不渲染该行) */
|
||||
const eventSubline = (e: TimelineEvent): string => {
|
||||
switch (e.event_name) {
|
||||
case 'contact_click':
|
||||
return (e.channel && (CONTACT_CHANNEL_LABELS[e.channel] ?? e.channel)) || ''
|
||||
case 'form_start':
|
||||
return e.form_id || ''
|
||||
case 'form_submit':
|
||||
return e.submission_id || ''
|
||||
case 'form_error':
|
||||
return [e.error_code, e.field_count != null ? `${e.field_count} 个字段` : '']
|
||||
.filter(Boolean)
|
||||
.join(' · ')
|
||||
case 'product_view':
|
||||
return [e.product_id, e.inferred ? 'URL推导' : ''].filter(Boolean).join(' · ')
|
||||
default:
|
||||
return ''
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
@@ -135,6 +191,13 @@
|
||||
color: #86909c;
|
||||
}
|
||||
|
||||
// 业务事件副行:白名单安全字段(咨询渠道/表单标识/错误码/产品标识)
|
||||
.evt-subline {
|
||||
margin-top: 2px;
|
||||
font-size: 12px;
|
||||
color: #86909c;
|
||||
}
|
||||
|
||||
// 停留时长:「停留」灰字 + 时长深色粗体
|
||||
.evt-stay {
|
||||
display: block;
|
||||
|
||||
Reference in New Issue
Block a user