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) */
|
/** 总览数据(site 传 site_id 可选;range 默认 7d) */
|
||||||
export const getOverview = (params: { site?: string; range?: OverviewRange } = {}) =>
|
export const getOverview = (params: { site?: string; range?: OverviewRange } = {}) =>
|
||||||
request.get<OverviewResp>({ url: '/admin/dashboard/overview', params })
|
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() 结果 + 规则版本/计算时间) */
|
/** 单条来源识别(visitors.py `_classification`:classify() 结果 + 规则版本/计算时间) */
|
||||||
export interface AttributionInfo {
|
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
|
channel: string
|
||||||
/** 平台显示名(Google/ChatGPT/微信…;referral 渠道为来源域名) */
|
/** 平台显示名(Google/ChatGPT/微信…;referral 渠道为来源域名) */
|
||||||
platform: string
|
platform: string
|
||||||
@@ -116,7 +116,8 @@ export interface VisitorDetail extends VisitorRow {
|
|||||||
sessions: SessionSummary[]
|
sessions: SessionSummary[]
|
||||||
}
|
}
|
||||||
|
|
||||||
/** 时间线单事件(`_merge_page_stay` 白名单字段,不透出 properties 原文) */
|
/** 时间线单事件(`_merge_page_stay` 白名单字段,不透出 properties 原文;
|
||||||
|
* 业务事件额外携带 `_safe_fields` 安全字段——均为可选,旧数据/无该字段时不出现) */
|
||||||
export interface TimelineEvent {
|
export interface TimelineEvent {
|
||||||
time: string
|
time: string
|
||||||
event_name: string
|
event_name: string
|
||||||
@@ -124,6 +125,22 @@ export interface TimelineEvent {
|
|||||||
title: string
|
title: string
|
||||||
/** page_view 合并后为累计毫秒(初始 0);独立成条的 page_stay 为毫秒;其余事件为 null */
|
/** page_view 合并后为累计毫秒(初始 0);独立成条的 page_stay 为毫秒;其余事件为 null */
|
||||||
active_ms: number | 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) */
|
/** 时间线单会话分组(session 为 NULL 的孤立事件并成一组:session_id/started_at 均为 null) */
|
||||||
@@ -191,7 +208,7 @@ export interface VisitorListParams {
|
|||||||
show_bot?: '1'
|
show_bot?: '1'
|
||||||
/** visitor_id / last_ip 前缀模糊匹配 */
|
/** visitor_id / last_ip 前缀模糊匹配 */
|
||||||
search?: string
|
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 分类过滤;无分类行的历史访客不属于任何渠道桶) */
|
* 按访客 first_touch 分类过滤;无分类行的历史访客不属于任何渠道桶) */
|
||||||
channel?: string
|
channel?: string
|
||||||
/** YYYY-MM-DD(非法值后端宽松忽略) */
|
/** 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 }> = {
|
export const CHANNEL_LABELS: Record<string, { label: string; icon: string; color: string }> = {
|
||||||
natural_search: { label: '自然搜索', icon: '🔍', color: '#409EFF' },
|
natural_search: { label: '自然搜索', icon: '🔍', color: '#409EFF' },
|
||||||
paid_search: { label: '付费广告', icon: '💰', color: '#F56C6C' },
|
paid_search: { label: '付费广告', icon: '💰', color: '#F56C6C' },
|
||||||
|
paid_social: { label: '付费社交', icon: '📣', color: '#F759AB' }, // 归因 v2:fbclid / fb 家族+付费 medium
|
||||||
ai_referral: { label: 'AI 引用', icon: '🤖', color: '#9B59B6' },
|
ai_referral: { label: 'AI 引用', icon: '🤖', color: '#9B59B6' },
|
||||||
social: { label: '社交媒体', icon: '💬', color: '#E6A23C' },
|
social: { label: '社交媒体', icon: '💬', color: '#E6A23C' },
|
||||||
in_app: { label: '应用内', icon: '📱', color: '#67C23A' },
|
in_app: { label: '应用内', icon: '📱', color: '#67C23A' },
|
||||||
|
|||||||
@@ -86,6 +86,30 @@
|
|||||||
</div>
|
</div>
|
||||||
</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="vd-card db-table-card">
|
||||||
<div class="db-card-head">
|
<div class="db-card-head">
|
||||||
@@ -139,6 +163,7 @@
|
|||||||
import { markRaw } from 'vue'
|
import { markRaw } from 'vue'
|
||||||
import {
|
import {
|
||||||
Clock as ClockIcon,
|
Clock as ClockIcon,
|
||||||
|
Filter as FilterIcon,
|
||||||
Histogram as HistogramIcon,
|
Histogram as HistogramIcon,
|
||||||
Timer as TimerIcon,
|
Timer as TimerIcon,
|
||||||
TrendCharts as TrendChartsIcon,
|
TrendCharts as TrendChartsIcon,
|
||||||
@@ -150,7 +175,9 @@
|
|||||||
import { useChart } from '@/hooks/core/useChart'
|
import { useChart } from '@/hooks/core/useChart'
|
||||||
import { getSites, type SiteRow } from '@/api/tracker/sites'
|
import { getSites, type SiteRow } from '@/api/tracker/sites'
|
||||||
import {
|
import {
|
||||||
|
getFunnelSummary,
|
||||||
getOverview,
|
getOverview,
|
||||||
|
type FunnelSummaryResp,
|
||||||
type OverviewRange,
|
type OverviewRange,
|
||||||
type OverviewResp
|
type OverviewResp
|
||||||
} from '@/api/tracker/dashboard'
|
} from '@/api/tracker/dashboard'
|
||||||
@@ -209,9 +236,10 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// 站点切换:总览与最近访问都按站点过滤,需同时重拉
|
// 站点切换:总览/漏斗与最近访问都按站点过滤,需同时重拉
|
||||||
const onSiteChange = () => {
|
const onSiteChange = () => {
|
||||||
loadOverview()
|
loadOverview()
|
||||||
|
loadFunnel()
|
||||||
loadRecentVisitors()
|
loadRecentVisitors()
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -383,6 +411,41 @@
|
|||||||
const channelLabel = (channel: string): string =>
|
const channelLabel = (channel: string): string =>
|
||||||
CHANNEL_LABELS[channel]?.label ?? channel
|
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 负责)
|
// 数据到位后重绘两张图(nextTick 等容器就绪;卸载销毁由 useChart 的 onUnmounted 负责)
|
||||||
watch(overview, () => {
|
watch(overview, () => {
|
||||||
nextTick(() => {
|
nextTick(() => {
|
||||||
@@ -432,6 +495,7 @@
|
|||||||
booted = true
|
booted = true
|
||||||
loadSites()
|
loadSites()
|
||||||
loadOverview()
|
loadOverview()
|
||||||
|
loadFunnel()
|
||||||
loadRecentVisitors()
|
loadRecentVisitors()
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -442,6 +506,7 @@
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
loadOverview()
|
loadOverview()
|
||||||
|
loadFunnel()
|
||||||
loadRecentVisitors()
|
loadRecentVisitors()
|
||||||
})
|
})
|
||||||
</script>
|
</script>
|
||||||
@@ -648,6 +713,65 @@
|
|||||||
padding: 12px 16px;
|
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 {
|
.db-table-card {
|
||||||
padding-bottom: 12px;
|
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 连接线贯穿 -->
|
<!-- 视觉:客迹 V1——灰色时间 + 36px 浅蓝图标块 + 粗体标题 + 灰色 path + 停留时长,左侧 2px 连接线贯穿 -->
|
||||||
<template>
|
<template>
|
||||||
<div class="evt-timeline">
|
<div class="evt-timeline">
|
||||||
@@ -11,14 +12,16 @@
|
|||||||
</ElIcon>
|
</ElIcon>
|
||||||
</div>
|
</div>
|
||||||
<div class="evt-content">
|
<div class="evt-content">
|
||||||
<!-- 标题行:事件名粗体(page_stay 独立条目带时长) -->
|
<!-- 标题行:事件名粗体(page_stay 独立条目带时长;form_submit_result 按 result_code 分支) -->
|
||||||
<div class="evt-title">
|
<div class="evt-title">
|
||||||
{{ eventMeta(e.event_name).label }}
|
{{ eventLabel(e) }}
|
||||||
<span v-if="e.event_name === 'page_stay'" class="evt-stay">
|
<span v-if="e.event_name === 'page_stay'" class="evt-stay">
|
||||||
<span class="evt-stay-label">停留</span>
|
<span class="evt-stay-label">停留</span>
|
||||||
{{ formatStay(e.active_ms ?? 0) }}
|
{{ formatStay(e.active_ms ?? 0) }}
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</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.path" class="evt-path">{{ e.path }}</div>
|
||||||
<div v-if="e.title" class="evt-page-title">{{ e.title }}</div>
|
<div v-if="e.title" class="evt-page-title">{{ e.title }}</div>
|
||||||
<!-- page_view 合并停留:下一行「停留」灰字 + 时长深色粗体 -->
|
<!-- page_view 合并停留:下一行「停留」灰字 + 时长深色粗体 -->
|
||||||
@@ -33,7 +36,18 @@
|
|||||||
|
|
||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { markRaw, type Component } from 'vue'
|
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 type { TimelineEvent } from '@/api/tracker/visitors'
|
||||||
import { formatStay } from '@/utils/tracker'
|
import { formatStay } from '@/utils/tracker'
|
||||||
|
|
||||||
@@ -47,14 +61,56 @@
|
|||||||
/** ISO(后端上海时间)→ HH:mm */
|
/** ISO(后端上海时间)→ HH:mm */
|
||||||
const formatTime = (iso?: string | null): string => (iso ? iso.slice(11, 16) : '—')
|
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 }> = {
|
const EVENT_META: Record<string, { icon: Component; label: string }> = {
|
||||||
page_view: { icon: markRaw(View), label: '浏览页面' },
|
page_view: { icon: markRaw(View), label: '浏览页面' },
|
||||||
page_stay: { icon: markRaw(Timer), 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 } =>
|
const eventMeta = (name: string): { icon: Component; label: string } =>
|
||||||
EVENT_META[name] ?? { icon: markRaw(InfoFilled), label: name }
|
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>
|
</script>
|
||||||
|
|
||||||
<style lang="scss" scoped>
|
<style lang="scss" scoped>
|
||||||
@@ -135,6 +191,13 @@
|
|||||||
color: #86909c;
|
color: #86909c;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 业务事件副行:白名单安全字段(咨询渠道/表单标识/错误码/产品标识)
|
||||||
|
.evt-subline {
|
||||||
|
margin-top: 2px;
|
||||||
|
font-size: 12px;
|
||||||
|
color: #86909c;
|
||||||
|
}
|
||||||
|
|
||||||
// 停留时长:「停留」灰字 + 时长深色粗体
|
// 停留时长:「停留」灰字 + 时长深色粗体
|
||||||
.evt-stay {
|
.evt-stay {
|
||||||
display: block;
|
display: block;
|
||||||
|
|||||||
@@ -23,6 +23,34 @@ TIMELINE_MAX_EVENTS = 1000
|
|||||||
ONLINE_WINDOW = timedelta(minutes=5) # 与 dashboard active_now 同口径
|
ONLINE_WINDOW = timedelta(minutes=5) # 与 dashboard active_now 同口径
|
||||||
ATTR_SESSION_ENTRIES = 5 # 详情页归因块带出的最近会话入口分类数
|
ATTR_SESSION_ENTRIES = 5 # 详情页归因块带出的最近会话入口分类数
|
||||||
|
|
||||||
|
# 业务事件时间线安全字段白名单(阶段B SDK 0.2.1 事件):按事件名枚举可透出的非 PII 字段
|
||||||
|
# (OT10 白名单内),不在表内的事件/字段一律不透出 properties 原文——隐私边界与
|
||||||
|
# _merge_page_stay 的既有白名单口径一致。submission_id 只透出前 8 位(对账够用)。
|
||||||
|
TIMELINE_SAFE_FIELDS = {
|
||||||
|
"contact_click": ("channel",),
|
||||||
|
"form_start": ("form_id",),
|
||||||
|
"form_submit": ("form_id", "submission_id"),
|
||||||
|
"form_error": ("error_code", "field_count"),
|
||||||
|
"form_submit_result": ("result_code",),
|
||||||
|
"product_view": ("product_id", "inferred"),
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def _safe_fields(e):
|
||||||
|
"""事件行安全字段提取:白名单命中才输出,且只出白名单键(缺失键不占位);
|
||||||
|
form_submit 的 submission_id 截前 8 位。"""
|
||||||
|
keys = TIMELINE_SAFE_FIELDS.get(e.event_name)
|
||||||
|
if not keys:
|
||||||
|
return {}
|
||||||
|
props = e.properties or {}
|
||||||
|
out = {}
|
||||||
|
for k in keys:
|
||||||
|
v = props.get(k)
|
||||||
|
if v is None:
|
||||||
|
continue
|
||||||
|
out[k] = str(v)[:8] if k == "submission_id" else v
|
||||||
|
return out
|
||||||
|
|
||||||
|
|
||||||
def _source(referrer_domain, utm):
|
def _source(referrer_domain, utm):
|
||||||
"""来源三级兜底:referrer_domain → utm.utm_source → 直接访问(访客档案/会话共用)。"""
|
"""来源三级兜底:referrer_domain → utm.utm_source → 直接访问(访客档案/会话共用)。"""
|
||||||
@@ -182,7 +210,8 @@ def _merge_page_stay(events):
|
|||||||
最近一条同 path 的 page_view 条目,把 active_ms 累加进去(同页多段隐藏/恢复多次累加);
|
最近一条同 path 的 page_view 条目,把 active_ms 累加进去(同页多段隐藏/恢复多次累加);
|
||||||
找不到匹配(含 page_stay 早于任何 page_view 出现)则独立成条目(event_name 保持 page_stay)。
|
找不到匹配(含 page_stay 早于任何 page_view 出现)则独立成条目(event_name 保持 page_stay)。
|
||||||
作用域天然限定在本组事件序列(单会话/时间线单会话桶)——跨会话绝不合并。
|
作用域天然限定在本组事件序列(单会话/时间线单会话桶)——跨会话绝不合并。
|
||||||
只出 time/event_name/path/title/active_ms 白名单字段,不透出 properties 原文(隐私)。
|
只出 time/event_name/path/title/active_ms(+业务事件 _safe_fields 白名单字段),
|
||||||
|
不透出 properties 原文(隐私)。
|
||||||
"""
|
"""
|
||||||
items = []
|
items = []
|
||||||
for e in events:
|
for e in events:
|
||||||
@@ -198,8 +227,10 @@ def _merge_page_stay(events):
|
|||||||
else:
|
else:
|
||||||
# page_view 初始 active_ms=0;其余(sdk_loaded/自定义事件)无停留概念 → null
|
# page_view 初始 active_ms=0;其余(sdk_loaded/自定义事件)无停留概念 → null
|
||||||
active = 0 if e.event_name == "page_view" else None
|
active = 0 if e.event_name == "page_view" else None
|
||||||
|
# 业务事件附带 _safe_fields 白名单字段(其余 properties 键不透出)
|
||||||
items.append({"time": _iso(e.occurred_at), "event_name": e.event_name,
|
items.append({"time": _iso(e.occurred_at), "event_name": e.event_name,
|
||||||
"path": e.path, "title": e.title, "active_ms": active})
|
"path": e.path, "title": e.title, "active_ms": active,
|
||||||
|
**_safe_fields(e)})
|
||||||
return items
|
return items
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -48,10 +48,12 @@ def _session(site, visitor, sid, at=NOW, referrer_domain="", utm=None,
|
|||||||
duration_ms=duration_ms, stay_ms=stay_ms)
|
duration_ms=duration_ms, stay_ms=stay_ms)
|
||||||
|
|
||||||
|
|
||||||
def _event(site, visitor, session, eid, name, at, path="", title="", active_ms=None):
|
def _event(site, visitor, session, eid, name, at, path="", title="", active_ms=None,
|
||||||
|
properties=None):
|
||||||
return Event.objects.create(site=site, visitor=visitor, session=session,
|
return Event.objects.create(site=site, visitor=visitor, session=session,
|
||||||
event_id=eid, event_name=name, occurred_at=at, path=path, title=title,
|
event_id=eid, event_name=name, occurred_at=at, path=path, title=title,
|
||||||
properties={"active_ms": active_ms} if active_ms is not None else {})
|
properties=properties if properties is not None else (
|
||||||
|
{"active_ms": active_ms} if active_ms is not None else {}))
|
||||||
|
|
||||||
|
|
||||||
def test_list_fields_and_default_bot_excluded(client, member_user):
|
def test_list_fields_and_default_bot_excluded(client, member_user):
|
||||||
@@ -128,7 +130,9 @@ def test_list_filters_and_search(client, member_user):
|
|||||||
d = client.get("/api/admin/visitors", {"is_new": "1"}).json()["data"] # first_seen 在今天
|
d = client.get("/api/admin/visitors", {"is_new": "1"}).json()["data"] # first_seen 在今天
|
||||||
assert set(ids(d)) == {"cn-desktop", "us-mobile"}
|
assert set(ids(d)) == {"cn-desktop", "us-mobile"}
|
||||||
d = client.get("/api/admin/visitors", {"date_to": YESTERDAY.isoformat()}).json()["data"]
|
d = client.get("/api/admin/visitors", {"date_to": YESTERDAY.isoformat()}).json()["data"]
|
||||||
assert ids(d) == ["old-visitor"] # last_seen 落在昨天
|
# 断言 date_to 的确定边界:昨天者必收、今天者必排除。us-mobile 的 now-2h 在本地
|
||||||
|
# 00:00-02:00 间跑测试会落到昨天(被一并收入)——属时钟归属,不作断言对象(防 flaky)。
|
||||||
|
assert "old-visitor" in ids(d) and "cn-desktop" not in ids(d)
|
||||||
|
|
||||||
|
|
||||||
def test_list_site_fields_and_site_filter(client, member_user):
|
def test_list_site_fields_and_site_filter(client, member_user):
|
||||||
@@ -268,6 +272,57 @@ def test_timeline_day_grouped_and_stay_merge(client, member_user):
|
|||||||
assert y_evs[1]["title"] == "Pricing"
|
assert y_evs[1]["title"] == "Pricing"
|
||||||
|
|
||||||
|
|
||||||
|
def test_timeline_business_event_safe_fields(client, member_user):
|
||||||
|
"""时间线业务事件安全字段白名单(阶段B T6 Step0):六类新事件行各带映射字段
|
||||||
|
(form_submit 的 submission_id 只透前 8 位),其余 properties 键一律不泄漏
|
||||||
|
(含故意塞进去的 PII 键);单会话接口走同一序列化,同样只出白名单。"""
|
||||||
|
_auth(client, member_user)
|
||||||
|
site = Site.objects.create(name="s", domains=["a.com"])
|
||||||
|
v = _visitor(site, "v1")
|
||||||
|
s = _session(site, v, "s1")
|
||||||
|
base = NOW
|
||||||
|
_event(site, v, s, "b1", "contact_click", base, path="/contact",
|
||||||
|
properties={"channel": "whatsapp", "label": " WhatsApp 按钮", "href": "https://wa.me/8613800000000"})
|
||||||
|
_event(site, v, s, "b2", "form_start", base + timedelta(seconds=1),
|
||||||
|
properties={"form_id": "cf7-12", "page_url": "https://a.com/contact"})
|
||||||
|
_event(site, v, s, "b3", "form_submit", base + timedelta(seconds=2),
|
||||||
|
properties={"form_id": "cf7-12", "submission_id": "abcd1234-9999-zzzz",
|
||||||
|
"fields": {"your-name": "张三", "your-email": "z@x.com"}})
|
||||||
|
_event(site, v, s, "b4", "form_error", base + timedelta(seconds=3),
|
||||||
|
properties={"error_code": "validation_failed", "field_count": 2,
|
||||||
|
"messages": ["邮箱格式不对"]})
|
||||||
|
_event(site, v, s, "b5", "form_submit_result", base + timedelta(seconds=4),
|
||||||
|
properties={"result_code": "mail_sent", "smtp_detail": "250 ok"})
|
||||||
|
_event(site, v, s, "b6", "product_view", base + timedelta(seconds=5),
|
||||||
|
properties={"product_id": "excavator-x1", "inferred": True,
|
||||||
|
"url": "https://a.com/product/excavator-x1"})
|
||||||
|
|
||||||
|
r = client.get("/api/admin/visitors/v1/timeline")
|
||||||
|
assert r.status_code == 200
|
||||||
|
evs = r.json()["data"]["days"][0]["sessions"][0]["events"]
|
||||||
|
base_keys = {"time", "event_name", "path", "title", "active_ms"}
|
||||||
|
e1, e2, e3, e4, e5, e6 = evs
|
||||||
|
assert set(e1) == base_keys | {"channel"} and e1["channel"] == "whatsapp"
|
||||||
|
assert set(e2) == base_keys | {"form_id"} and e2["form_id"] == "cf7-12"
|
||||||
|
assert set(e3) == base_keys | {"form_id", "submission_id"}
|
||||||
|
assert e3["submission_id"] == "abcd1234" # 前 8 位截断,尾部不透出
|
||||||
|
assert "abcd1234-9999" not in r.content.decode() # 全量 submission_id 不出现在响应
|
||||||
|
assert set(e4) == base_keys | {"error_code", "field_count"}
|
||||||
|
assert e4["error_code"] == "validation_failed" and e4["field_count"] == 2
|
||||||
|
assert set(e5) == base_keys | {"result_code"} and e5["result_code"] == "mail_sent"
|
||||||
|
assert set(e6) == base_keys | {"product_id", "inferred"}
|
||||||
|
assert e6["product_id"] == "excavator-x1" and e6["inferred"] is True
|
||||||
|
body = r.content.decode()
|
||||||
|
for leak in ("wa.me", "张三", "z@x.com", "邮箱格式不对", "250 ok", "smtp_detail",
|
||||||
|
"page_url", "fields", "messages", "label"):
|
||||||
|
assert leak not in body # 无其他 properties 键泄漏
|
||||||
|
|
||||||
|
d = client.get("/api/admin/visitors/v1/sessions/s1").json()["data"] # 同一序列化路径
|
||||||
|
sub = next(e for e in d["events"] if e["event_name"] == "form_submit")
|
||||||
|
assert sub["submission_id"] == "abcd1234"
|
||||||
|
assert "fields" not in sub
|
||||||
|
|
||||||
|
|
||||||
def test_timeline_days_param_and_404(client, member_user):
|
def test_timeline_days_param_and_404(client, member_user):
|
||||||
_auth(client, member_user)
|
_auth(client, member_user)
|
||||||
site = Site.objects.create(name="s", domains=["a.com"])
|
site = Site.objects.create(name="s", domains=["a.com"])
|
||||||
|
|||||||
Reference in New Issue
Block a user