feat(admin): 数据总览按客迹视觉V1重做(周期卡/趋势双线/渠道条形/最近访问)

Co-Authored-By: Claude Code <noreply@anthropic.com>
This commit is contained in:
浅念
2026-09-17 12:18:37 +08:00
parent 132e2feb75
commit 8f20d8d9a2
8 changed files with 955 additions and 300 deletions
+33 -33
View File
@@ -1,59 +1,59 @@
import request from '@/utils/http' import request from '@/utils/http'
/** /**
* 总览 API(server/tracker/api/dashboard.py) * 总览 API(server/tracker/api/dashboard.py,客迹视觉 V1 形状)
* *
* 今日四指标与 refresh_daily 共享口径(daily_counts:排除爬虫,上海自然日); * 周期口径全排除爬虫;site 参数传 site_id,不传 = 聚合全部站点
* site 参数传 site_id,不传 = 聚合全部站点(站点不存在返回 404 site_not_found)。 * (站点不存在返回 404 site_not_found);range 非法值后端回落 7d。
*/ */
/** 今日四指标 + 当前在线(5 分钟内有行为的非爬虫访客数) */ /** 周期枚举:today=上海自然日;7d/30d=近 N 天窗口 */
export interface TodayCounts { export type OverviewRange = 'today' | '7d' | '30d'
pv: number
uv: number /** 页头在线信息(active_now 是 online 的兼容别名,二者恒相等) */
sessions: number export interface OverviewHeader {
new_visitors: number online: number
active_now: number active_now: number
} }
/** 近 30 天趋势点(缺日补零,今天一位用实时值覆盖;每日含全部四指标) */ /** 周期卡指标;delta_pct = 与上一个等长周期比较的百分比(today 比昨天),
* 上期值为 0 时为 null(前端显示 —) */
export interface CardMetric {
value: number
delta_pct: number | null
}
/** 四张周期卡:访客数 / 访问次数 / 平均有效停留(ms) / 回访访客 */
export interface OverviewCards {
visitors: CardMetric
visits: CardMetric
avg_stay_ms: CardMetric
returning: CardMetric
}
/** 趋势点(日粒度:历史位为 DailyStat 聚合、缺日补零,今天一位为实时值) */
export interface TrendPoint { export interface TrendPoint {
date: string date: string
pv: number
uv: number uv: number
sessions: number sessions: number
new_visitors: number
} }
/** 今日来源 TOP8(referrer_domain 为空归并为"直接访问") */ /** 渠道聚合行:channel 为 CHANNELS 枚举(含 direct/unknown);
export interface SourceItem { * pct = 占已分类会话比(4 位小数,列表内总和 ≈ 1;无分类行的历史会话不计入) */
source: string
sessions: number
}
/** 今日渠道构成 TOP8(session_entry 分类聚合,sessions 去重计数;
* channel 为 CHANNELS 枚举,无分类行不计入——未重算的历史会话不在此口径内) */
export interface ChannelItem { export interface ChannelItem {
channel: string channel: string
sessions: number sessions: number
} pct: number
/** 今日热门页面 TOP8(page_view 按 path 聚合) */
export interface PageItem {
path: string
pv: number
} }
/** 总览响应 */ /** 总览响应 */
export interface OverviewResp { export interface OverviewResp {
today: TodayCounts header: OverviewHeader
cards: OverviewCards
trend: TrendPoint[] trend: TrendPoint[]
sources: SourceItem[]
/** 来源渠道聚合(sources 的归因升级版,原始域名口径保留在 sources) */
channels: ChannelItem[] channels: ChannelItem[]
top_pages: PageItem[]
} }
/** 总览数据(site 可选,传 site_id) */ /** 总览数据(site 传 site_id 可选;range 默认 7d) */
export const getOverview = (site?: string) => export const getOverview = (params: { site?: string; range?: OverviewRange } = {}) =>
request.get<OverviewResp>({ url: '/admin/dashboard/overview', params: site ? { site } : {} }) request.get<OverviewResp>({ url: '/admin/dashboard/overview', params })
+2
View File
@@ -34,6 +34,8 @@ export interface VisitorRow {
last_source: string last_source: string
/** 首次访问落地页(空串=未采集,如直达首页;列表 tooltip / 详情识别卡用) */ /** 首次访问落地页(空串=未采集,如直达首页;列表 tooltip / 详情识别卡用) */
first_landing_path: string first_landing_path: string
/** 最近一条事件的 path(总览「浏览页面」列;无事件访客为空串) */
last_path: string
/** 首触来源归因(first_touch 分类;无分类行/未重算的历史访客为 '',前端回落 first_source 展示) */ /** 首触来源归因(first_touch 分类;无分类行/未重算的历史访客为 '',前端回落 first_source 展示) */
channel: string channel: string
platform: string platform: string
+23
View File
@@ -80,6 +80,29 @@ export function channelBadge(row: ChannelBadgeRow): string {
export const formatDateTime = (iso?: string | null): string => export const formatDateTime = (iso?: string | null): string =>
iso ? iso.replace('T', ' ').slice(0, 16) : '—' iso ? iso.replace('T', ' ').slice(0, 16) : '—'
/** 上海自然日今天(YYYY-MM-DD;后端 ISO 串同为上海时间,日期部分可直接比对) */
const shanghaiDate = (): string =>
new Intl.DateTimeFormat('en-CA', { timeZone: 'Asia/Shanghai' }).format(new Date())
/** YYYY-MM-DD 平移 n 天(按 UTC 毫秒运算,不受本地时区夏令时干扰) */
const shiftDate = (date: string, days: number): string => {
const d = new Date(`${date}T00:00:00Z`)
d.setUTCDate(d.getUTCDate() + days)
return d.toISOString().slice(0, 10)
}
/** ISO 时间 → 智能短时间:今天 HH:mm / 昨天 HH:mm / 更早 MM-DD HH:mm(空值 —)
* 后端已是上海时间,日期部分与 shanghaiDate 同口径比对,不做时区换算 */
export const formatSmartTime = (iso?: string | null): string => {
if (!iso) return '—'
const date = iso.slice(0, 10)
const time = iso.slice(11, 16)
const today = shanghaiDate()
if (date === today) return `今天 ${time}`
if (date === shiftDate(today, -1)) return `昨天 ${time}`
return `${date.slice(5)} ${time}`
}
/** 毫秒 → x天x小时x分x秒(智能省略:为 0 的单位不显示;全 0 / 不足 1 秒显示 0秒) */ /** 毫秒 → x天x小时x分x秒(智能省略:为 0 的单位不显示;全 0 / 不足 1 秒显示 0秒) */
export const formatStay = (ms?: number | null): string => { export const formatStay = (ms?: number | null): string => {
const totalSec = Math.max(0, Math.floor((ms ?? 0) / 1000)) const totalSec = Math.max(0, Math.floor((ms ?? 0) / 1000))
+633 -142
View File
@@ -1,19 +1,23 @@
<!-- 访客统计 · 数据总览 --> <!-- 访客统计 · 数据总览(客迹视觉 V1:周期四卡 + 趋势双线 + 渠道条形 + 最近访问) -->
<template> <template>
<div class="tracker-dashboard-page"> <div class="tracker-dashboard-page p-5">
<!-- 筛选栏:站点切换 + 刷新 --> <!-- 页头:标题/副标题 + 在线胶囊 + 站点/周期筛选 -->
<div class="art-card mb-4 flex-c flex-wrap gap-3 px-5 py-4"> <div class="db-header">
<ArtSvgIcon icon="ri:bar-chart-box-line" class="text-lg text-theme" /> <div class="db-header-main">
<h2 class="m-0 text-base font-semibold">数据总览</h2> <h2 class="db-title">数据概览</h2>
<span class="text-xs text-g-500">今日指标为上海自然日口径,已排除爬虫</span> <p class="db-subtitle">了解每一次访问,连接每一个触点</p>
<div class="ml-auto flex-c gap-2"> </div>
<span class="text-sm text-g-600">站点</span> <div class="db-header-tools">
<span class="db-online" :class="{ 'is-online': onlineCount > 0 }">
<i class="db-online-dot" />
在线 {{ onlineCount }}
</span>
<!-- 宽度用内联 style:EP 未分层 width 规则会压过 Tailwind utilities 层的 w-* 类 --> <!-- 宽度用内联 style:EP 未分层 width 规则会压过 Tailwind utilities 层的 w-* 类 -->
<ElSelect <ElSelect
v-model="selectedSiteId" v-model="selectedSiteId"
style="width: 256px" style="width: 220px"
:loading="sitesLoading" :loading="sitesLoading"
@change="loadOverview" @change="onSiteChange"
> >
<ElOption label="全部站点" value="ALL_SITES" /> <ElOption label="全部站点" value="ALL_SITES" />
<ElOption <ElOption
@@ -23,120 +27,153 @@
:value="site.site_id" :value="site.site_id"
/> />
</ElSelect> </ElSelect>
<ElTooltip content="刷新数据" placement="top"> <ElSelect v-model="range" style="width: 132px" @change="loadOverview">
<ElButton circle :icon="RefreshIcon" :loading="overviewLoading" @click="refreshAll" /> <ElOption label="今天" value="today" />
</ElTooltip> <ElOption label="近 7 天" value="7d" />
<ElOption label="近 30 天" value="30d" />
</ElSelect>
</div> </div>
</div> </div>
<!-- 今日五指标 --> <!-- 4 周期统计卡 -->
<div <div v-loading="overviewLoading" class="db-stats">
v-loading="overviewLoading" <div v-for="card in statCards" :key="card.label" class="vd-card db-stat-card">
class="grid grid-cols-2 gap-4 mb-4 md:grid-cols-3 xl:grid-cols-5" <div class="db-stat-top">
<div class="db-stat-icon">
<ElIcon :size="20">
<component :is="card.icon" />
</ElIcon>
</div>
<span class="db-stat-label">{{ card.label }}</span>
</div>
<span class="db-stat-value">{{ card.value }}</span>
<div class="db-stat-trend">
<span v-if="card.delta === null" class="db-delta is-none">—</span>
<span
v-else
class="db-delta"
:class="card.delta >= 0 ? 'is-up' : 'is-down'"
> >
<div {{ card.delta >= 0 ? '↑' : '↓' }}{{ Math.abs(card.delta) }}%
v-for="card in statCards" </span>
:key="card.label" <span class="db-delta-caption">较上一周期</span>
class="art-card relative h-30 flex flex-col justify-center px-5" </div>
</div>
</div>
<!-- 双栏图表行:访问趋势(2/3) + 来源分布(1/3) -->
<div class="db-charts">
<div class="vd-card db-chart-card">
<div class="db-card-head">
<div class="db-card-title">
<ElIcon :size="16"><TrendChartsIcon /></ElIcon>
<span>访问趋势</span>
</div>
<span class="db-card-hint">{{ RANGE_HINTS[range] }},今日为实时值</span>
</div>
<div ref="trendRef" class="db-chart-body" />
</div>
<div class="vd-card db-chart-card">
<div class="db-card-head">
<div class="db-card-title">
<ElIcon :size="16"><HistogramIcon /></ElIcon>
<span>来源分布</span>
</div>
<span class="db-card-hint">按会话入口归因</span>
</div>
<div ref="channelRef" class="db-chart-body" />
</div>
</div>
<!-- 最近访问表 -->
<div class="vd-card db-table-card">
<div class="db-card-head">
<div class="db-card-title">
<ElIcon :size="16"><ClockIcon /></ElIcon>
<span>最近访问</span>
</div>
<ElButton link type="primary" @click="goVisitorList">查看更多 →</ElButton>
</div>
<ElTable
:data="recentVisitors"
v-loading="visitorsLoading"
empty-text="暂无访客数据"
row-class-name="db-clickable-row"
@row-click="openVisitor"
> >
<span class="text-sm text-g-600">{{ card.label }}</span> <ElTableColumn label="访客" min-width="210">
<ArtCountTo class="mt-1.5 text-[26px] font-medium" :target="card.value" :duration="1300" />
<div class="absolute top-0 bottom-0 right-5 m-auto size-11 rounded-lg flex-cc bg-theme/10">
<ArtSvgIcon :icon="card.icon" class="text-lg text-theme" />
</div>
</div>
</div>
<!-- 近 30 天趋势 -->
<div class="art-card mb-4 px-5 py-4">
<div class="flex-c flex-wrap gap-2 mb-2">
<h3 class="m-0 text-sm font-semibold">近 30 天趋势</h3>
<span class="text-xs text-g-500">今日为实时值,历史为每日聚合</span>
</div>
<ArtLineChart
:data="trendSeries"
:x-axis-data="trendDates"
:colors="lineColors"
:loading="overviewLoading"
height="22rem"
show-legend
legend-position="top"
/>
</div>
<!-- 来源构成 + 热门页面 -->
<div class="grid grid-cols-1 gap-4 xl:grid-cols-2">
<div class="art-card px-5 py-4">
<div class="flex-c flex-wrap gap-2 mb-2">
<h3 class="m-0 text-sm font-semibold">渠道构成(今日 TOP8)</h3>
<span class="text-xs text-g-500">按归因规则聚合会话数,未重算的历史会话不计入</span>
</div>
<ArtRingChart
:data="channelData"
:colors="channelColors"
:loading="overviewLoading"
height="15rem"
show-legend
legend-position="right"
/>
<!-- 平台明细:原始 referrer 域名口径(与渠道归因互补,域名级下钻) -->
<div class="mt-4" v-loading="overviewLoading">
<div class="flex-c flex-wrap gap-2 mb-2">
<h4 class="m-0 text-xs font-semibold text-g-600">平台明细(今日 TOP8)</h4>
<span class="text-xs text-g-500">无 referrer 归为"直接访问"</span>
</div>
<ElTable :data="platformSources" size="small" max-height="240">
<ElTableColumn prop="source" label="来源域名" min-width="160" show-overflow-tooltip>
<template #default="{ row }"> <template #default="{ row }">
<span class="text-xs">{{ row.source }}</span> <div class="db-visitor-cell">
<code class="db-visitor-code">{{ row.visitor_id.slice(0, 8) }}</code>
<span
v-if="row.channel"
class="db-badge"
:style="badgeStyle(row)"
>{{ channelBadge(row) }}</span>
</div>
</template> </template>
</ElTableColumn> </ElTableColumn>
<ElTableColumn prop="sessions" label="会话数" width="90" align="right" /> <ElTableColumn label="本次来源" min-width="150" show-overflow-tooltip>
</ElTable>
</div>
</div>
<div class="art-card px-5 py-4" v-loading="overviewLoading">
<div class="flex-c flex-wrap gap-2 mb-2">
<h3 class="m-0 text-sm font-semibold">热门页面(今日 TOP8)</h3>
<span class="text-xs text-g-500">按 PV 统计</span>
</div>
<ElTable :data="topPages" height="20rem">
<ElTableColumn type="index" label="#" width="50" />
<ElTableColumn prop="path" label="页面路径" show-overflow-tooltip min-width="200">
<template #default="{ row }"> <template #default="{ row }">
<span class="font-mono text-xs">{{ row.path }}</span> <span class="db-source-text">{{ row.last_source || '—' }}</span>
</template> </template>
</ElTableColumn> </ElTableColumn>
<ElTableColumn prop="pv" label="PV" width="80" align="right" sortable /> <ElTableColumn label="浏览页面" min-width="200" show-overflow-tooltip>
<template #default="{ row }">
<span class="db-path-text">{{ row.last_path || '—' }}</span>
</template>
</ElTableColumn>
<ElTableColumn label="有效停留" width="130" align="right">
<template #default="{ row }">{{ formatStay(row.total_stay_ms) }}</template>
</ElTableColumn>
<ElTableColumn label="最近访问" width="130">
<template #default="{ row }">{{ formatSmartTime(row.last_seen_at) }}</template>
</ElTableColumn>
</ElTable> </ElTable>
</div> </div>
</div> </div>
</div>
</template> </template>
<script setup lang="ts"> <script setup lang="ts">
import { Refresh as RefreshIcon } from '@element-plus/icons-vue' import { markRaw } from 'vue'
import { useChartOps } from '@/hooks/core/useChart' import {
Clock as ClockIcon,
Histogram as HistogramIcon,
Timer as TimerIcon,
TrendCharts as TrendChartsIcon,
User as UserIcon,
UserFilled as UserFilledIcon,
VideoPlay as VideoPlayIcon
} from '@element-plus/icons-vue'
import { graphic } from '@/plugins/echarts'
import { useChart } from '@/hooks/core/useChart'
import { getSites, type SiteRow } from '@/api/tracker/sites' import { getSites, type SiteRow } from '@/api/tracker/sites'
import { getOverview, type OverviewResp } from '@/api/tracker/dashboard' import {
import { CHANNEL_LABELS } from '@/utils/tracker' getOverview,
type OverviewRange,
type OverviewResp
} from '@/api/tracker/dashboard'
import { getVisitors, type VisitorRow } from '@/api/tracker/visitors'
import { CHANNEL_LABELS, channelBadge, formatSmartTime, formatStay } from '@/utils/tracker'
defineOptions({ name: 'TrackerDashboard' }) defineOptions({ name: 'TrackerDashboard' })
/** 图表色板:模板主题色为主,剔除过浅色与重复色(useChartOps 默认第 3 位 #EDF2FF 在浅色底不可见) */ // 客迹视觉 V1 图表用色(双线:主蓝 + 青;与详情页色板同源)
const themeColor = useChartOps().themeColor const TREND_COLORS = { uv: '#2563EB', sessions: '#06B6D4' }
const lineColors = [themeColor, '#4ABEFF', '#14DEBA', '#FFAF20'] const RANGE_HINTS: Record<OverviewRange, string> = {
today: '今天',
'7d': '近 7 天',
'30d': '近 30 天'
}
// 站点筛选(哨兵值 ALL_SITES = 全部站点;空串会被 ElSelect 当空值显示 placeholder) // ---------- 站点 / 周期筛选 ----------
// 哨兵值 ALL_SITES = 全部站点(空串会被 ElSelect 当空值显示 placeholder)
const ALL_SITES = 'ALL_SITES' const ALL_SITES = 'ALL_SITES'
const sites = ref<SiteRow[]>([]) const sites = ref<SiteRow[]>([])
const sitesLoading = ref(false) const sitesLoading = ref(false)
const selectedSiteId = ref(ALL_SITES) const selectedSiteId = ref(ALL_SITES)
const range = ref<OverviewRange>('7d')
// 总览数据
const overview = ref<OverviewResp | null>(null)
const overviewLoading = ref(false)
const loadSites = async () => { const loadSites = async () => {
sitesLoading.value = true sitesLoading.value = true
@@ -149,74 +186,528 @@
} }
} }
// 请求令牌:快速切换站点时丢弃过期响应,避免旧数据覆盖新数据/旧 finally 提前清 loading // ---------- 总览(周期卡 + 趋势 + 渠道) ----------
const overview = ref<OverviewResp | null>(null)
const overviewLoading = ref(false)
// 请求令牌:快速切周期/站点时丢弃过期响应,避免旧数据覆盖新数据/旧 finally 提前清 loading
let reqSeq = 0 let reqSeq = 0
const loadOverview = async () => { const loadOverview = async () => {
const id = ++reqSeq const id = ++reqSeq
overviewLoading.value = true overviewLoading.value = true
try { try {
const resp = await getOverview( const resp = await getOverview({
selectedSiteId.value === ALL_SITES ? undefined : selectedSiteId.value site: selectedSiteId.value === ALL_SITES ? undefined : selectedSiteId.value,
) range: range.value
})
if (id === reqSeq) overview.value = resp if (id === reqSeq) overview.value = resp
} catch { } catch {
// 失败已由请求层统一 ElMessage 提示;overview 置空让图表/表格走空态 // 失败已由请求层统一 ElMessage 提示;overview 置空走空态
if (id === reqSeq) overview.value = null if (id === reqSeq) overview.value = null
} finally { } finally {
if (id === reqSeq) overviewLoading.value = false if (id === reqSeq) overviewLoading.value = false
} }
} }
const refreshAll = () => { // 站点切换:总览与最近访问都按站点过滤,需同时重拉
loadSites() const onSiteChange = () => {
loadOverview() loadOverview()
loadRecentVisitors()
} }
onMounted(() => { const onlineCount = computed(() => overview.value?.header.online ?? 0)
loadSites()
loadOverview()
})
// 今日五指标卡 /** 千分位(访客数/访问次数/回访访客) */
const statCards = computed(() => [ const fmtInt = (n: number) => n.toLocaleString('en-US')
{ label: '今日 PV', value: overview.value?.today.pv ?? 0, icon: 'ri:eye-line' },
{ label: '今日 UV', value: overview.value?.today.uv ?? 0, icon: 'ri:user-line' }, // 四张周期卡:值 + 环比(delta 为 null 显示灰 —,负值红 ↓)
{ label: '今日会话', value: overview.value?.today.sessions ?? 0, icon: 'ri:door-open-line' }, const statCards = computed<{ label: string; icon: Component; value: string; delta: number | null }[]>(() => [
{ {
label: '今日新访客', label: '访客数',
value: overview.value?.today.new_visitors ?? 0, icon: markRaw(UserIcon),
icon: 'ri:user-add-line' value: fmtInt(overview.value?.cards.visitors.value ?? 0),
delta: overview.value?.cards.visitors.delta_pct ?? null
}, },
{ label: '在线访客', value: overview.value?.today.active_now ?? 0, icon: 'ri:pulse-line' } {
label: '访问次数',
icon: markRaw(VideoPlayIcon),
value: fmtInt(overview.value?.cards.visits.value ?? 0),
delta: overview.value?.cards.visits.delta_pct ?? null
},
{
label: '平均有效停留',
icon: markRaw(TimerIcon),
value: formatStay(overview.value?.cards.avg_stay_ms.value ?? 0),
delta: overview.value?.cards.avg_stay_ms.delta_pct ?? null
},
{
label: '回访访客',
icon: markRaw(UserFilledIcon),
value: fmtInt(overview.value?.cards.returning.value ?? 0),
delta: overview.value?.cards.returning.delta_pct ?? null
}
]) ])
// 趋势折线:x 轴日期取 MM-DD // ---------- 访问趋势双线图(主蓝=访客数 / 青=访问次数) ----------
const trendDates = computed(() => (overview.value?.trend ?? []).map((p) => p.date.slice(5))) const trend = useChart()
const trendSeries = computed(() => { const trendRef = trend.chartRef
const trend = overview.value?.trend ?? [] const { isDark } = trend
return [
{ name: '浏览量', data: trend.map((p) => p.pv) }, const areaGradient = (hex: string, topAlpha: number) =>
{ name: '访客数', data: trend.map((p) => p.uv) }, new graphic.LinearGradient(0, 0, 0, 1, [
{ name: '会话数', data: trend.map((p) => p.sessions) }, { offset: 0, color: `rgba(${hex}, ${topAlpha})` },
{ name: '新访客', data: trend.map((p) => p.new_visitors) } { offset: 1, color: `rgba(${hex}, 0.01)` }
])
const renderTrendChart = () => {
const points = overview.value?.trend ?? []
// 全零窗口显示空态(与模板折线图组件同款判定)
const empty = !points.length || points.every((p) => p.uv === 0 && p.sessions === 0)
if (empty) {
trend.initChart({}, true)
return
}
trend.updateChart({
grid: { top: 44, right: 16, bottom: 8, left: 0, containLabel: true },
tooltip: { ...trend.getTooltipStyle('axis') },
legend: {
right: 0,
top: 0,
icon: 'roundRect',
itemWidth: 14,
itemHeight: 4,
itemGap: 16,
textStyle: { color: isDark.value ? '#fff' : '#4E5969', fontSize: 12 }
},
xAxis: {
type: 'category',
boundaryGap: false,
data: points.map((p) => p.date.slice(5).replace('-', '/')), // MM/DD
axisTick: trend.getAxisTickStyle(),
axisLine: trend.getAxisLineStyle(),
axisLabel: trend.getAxisLabelStyle()
},
yAxis: {
type: 'value',
min: 0,
minInterval: 1,
axisLabel: trend.getAxisLabelStyle(),
axisLine: trend.getAxisLineStyle(false),
splitLine: trend.getSplitLineStyle()
},
series: [
{
name: '访客数',
type: 'line',
z: 3, // 双线数值相等(uv==sessions)时主蓝线画在青线之上,保持"访客数为主"的视觉层级
smooth: true,
symbol: 'circle',
symbolSize: 5,
showSymbol: false,
data: points.map((p) => p.uv),
color: TREND_COLORS.uv,
lineStyle: { width: 2.5 },
areaStyle: { color: areaGradient('37, 99, 235', 0.18) }
},
{
name: '访问次数',
type: 'line',
z: 2,
smooth: true,
symbol: 'circle',
symbolSize: 5,
showSymbol: false,
data: points.map((p) => p.sessions),
color: TREND_COLORS.sessions,
lineStyle: { width: 2.5 },
areaStyle: { color: areaGradient('6, 182, 212', 0.16) }
}
] ]
}) })
}
// 渠道构成饼图(归因口径):标签/颜色都按渠道绑定取自 CHANNEL_LABELS—— // ---------- 来源分布横向条形图(条长严格按 pct,x 轴定死 0-100) ----------
// 颜色随 data 同序映射,渠道缺席时也不会错位到别的渠道色 const channelChart = useChart()
const channelData = computed(() => const channelRef = channelChart.chartRef
(overview.value?.channels ?? []).map((c) => ({
name: CHANNEL_LABELS[c.channel]?.label ?? c.channel, const renderChannelChart = () => {
value: c.sessions const channels = overview.value?.channels ?? []
})) if (!channels.length) {
) channelChart.initChart({}, true)
const channelColors = computed(() => return
(overview.value?.channels ?? []).map((c) => CHANNEL_LABELS[c.channel]?.color ?? '#C0C4CC') }
const sessionsOf = new Map(
channels.map((c) => [channelLabel(c.channel), c.sessions])
) )
channelChart.updateChart({
grid: { top: 8, right: 52, bottom: 8, left: 8, containLabel: true },
tooltip: {
...channelChart.getTooltipStyle('item'),
formatter: (p: { name: string }) =>
`${p.name}:${sessionsOf.get(p.name) ?? 0} 次会话`
},
xAxis: { type: 'value', min: 0, max: 100, show: false },
yAxis: {
type: 'category',
inverse: true, // 最大的渠道在最上
data: channels.map((c) => channelLabel(c.channel)),
axisTick: { show: false },
axisLine: { show: false },
axisLabel: { color: isDark.value ? '#fff' : '#4E5969', fontSize: 12 }
},
series: [
{
type: 'bar',
barWidth: 14,
data: channels.map((c) => ({
value: Number((c.pct * 100).toFixed(2)),
itemStyle: {
color: CHANNEL_LABELS[c.channel]?.color ?? '#C0C4CC',
borderRadius: [0, 7, 7, 0]
}
})),
label: {
show: true,
position: 'right',
color: isDark.value ? '#fff' : '#4E5969',
fontSize: 12,
fontWeight: 600,
formatter: (p: { value: unknown }) => `${Math.round(Number(p.value))}%`
}
}
]
})
}
// 平台明细(原始 referrer 域名口径,与渠道归因互补) /** 渠道显示名(CHANNEL_LABELS 已把 direct 标为「直接或未知」;未枚举值原样) */
const platformSources = computed(() => overview.value?.sources ?? []) const channelLabel = (channel: string): string =>
CHANNEL_LABELS[channel]?.label ?? channel
// 热门页面 // 数据到位后重绘两张图(nextTick 等容器就绪;卸载销毁由 useChart 的 onUnmounted 负责)
const topPages = computed(() => overview.value?.top_pages ?? []) watch(overview, () => {
nextTick(() => {
renderTrendChart()
renderChannelChart()
})
})
// ---------- 最近访问(前 5 行,既有访客列表接口) ----------
const recentVisitors = ref<VisitorRow[]>([])
const visitorsLoading = ref(false)
let visitorsSeq = 0
const loadRecentVisitors = async () => {
const seq = ++visitorsSeq
visitorsLoading.value = true
try {
const resp = await getVisitors({
page: 1,
site: selectedSiteId.value === ALL_SITES ? undefined : selectedSiteId.value
})
if (seq === visitorsSeq) recentVisitors.value = (resp.list ?? []).slice(0, 5)
} catch {
if (seq === visitorsSeq) recentVisitors.value = []
} finally {
if (seq === visitorsSeq) visitorsLoading.value = false
}
}
/** 渠道徽章配色:渠道色 → 文字/浅底/描边(与列表/详情同款) */
const badgeStyle = (row: VisitorRow): Record<string, string> => {
const color = CHANNEL_LABELS[row.channel]?.color ?? CHANNEL_LABELS.unknown.color
return { color, background: `${color}1A`, borderColor: `${color}59` }
}
const router = useRouter()
const openVisitor = (row: VisitorRow) => {
router.push({ path: '/tracker/visitor/detail', query: { visitor_id: row.visitor_id } })
}
const goVisitorList = () => router.push('/tracker/visitor')
// ---------- 生命周期(keepAlive:首次挂载拉取,再次激活刷新数据) ----------
let booted = false
let firstActivation = true
const boot = () => {
if (booted) return
booted = true
loadSites()
loadOverview()
loadRecentVisitors()
}
onMounted(boot)
onActivated(() => {
if (firstActivation) {
firstActivation = false // 首次激活紧随 onMounted,boot 已拉取
return
}
loadOverview()
loadRecentVisitors()
})
</script> </script>
<style lang="scss" scoped>
// 客迹视觉 V1 色板 token(与访客详情页同款)
$vd-primary: #2563eb;
$vd-primary-bg: #e8f1fe;
$vd-text: #1d2129;
$vd-text-2: #4e5969;
$vd-text-3: #86909c;
$vd-border: #e9eaf0;
.vd-card {
background: var(--default-box-color, #fff);
border: 1px solid $vd-border;
border-radius: 12px;
box-shadow: 0 1px 3px rgba(29, 33, 41, 0.04);
}
// 页头
.db-header {
display: flex;
align-items: flex-end;
justify-content: space-between;
flex-wrap: wrap;
gap: 12px 20px;
margin-bottom: 16px;
}
.db-title {
margin: 0;
font-size: 24px;
font-weight: 700;
color: $vd-text;
line-height: 1.3;
}
.db-subtitle {
margin: 6px 0 0;
font-size: 14px;
color: $vd-text-3;
}
.db-header-tools {
display: flex;
align-items: center;
flex-wrap: wrap;
gap: 10px;
}
// 在线胶囊:灰点离线 / 绿点在线
.db-online {
display: inline-flex;
align-items: center;
padding: 5px 12px;
font-size: 13px;
color: $vd-text-2;
background: var(--default-box-color, #fff);
border: 1px solid $vd-border;
border-radius: 999px;
gap: 6px;
.db-online-dot {
width: 7px;
height: 7px;
background: var(--el-text-color-disabled);
border-radius: 50%;
}
&.is-online {
color: var(--el-color-success);
.db-online-dot {
background: var(--el-color-success);
}
}
}
// 4 周期统计卡
.db-stats {
display: grid;
grid-template-columns: repeat(4, minmax(0, 1fr));
gap: 16px;
margin-bottom: 16px;
min-height: 118px;
}
.db-stat-card {
display: flex;
flex-direction: column;
padding: 18px 20px 14px;
}
.db-stat-top {
display: flex;
align-items: center;
gap: 12px;
}
.db-stat-icon {
display: flex;
width: 44px;
height: 44px;
flex: none;
align-items: center;
justify-content: center;
color: $vd-primary;
background: $vd-primary-bg;
border-radius: 12px;
}
.db-stat-label {
font-size: 13px;
color: $vd-text-3;
}
.db-stat-value {
margin-top: 10px;
overflow: hidden;
font-size: 26px;
font-weight: 700;
color: $vd-text;
text-overflow: ellipsis;
white-space: nowrap;
}
// 趋势行(卡右下角):涨绿跌红 / null 灰 —
.db-stat-trend {
display: flex;
align-items: baseline;
justify-content: flex-end;
margin-top: auto;
padding-top: 8px;
gap: 6px;
}
.db-delta {
font-size: 13px;
font-weight: 700;
&.is-up {
color: var(--el-color-success);
}
&.is-down {
color: var(--el-color-danger);
}
&.is-none {
font-weight: 400;
color: $vd-text-3;
}
}
.db-delta-caption {
font-size: 12px;
color: $vd-text-3;
}
// 双栏图表行:趋势 2/3 + 来源分布 1/3
.db-charts {
display: grid;
grid-template-columns: minmax(0, 2fr) minmax(0, 1fr);
gap: 20px;
margin-bottom: 16px;
}
.db-chart-card {
display: flex;
flex-direction: column;
}
.db-card-head {
display: flex;
align-items: center;
justify-content: space-between;
flex-wrap: wrap;
padding: 16px 20px 12px;
gap: 8px 12px;
border-bottom: 1px solid $vd-border;
}
.db-card-title {
display: flex;
align-items: center;
font-size: 15px;
font-weight: 600;
color: $vd-text;
gap: 8px;
.el-icon {
color: $vd-primary;
}
}
.db-card-hint {
font-size: 12px;
color: $vd-text-3;
}
.db-chart-body {
height: 300px;
padding: 12px 16px;
}
// 最近访问表卡
.db-table-card {
padding-bottom: 12px;
}
.db-visitor-cell {
display: flex;
flex-direction: column;
align-items: flex-start;
padding: 6px 0;
gap: 4px;
}
.db-visitor-code {
padding: 2px 8px;
font-size: 12px;
background: var(--el-fill-color-light);
border-radius: 4px;
color: var(--el-text-color-regular);
}
// 渠道徽章胶囊(复用 channelBadge 文案 + 渠道配色)
.db-badge {
display: inline-block;
max-width: 100%;
padding: 2px 10px;
overflow: hidden;
font-size: 12px;
line-height: 18px;
border: 1px solid transparent;
border-radius: 999px;
text-overflow: ellipsis;
white-space: nowrap;
}
.db-source-text {
font-size: 13px;
color: $vd-text-2;
}
.db-path-text {
font-family: var(--el-font-family-mono, monospace);
font-size: 12px;
color: $vd-text-2;
}
:deep(.db-clickable-row) {
cursor: pointer;
}
// 响应式:中屏图表下沉单栏,小屏统计卡两列
@media (max-width: 1200px) {
.db-charts {
grid-template-columns: minmax(0, 1fr);
}
}
@media (max-width: 900px) {
.db-stats {
grid-template-columns: repeat(2, minmax(0, 1fr));
}
}
</style>
+83 -49
View File
@@ -1,19 +1,61 @@
"""数据总览接口(客迹视觉 V1:周期卡 + 趋势双线 + 渠道分布)。
?range=today|7d|30d(默认 7d,非法值回落默认)+ ?site=(可选,缺省聚合全部站点)。
- 周期卡四指标全口径排除爬虫,与上一个等长周期比较出 delta_pct(today 比昨天);
上期值为 0 时 delta 为 null(前端显示 —)。
- 趋势为日粒度:历史位读 DailyStat 聚合(缺日补零),今天一位用 daily_counts 实时值
(聚合作业未跑也不缺当日)——与 refresh_daily 共享口径(Task8 审查结论)。
- channels 按窗口内会话入口分类聚合,pct = 占已分类会话比(列表含 direct/unknown,
总和 ≈ 1;未重算出分类行的历史会话不计入)。
- 最近访问不在本接口做:前端调既有 /api/admin/visitors?page=1 取前 5 行。
"""
from datetime import datetime, time, timedelta from datetime import datetime, time, timedelta
from django.db.models import Count, Sum from django.db.models import Count, Sum
from django.utils import timezone from django.utils import timezone
from rest_framework.views import APIView
from rest_framework.response import Response from rest_framework.response import Response
from rest_framework.views import APIView
from tracker.api.envelope import EnvelopeMixin, ok from tracker.api.envelope import EnvelopeMixin, ok
from tracker.models import DailyStat, Event, Site, SourceClassification, VisitSession, Visitor from tracker.models import DailyStat, Event, Site, SourceClassification, VisitSession, Visitor
from tracker.services.stats import daily_counts from tracker.services.stats import daily_counts
METRICS = ("pv", "uv", "sessions", "new_visitors") RANGE_DAYS = {"today": 1, "7d": 7, "30d": 30}
CARD_KEYS = ("visitors", "visits", "avg_stay_ms", "returning")
def _day_bounds(date): def _window_stats(site, start, end):
"""上海自然日窗口,与 daily_counts 同款 make_aware。""" """窗口四指标(site=None 聚合全部站点;全口径排除爬虫)。
start = timezone.make_aware(datetime.combine(date, time.min))
return start, start + timedelta(days=1) visitors 按 Event distinct visitor(与 daily_counts 的 uv 同源,跨日不重复计);
visits 按会话 started_at 落窗;avg_stay_ms = Sum(stay_ms)/会话数(0 保护);
returning = visitors - 窗口内新访客(first_seen_at 落窗;异常数据下限 0)。
"""
ev = Event.objects.filter(is_bot=False,
occurred_at__gte=start, occurred_at__lt=end)
vs = VisitSession.objects.filter(visitor__is_bot=False,
started_at__gte=start, started_at__lt=end)
vis = Visitor.objects.filter(is_bot=False,
first_seen_at__gte=start, first_seen_at__lt=end)
if site is not None:
ev = ev.filter(site=site)
vs = vs.filter(site=site)
vis = vis.filter(site=site)
agg = vs.aggregate(n=Count("id"), stay=Sum("stay_ms"))
sessions = agg["n"]
visitors = ev.values("visitor").distinct().count()
new_visitors = vis.count()
return {"visitors": visitors,
"visits": sessions,
"avg_stay_ms": round((agg["stay"] or 0) / sessions) if sessions else 0,
"returning": max(visitors - new_visitors, 0)}
def _delta(cur, prev):
"""环比百分比(保留 1 位小数);上期值为 0 无意义 → None。"""
if prev == 0:
return None
return round((cur - prev) / prev * 100, 1)
class OverviewView(EnvelopeMixin, APIView): class OverviewView(EnvelopeMixin, APIView):
@@ -25,14 +67,25 @@ class OverviewView(EnvelopeMixin, APIView):
if not site: if not site:
return Response({"code": 404, "data": None, return Response({"code": 404, "data": None,
"message": "site_not_found"}, status=404) "message": "site_not_found"}, status=404)
days = RANGE_DAYS.get(request.query_params.get("range", "7d"), 7)
today = timezone.localdate() today = timezone.localdate()
counts = daily_counts(site, today) # 与 refresh_daily 共享口径(Task8 审查结论) # 当前窗口与上期等长窗口(上海自然日对齐:[T-N+1, T+1) 与 [T-2N+1, T-N+1))
counts["active_now"] = self._active_now(site) cur_start = timezone.make_aware(
return ok({"today": counts, datetime.combine(today - timedelta(days=days - 1), time.min))
"trend": self._trend(site, today, counts), cur_end = timezone.make_aware(
"sources": self._today_sources(site, today), datetime.combine(today + timedelta(days=1), time.min))
"channels": self._today_channels(site, today), prev_start = cur_start - timedelta(days=days)
"top_pages": self._today_pages(site, today)})
cur = _window_stats(site, cur_start, cur_end)
prev = _window_stats(site, prev_start, cur_start)
online = self._active_now(site)
return ok({
"header": {"online": online, "active_now": online}, # active_now 兼容保留
"cards": {k: {"value": cur[k], "delta_pct": _delta(cur[k], prev[k])}
for k in CARD_KEYS},
"trend": self._trend(site, today, days),
"channels": self._channels(site, cur_start, cur_end),
})
@staticmethod @staticmethod
def _active_now(site): def _active_now(site):
@@ -43,62 +96,43 @@ class OverviewView(EnvelopeMixin, APIView):
q = q.filter(site=site) q = q.filter(site=site)
return q.count() return q.count()
def _trend(self, site, today, today_counts): def _trend(self, site, today, days):
"""DailyStat 近 30 天(缺日补零;今天一位用 daily_counts 实时值覆盖——聚合作业未跑也不缺当日)。""" """窗口每日 [{date, uv, sessions}]:历史位读 DailyStat(缺日补零),
days = 30 今天一位用 daily_counts 实时值覆盖——聚合作业未跑也不缺当日。"""
start = today - timedelta(days=days - 1) start = today - timedelta(days=days - 1)
qs = DailyStat.objects.filter(date__gte=start, date__lte=today) qs = DailyStat.objects.filter(date__gte=start, date__lte=today)
if site is not None: if site is not None:
qs = qs.filter(site=site) qs = qs.filter(site=site)
rows = {r["date"]: r for r in qs.values("date").annotate( rows = {r["date"]: r for r in qs.values("date").annotate(
pv=Sum("pv"), uv=Sum("uv"), sessions=Sum("sessions"), uv=Sum("uv"), sessions=Sum("sessions"))}
new_visitors=Sum("new_visitors"))}
out = [] out = []
for i in range(days): for i in range(days):
date = start + timedelta(days=i) date = start + timedelta(days=i)
if date == today: if date == today:
counts = daily_counts(site, today)
out.append({"date": date.isoformat(), out.append({"date": date.isoformat(),
**{k: today_counts[k] for k in METRICS}}) "uv": counts["uv"], "sessions": counts["sessions"]})
else: else:
r = rows.get(date, {}) r = rows.get(date, {})
out.append({"date": date.isoformat(), out.append({"date": date.isoformat(),
**{k: r.get(k, 0) for k in METRICS}}) "uv": r.get("uv") or 0, "sessions": r.get("sessions") or 0})
return out return out
def _today_sources(self, site, today): def _channels(self, site, start, end):
start, end = _day_bounds(today) """窗口内会话按 session_entry 分类聚合(session.started_at 落窗)。
q = VisitSession.objects.filter(visitor__is_bot=False,
started_at__gte=start, started_at__lt=end)
if site is not None:
q = q.filter(site=site)
return [{"source": r["referrer_domain"] or "直接访问", "sessions": r["n"]}
for r in q.values("referrer_domain")
.annotate(n=Count("id"))
.order_by("-n", "referrer_domain")[:8]] # 平局时次序跨请求稳定
def _today_channels(self, site, today):
"""当天会话按来源渠道聚合(session_entry 分类;sources 的升级版,原始口径保留)。
从分类表正向聚合 + Count(distinct session):无唯一约束下分类表偶发同键重复行 从分类表正向聚合 + Count(distinct session):无唯一约束下分类表偶发同键重复行
也不会把同一会话计两次。无分类行的会话(未重算的历史数据)不计入。""" 也不会把同一会话计两次。pct = 占已分类会话比(含 direct/unknown,总和 ≈ 1;
start, end = _day_bounds(today) 无分类行的会话——未重算的历史数据——不计入)。"""
q = SourceClassification.objects.filter( q = SourceClassification.objects.filter(
scope="session_entry", scope="session_entry",
session__visitor__is_bot=False, session__visitor__is_bot=False,
session__started_at__gte=start, session__started_at__lt=end) session__started_at__gte=start, session__started_at__lt=end)
if site is not None: if site is not None:
q = q.filter(site=site) q = q.filter(site=site)
return [{"channel": r["channel"] or "unknown", "sessions": r["n"]} rows = list(q.values("channel")
for r in q.values("channel")
.annotate(n=Count("session", distinct=True)) .annotate(n=Count("session", distinct=True))
.order_by("-n", "channel")[:8]] # 平局时次序跨请求稳定 .order_by("-n", "channel")) # 平局时次序跨请求稳定
total = sum(r["n"] for r in rows)
def _today_pages(self, site, today): return [{"channel": r["channel"] or "unknown", "sessions": r["n"],
start, end = _day_bounds(today) "pct": round(r["n"] / total, 4) if total else 0.0}
q = Event.objects.filter(is_bot=False, event_name="page_view", for r in rows]
occurred_at__gte=start, occurred_at__lt=end)
if site is not None:
q = q.filter(site=site)
return [{"path": r["path"], "pv": r["n"]}
for r in q.values("path")
.annotate(n=Count("id"))
.order_by("-n", "path")[:8]] # 平局时次序跨请求稳定
+9 -2
View File
@@ -37,7 +37,8 @@ def _iso(dt):
def _row(visitor, last_session, online_cutoff, ft=None): def _row(visitor, last_session, online_cutoff, ft=None):
"""列表/详情共用行字段。last_source 依赖最近会话(由调用方批量取好传入,防 N+1)。 """列表/详情共用行字段。last_source 依赖最近会话(由调用方批量取好传入,防 N+1)。
channel 等归因五字段来自该访客 first_touch 分类(无分类行 → 空串,前端回落 first_source 展示)。""" channel 等归因五字段来自该访客 first_touch 分类(无分类行 → 空串,前端回落 first_source 展示)。
last_path 是最近一条事件的 path(_annotate 的 Subquery 注解,同样无 N+1)。"""
return { return {
"visitor_id": visitor.visitor_id, "visitor_id": visitor.visitor_id,
# 站点归属:visitor.site 需随行 select_related("site"),否则逐行多一条查询 # 站点归属:visitor.site 需随行 select_related("site"),否则逐行多一条查询
@@ -55,6 +56,7 @@ def _row(visitor, last_session, online_cutoff, ft=None):
"last_source": _source(last_session.referrer_domain, last_session.utm) "last_source": _source(last_session.referrer_domain, last_session.utm)
if last_session else "直接访问", # 无会话访客(仅采集异常场景) if last_session else "直接访问", # 无会话访客(仅采集异常场景)
"first_landing_path": visitor.first_landing_path, # 列表首触来源 tooltip 用(详情本就输出) "first_landing_path": visitor.first_landing_path, # 列表首触来源 tooltip 用(详情本就输出)
"last_path": visitor.last_event_path or "", # 最近一条事件 path(总览"浏览页面"列)
"channel": ft.channel if ft else "", "channel": ft.channel if ft else "",
"platform": ft.platform if ft else "", "platform": ft.platform if ft else "",
"variant": ft.variant if ft else "", "variant": ft.variant if ft else "",
@@ -87,18 +89,23 @@ def _session_summary(s):
def _annotate(qs): def _annotate(qs):
"""会话数/有效停留/最近会话标记一次注解到位。 """会话数/有效停留/最近会话/最近事件路径一次注解到位。
last_source 不逐行 sessions.order_by("-started_at").first()(页大小 20 即 N+1): last_source 不逐行 sessions.order_by("-started_at").first()(页大小 20 即 N+1):
先用相关子查询把每个访客最近会话的 pk 标到行上(走 (visitor,-started_at) 索引), 先用相关子查询把每个访客最近会话的 pk 标到行上(走 (visitor,-started_at) 索引),
分页后一次 IN 查询取回整页会话——全程只多 2 条 SQL。 分页后一次 IN 查询取回整页会话——全程只多 2 条 SQL。
last_path 同理用相关子查询取最近一条事件的 path(走 (site,visitor,-occurred_at) 索引),
-pk 平局次序跨请求稳定;无事件访客注解为 NULL,_row 收敛为空串。
""" """
latest = (VisitSession.objects.filter(visitor=OuterRef("pk")) latest = (VisitSession.objects.filter(visitor=OuterRef("pk"))
.order_by("-started_at", "-pk")) # -pk 平局次序跨请求稳定 .order_by("-started_at", "-pk")) # -pk 平局次序跨请求稳定
last_event = (Event.objects.filter(visitor=OuterRef("pk"))
.order_by("-occurred_at", "-pk"))
return qs.annotate( return qs.annotate(
session_count=Count("sessions"), session_count=Count("sessions"),
total_stay_ms=Sum("sessions__stay_ms"), # 有效停留口径(非 duration_ms) total_stay_ms=Sum("sessions__stay_ms"), # 有效停留口径(非 duration_ms)
latest_session_pk=Subquery(latest.values("pk")[:1]), latest_session_pk=Subquery(latest.values("pk")[:1]),
last_event_path=Subquery(last_event.values("path")[:1]),
) )
+143 -66
View File
@@ -14,6 +14,8 @@ User = get_user_model()
NOW = timezone.localtime(timezone.now()) NOW = timezone.localtime(timezone.now())
TODAY = NOW.date() TODAY = NOW.date()
YESTERDAY = TODAY - timedelta(days=1) YESTERDAY = TODAY - timedelta(days=1)
AT_3D = NOW - timedelta(days=3) # 7d 当前窗口内(近 3 天)
AT_10D = NOW - timedelta(days=10) # 7d 上期窗口内(前 7 天)
@pytest.fixture @pytest.fixture
@@ -32,16 +34,23 @@ def _auth(client, user):
client.credentials(HTTP_AUTHORIZATION=f"Bearer {token.access_token}") client.credentials(HTTP_AUTHORIZATION=f"Bearer {token.access_token}")
def _visitor(site, vid, last_seen=None): def _visitor(site, vid, last_seen=None, first_seen=None):
v = Visitor.objects.create(site=site, visitor_id=vid) v = Visitor.objects.create(site=site, visitor_id=vid)
if last_seen is not None: # last_seen_at 是 auto_now_add,只能 queryset.update 绕开 # first/last_seen_at 是 auto_now_add,只能 queryset.update 绕开
Visitor.objects.filter(pk=v.pk).update(last_seen_at=last_seen) fields = {}
if last_seen is not None:
fields["last_seen_at"] = last_seen
if first_seen is not None:
fields["first_seen_at"] = first_seen
if fields:
Visitor.objects.filter(pk=v.pk).update(**fields)
return v return v
def _session(site, visitor, sid, referrer="", at=NOW): def _session(site, visitor, sid, referrer="", at=NOW, stay_ms=0):
return VisitSession.objects.create(site=site, visitor=visitor, session_id=sid, return VisitSession.objects.create(site=site, visitor=visitor, session_id=sid,
started_at=at, ended_at=at + timedelta(minutes=5), referrer_domain=referrer) started_at=at, ended_at=at + timedelta(minutes=5), referrer_domain=referrer,
stay_ms=stay_ms)
def _events(site, visitor, session, n, path, at=NOW, prefix="e"): def _events(site, visitor, session, n, path, at=NOW, prefix="e"):
@@ -55,106 +64,174 @@ def test_overview_requires_auth(client):
assert client.get("/api/admin/dashboard/overview").status_code == 401 assert client.get("/api/admin/dashboard/overview").status_code == 401
def test_overview_shape_and_numbers(client, member_user): def test_overview_7d_cards_and_deltas(client, member_user):
"""7d 四卡指标 + delta 正负 + 上期零(returning)为 null;爬虫全口径排除。"""
_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"])
v1 = _visitor(site, "v1", last_seen=timezone.now()) # 活跃(5 分钟内) # 本期(近 7 天):v1/v2 今天首访(新访客),v3 是 10 天前的老访客 3 天前回访
v2 = _visitor(site, "v2", last_seen=timezone.now() - timedelta(hours=1)) # 不活跃 v1 = _visitor(site, "v1", last_seen=timezone.now())
s1 = _session(site, v1, "s1", referrer="google.com") v2 = _visitor(site, "v2", last_seen=timezone.now())
s2 = _session(site, v2, "s2", referrer="google.com") v3 = _visitor(site, "v3", last_seen=timezone.now(), first_seen=AT_10D)
s3 = _session(site, v1, "s3", referrer="") # 空来源 → 直接访问 s1 = _session(site, v1, "s1", stay_ms=60000)
_events(site, v1, s1, 6, "/pricing", prefix="p") # /pricing x6 s2 = _session(site, v2, "s2", stay_ms=30000)
_events(site, v1, s3, 2, "/", prefix="r") # / x2 s3 = _session(site, v3, "s3", at=AT_3D) # stay 0
_events(site, v2, s2, 2, "/docs", prefix="d") # /docs x2 _events(site, v1, s1, 2, "/a", prefix="a")
_events(site, v2, s2, 1, "/b", prefix="b")
_events(site, v3, s3, 1, "/c", at=AT_3D, prefix="c")
# 上期(前 7 天):v3 首访 + v4 三次会话——上期 visitors=2 / visits=4 / avg=20000
sp3 = _session(site, v3, "sp3", at=AT_10D, stay_ms=10000)
_events(site, v3, sp3, 1, "/old3", at=AT_10D, prefix="o3")
v4 = _visitor(site, "v4", last_seen=AT_10D, first_seen=AT_10D)
for i, stay in enumerate((20000, 30000, 20000)):
sp = _session(site, v4, f"sp4-{i}", at=AT_10D, stay_ms=stay)
_events(site, v4, sp, 1, "/old4", at=AT_10D, prefix=f"o4{i}")
# 爬虫:今天有大额停留与事件,任何口径都不得计入(事件行同步标记 is_bot,与 ingest 同口径)
bot = _visitor(site, "bot-v", last_seen=timezone.now())
Visitor.objects.filter(pk=bot.pk).update(is_bot=True)
sb = _session(site, bot, "sb", stay_ms=999000)
_events(site, bot, sb, 5, "/bot", prefix="bt")
Event.objects.filter(visitor=bot).update(is_bot=True)
r = client.get("/api/admin/dashboard/overview") r = client.get("/api/admin/dashboard/overview") # 缺省 range=7d
assert r.status_code == 200 assert r.status_code == 200
d = r.json()["data"] d = r.json()["data"]
assert d["today"] == {"pv": 10, "uv": 2, "sessions": 3, # 本期:visitors=3(v1/v2/v3)、visits=3、avg=(60k+30k+0)/3=30k、returning=3-2 新=1
"new_visitors": 2, "active_now": 1} # 上期:visitors=2(v3/v4)、visits=4、avg=80k/4=20k、returning=2-2 新=0 → delta null
assert d["cards"]["visitors"] == {"value": 3, "delta_pct": 50.0} # 正
assert d["cards"]["visits"] == {"value": 3, "delta_pct": -25.0} # 负
assert d["cards"]["avg_stay_ms"] == {"value": 30000, "delta_pct": 50.0}
assert d["cards"]["returning"] == {"value": 1, "delta_pct": None} # 上期 0 → null
# 在线:v1/v2/v3 活跃(bot 排除、v4 十天前不在线)
assert d["header"] == {"online": 3, "active_now": 3} # active_now 兼容保留
# 趋势:7 点日粒度,历史无 DailyStat 补零,今天一位用实时值(uv=2、sessions=2,bot 排除)
trend = d["trend"] trend = d["trend"]
assert len(trend) == 30 assert len(trend) == 7
assert trend[-1]["date"] == TODAY.isoformat() assert trend[0]["date"] == (TODAY - timedelta(days=6)).isoformat()
assert trend[-1]["pv"] == 10 # 今天位用实时值 assert trend[-1] == {"date": TODAY.isoformat(), "uv": 2, "sessions": 2}
assert trend[-2]["pv"] == 0 # 昨天无 DailyStat → 补零 assert trend[-2] == {"date": YESTERDAY.isoformat(), "uv": 0, "sessions": 0}
assert d["sources"][0] == {"source": "google.com", "sessions": 2} assert d["channels"] == [] # 无分类行 → 空列表
assert d["sources"][-1] == {"source": "直接访问", "sessions": 1}
assert d["top_pages"][0] == {"path": "/pricing", "pv": 6}
def test_overview_prev_zero_delta_null(client, member_user):
"""上期完全无数据:四卡 delta_pct 全为 null,卡值正常。"""
_auth(client, member_user)
site = Site.objects.create(name="s", domains=["a.com"])
v1 = _visitor(site, "v1", last_seen=timezone.now())
s1 = _session(site, v1, "s1", stay_ms=50000)
_events(site, v1, s1, 2, "/a", prefix="a")
d = client.get("/api/admin/dashboard/overview").json()["data"]
assert d["cards"] == {
"visitors": {"value": 1, "delta_pct": None},
"visits": {"value": 1, "delta_pct": None},
"avg_stay_ms": {"value": 50000, "delta_pct": None},
"returning": {"value": 0, "delta_pct": None}, # 新访客 → 回访 0,同样上期零
}
def test_overview_channels_pct(client, member_user):
"""channels:窗口内 session_entry 分类聚合,pct 占已分类会话比、总和 ≈ 1。"""
_auth(client, member_user)
site = Site.objects.create(name="s", domains=["a.com"])
v1 = _visitor(site, "v1", last_seen=timezone.now())
v2 = _visitor(site, "v2", last_seen=timezone.now())
s1 = _session(site, v1, "s1", referrer="www.bing.com")
s2 = _session(site, v1, "s2", referrer="www.bing.com", at=AT_3D)
s3 = _session(site, v2, "s3", referrer="chatgpt.com")
s4 = _session(site, v2, "s4") # 直接访问
s5 = _session(site, v2, "s5", referrer="t.co", at=AT_10D) # 上期:不计入
s6 = _session(site, v2, "s6", referrer="example.com") # 无分类行:不计入
for s, ch, pf in ((s1, "natural_search", "Bing"), (s2, "natural_search", "Bing"),
(s3, "ai_referral", "ChatGPT"), (s4, "direct", "")):
SourceClassification.objects.create(site=site, visitor=s.visitor, session=s,
scope="session_entry", channel=ch, platform=pf)
SourceClassification.objects.create(site=site, visitor=v2, session=s5,
scope="session_entry", channel="social", platform="Twitter")
_events(site, v1, s1, 1, "/", prefix="x")
d = client.get("/api/admin/dashboard/overview").json()["data"]
assert d["channels"] == [
{"channel": "natural_search", "sessions": 2, "pct": pytest.approx(0.5)},
{"channel": "ai_referral", "sessions": 1, "pct": pytest.approx(0.25)},
{"channel": "direct", "sessions": 1, "pct": pytest.approx(0.25)}, # 平局按名稳定
]
assert sum(c["pct"] for c in d["channels"]) == pytest.approx(1.0)
def test_overview_range_today(client, member_user):
"""range=today:卡按今天口径、delta 比昨天;趋势单点;非法 range 回落 7d。"""
_auth(client, member_user)
site = Site.objects.create(name="s", domains=["a.com"])
vy = _visitor(site, "vy", last_seen=timezone.now(), first_seen=NOW - timedelta(days=1))
sy = _session(site, vy, "sy", at=NOW - timedelta(days=1), stay_ms=60000)
_events(site, vy, sy, 2, "/y", at=NOW - timedelta(days=1), prefix="y")
vt = _visitor(site, "vt", last_seen=timezone.now())
st = _session(site, vt, "st", stay_ms=30000)
_events(site, vt, st, 1, "/t", prefix="t")
d = client.get("/api/admin/dashboard/overview", {"range": "today"}).json()["data"]
assert d["trend"] == [{"date": TODAY.isoformat(), "uv": 1, "sessions": 1}]
assert d["cards"]["visitors"] == {"value": 1, "delta_pct": 0.0} # 昨天也是 1
assert d["cards"]["visits"] == {"value": 1, "delta_pct": 0.0}
assert d["cards"]["avg_stay_ms"] == {"value": 30000, "delta_pct": -50.0}
assert d["cards"]["returning"]["delta_pct"] is None # 两天都是全新访客
assert len(client.get("/api/admin/dashboard/overview").json()["data"]["trend"]) == 7
d = client.get("/api/admin/dashboard/overview", {"range": "abc"}).json()["data"]
assert len(d["trend"]) == 7 # 非法值回落 7d
d = client.get("/api/admin/dashboard/overview", {"range": "30d"}).json()["data"]
assert len(d["trend"]) == 30
def test_overview_trend_uses_dailystat_history(client, member_user): def test_overview_trend_uses_dailystat_history(client, member_user):
"""7d 趋势历史位读 DailyStat 聚合(跨站求和),今天位用实时值。"""
_auth(client, member_user) _auth(client, member_user)
site_a = Site.objects.create(name="a", domains=["a.com"]) site_a = Site.objects.create(name="a", domains=["a.com"])
site_b = Site.objects.create(name="b", domains=["b.com"]) site_b = Site.objects.create(name="b", domains=["b.com"])
yn = NOW - timedelta(days=1) # 昨天同一本地时刻,必落上海昨日窗口 yn = NOW - timedelta(days=1) # 昨天同一本地时刻,必落上海昨日窗口
# a 站昨天 3 事件走真实 refresh_daily 落 DailyStat
va = Visitor.objects.create(site=site_a, visitor_id="va") va = Visitor.objects.create(site=site_a, visitor_id="va")
sa = _session(site_a, va, "sa", at=yn) sa = _session(site_a, va, "sa", at=yn)
_events(site_a, va, sa, 3, "/", at=yn, prefix="ya") _events(site_a, va, sa, 3, "/", at=yn, prefix="ya")
refresh_daily(site_a, YESTERDAY) refresh_daily(site_a, YESTERDAY)
# b 站昨天 4 事件同样走 refresh_daily——两站各落一行,验证 site=None 的跨站 Sum
vb = Visitor.objects.create(site=site_b, visitor_id="vb") vb = Visitor.objects.create(site=site_b, visitor_id="vb")
sb = _session(site_b, vb, "sb", at=yn) sb = _session(site_b, vb, "sb", at=yn)
_events(site_b, vb, sb, 4, "/", at=yn, prefix="yb") _events(site_b, vb, sb, 4, "/", at=yn, prefix="yb")
refresh_daily(site_b, YESTERDAY) refresh_daily(site_b, YESTERDAY)
d = client.get("/api/admin/dashboard/overview").json()["data"] d = client.get("/api/admin/dashboard/overview").json()["data"]
assert d["trend"][-2] == {"date": YESTERDAY.isoformat(), "pv": 7, "uv": 2, trend = d["trend"]
"sessions": 2, "new_visitors": 0} # 3+4 跨站点求和 assert len(trend) == 7
assert d["trend"][-1]["pv"] == 0 # 今天无事件,实时覆盖为 0 assert trend[-2] == {"date": YESTERDAY.isoformat(), "uv": 2, "sessions": 2} # 跨站求和
assert trend[-1] == {"date": TODAY.isoformat(), "uv": 0, "sessions": 0} # 今天无事件
def test_overview_channels_aggregation(client, member_user):
"""channels:当天会话按 session_entry 分类的 channel 聚合(与 sources 并存,互不影响)。"""
_auth(client, member_user)
site = Site.objects.create(name="s", domains=["a.com"])
v1 = _visitor(site, "v1", last_seen=timezone.now())
v2 = _visitor(site, "v2", last_seen=timezone.now())
s1 = _session(site, v1, "s1", referrer="www.bing.com")
s2 = _session(site, v1, "s2", referrer="www.bing.com")
s3 = _session(site, v2, "s3", referrer="chatgpt.com")
s4 = _session(site, v2, "s4", referrer="") # 直接访问
SourceClassification.objects.create(site=site, visitor=v1, session=s1,
scope="session_entry", channel="natural_search", platform="Bing")
SourceClassification.objects.create(site=site, visitor=v1, session=s2,
scope="session_entry", channel="natural_search", platform="Bing")
SourceClassification.objects.create(site=site, visitor=v2, session=s3,
scope="session_entry", channel="ai_referral", platform="ChatGPT")
SourceClassification.objects.create(site=site, visitor=v2, session=s4,
scope="session_entry", channel="direct", resolution="unknown")
_events(site, v1, s1, 2, "/", prefix="a")
d = client.get("/api/admin/dashboard/overview").json()["data"]
assert d["channels"] == [{"channel": "natural_search", "sessions": 2},
{"channel": "ai_referral", "sessions": 1},
{"channel": "direct", "sessions": 1}] # 倒序 top8,平局按名稳定
assert d["sources"][0] == {"source": "www.bing.com", "sessions": 2} # 原口径保留
def test_overview_site_filter_and_404(client, member_user): def test_overview_site_filter_and_404(client, member_user):
_auth(client, member_user) _auth(client, member_user)
site_a = Site.objects.create(name="a", domains=["a.com"]) site_a = Site.objects.create(name="a", domains=["a.com"])
site_b = Site.objects.create(name="b", domains=["b.com"]) site_b = Site.objects.create(name="b", domains=["b.com"])
va = _visitor(site_a, "va") va = _visitor(site_a, "va", last_seen=timezone.now())
vb = _visitor(site_b, "vb") vb = _visitor(site_b, "vb", last_seen=timezone.now())
sa = _session(site_a, va, "sa", referrer="google.com") sa = _session(site_a, va, "sa", referrer="google.com")
sb = _session(site_b, vb, "sb", referrer="") sb = _session(site_b, vb, "sb")
_events(site_a, va, sa, 2, "/", prefix="a") _events(site_a, va, sa, 2, "/", prefix="a")
_events(site_b, vb, sb, 5, "/b", prefix="b") _events(site_b, vb, sb, 5, "/b", prefix="b")
SourceClassification.objects.create(site=site_b, visitor=vb, session=sb,
scope="session_entry", channel="direct", resolution="unknown")
# a 站昨天有 DailyStat 行——过滤 site_b 时不得混入 # a 站昨天有 DailyStat 行——过滤 site_b 时不得混入
yn = NOW - timedelta(days=1) yn = NOW - timedelta(days=1)
_events(site_a, va, sa, 3, "/", at=yn, prefix="ay") _events(site_a, va, sa, 3, "/", at=yn, prefix="ay")
refresh_daily(site_a, YESTERDAY) refresh_daily(site_a, YESTERDAY)
d = client.get("/api/admin/dashboard/overview").json()["data"] d = client.get("/api/admin/dashboard/overview").json()["data"]
assert d["today"]["pv"] == 7 # 缺省聚合全部站点 assert d["cards"]["visitors"]["value"] == 2 # 缺省聚合全部站点
assert d["header"]["online"] == 2
d = client.get("/api/admin/dashboard/overview", {"site": site_b.site_id}).json()["data"] d = client.get("/api/admin/dashboard/overview", {"site": site_b.site_id}).json()["data"]
assert d["today"]["pv"] == 5 and d["today"]["uv"] == 1 assert d["cards"]["visitors"]["value"] == 1 and d["cards"]["visits"]["value"] == 1
assert d["today"]["active_now"] == 1 # 两访客 last_seen 均为 now,但只计本站 assert d["header"]["online"] == 1 # 只计本站
assert d["sources"] == [{"source": "直接访问", "sessions": 1}] assert d["channels"] == [{"channel": "direct", "sessions": 1,
assert d["top_pages"][0] == {"path": "/b", "pv": 5} "pct": pytest.approx(1.0)}]
assert d["trend"][-2]["pv"] == 0 # a 站昨日行被站点过滤排除 assert d["trend"][-2] == {"date": YESTERDAY.isoformat(), "uv": 0, "sessions": 0}
assert d["trend"][-1]["pv"] == 5 # 今天位仍是本站实时值 assert d["trend"][-1] == {"date": TODAY.isoformat(), "uv": 1, "sessions": 1}
r = client.get("/api/admin/dashboard/overview", {"site": "site_nope"}) r = client.get("/api/admin/dashboard/overview", {"site": "site_nope"})
assert r.status_code == 404 assert r.status_code == 404
+23 -2
View File
@@ -78,8 +78,8 @@ def test_list_fields_and_default_bot_excluded(client, member_user):
"device_type", "os", "browser", "language", "first_seen_at", "device_type", "os", "browser", "language", "first_seen_at",
"last_seen_at", "visit_count", "active_days", "session_count", "last_seen_at", "visit_count", "active_days", "session_count",
"total_stay_ms", "first_source", "last_source", "first_landing_path", "total_stay_ms", "first_source", "last_source", "first_landing_path",
"online", "channel", "platform", "variant", "search_term", "resolution", "last_path", "online", "channel", "platform", "variant", "search_term",
"device_model"} "resolution", "device_model"}
assert row["country"] == "中国" and row["os"] == "Windows" and row["browser"] == "Chrome" assert row["country"] == "中国" and row["os"] == "Windows" and row["browser"] == "Chrome"
assert row["session_count"] == 2 assert row["session_count"] == 2
assert row["total_stay_ms"] == 150000 # 120000+30000,有效停留口径 assert row["total_stay_ms"] == 150000 # 120000+30000,有效停留口径
@@ -146,6 +146,26 @@ def test_list_site_fields_and_site_filter(client, member_user):
assert d["total"] == 1 and [x["visitor_id"] for x in d["list"]] == ["vb"] # 只剩本站访客 assert d["total"] == 1 and [x["visitor_id"] for x in d["list"]] == ["vb"] # 只剩本站访客
def test_list_last_path(client, member_user):
"""列表行 last_path:该访客最近一条事件的 path(Subquery 注解,无 N+1);
无事件访客为空串。"""
_auth(client, member_user)
site = Site.objects.create(name="s", domains=["a.com"])
v1 = _visitor(site, "v1", last_seen=timezone.now())
v2 = _visitor(site, "v2", last_seen=timezone.now() - timedelta(hours=1))
s1 = _session(site, v1, "s1", at=NOW - timedelta(hours=2))
_event(site, v1, s1, "e1", "page_view", NOW - timedelta(hours=2), path="/old")
_event(site, v1, s1, "e2", "page_view", NOW - timedelta(minutes=90), path="/stay")
_event(site, v1, s1, "e3", "page_stay", NOW - timedelta(minutes=30), path="/stay",
active_ms=1000) # occurred_at 早于 e2 → 不改判"最近"
_event(site, v1, s1, "e4", "page_view", NOW, path="/newest")
d = client.get("/api/admin/visitors").json()["data"]
by_id = {x["visitor_id"]: x for x in d["list"]}
assert by_id["v1"]["last_path"] == "/newest" # 最近一条事件的 path
assert by_id["v2"]["last_path"] == "" # 无事件 → 空串(前端显示 —)
def test_list_site_not_found(client, member_user): def test_list_site_not_found(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"])
@@ -198,6 +218,7 @@ def test_detail_shape(client, member_user):
assert d["session_count"] == 2 and d["total_stay_ms"] == 90000 assert d["session_count"] == 2 and d["total_stay_ms"] == 90000
assert d["first_source"] == "google.com" assert d["first_source"] == "google.com"
assert d["last_source"] == "wechat" # 最近会话 s-new:referrer 空 → utm 兜底 assert d["last_source"] == "wechat" # 最近会话 s-new:referrer 空 → utm 兜底
assert d["last_path"] == "/b" # 最近一条事件 e3 的 path
assert [s["session_id"] for s in d["sessions"]] == ["s-new", "s-old"] # 最近在前 assert [s["session_id"] for s in d["sessions"]] == ["s-new", "s-old"] # 最近在前
srow = d["sessions"][0] srow = d["sessions"][0]
assert set(srow) == {"session_id", "started_at", "ended_at", "referrer_domain", "utm", assert set(srow) == {"session_id", "started_at", "ended_at", "referrer_domain", "utm",