feat(admin): 时间线业务事件徽章+询盘漏斗卡+付费社交渠道标签(含server时间线安全字段)

Co-Authored-By: Claude Code <noreply@anthropic.com>
This commit is contained in:
浅念
2026-09-19 00:12:13 +08:00
parent 8d7fc99ef6
commit c9967b6d70
7 changed files with 324 additions and 15 deletions
+18
View File
@@ -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 } })
+20 -3
View File
@@ -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(非法值后端宽松忽略) */
+1
View File
@@ -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' },
+125 -1
View File
@@ -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;