feat(admin): 数据总览按客迹视觉V1重做(周期卡/趋势双线/渠道条形/最近访问)
Co-Authored-By: Claude Code <noreply@anthropic.com>
This commit is contained in:
@@ -1,59 +1,59 @@
|
||||
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,不传 = 聚合全部站点(站点不存在返回 404 site_not_found)。
|
||||
* 周期口径全排除爬虫;site 参数传 site_id,不传 = 聚合全部站点
|
||||
* (站点不存在返回 404 site_not_found);range 非法值后端回落 7d。
|
||||
*/
|
||||
|
||||
/** 今日四指标 + 当前在线(5 分钟内有行为的非爬虫访客数) */
|
||||
export interface TodayCounts {
|
||||
pv: number
|
||||
uv: number
|
||||
sessions: number
|
||||
new_visitors: number
|
||||
/** 周期枚举:today=上海自然日;7d/30d=近 N 天窗口 */
|
||||
export type OverviewRange = 'today' | '7d' | '30d'
|
||||
|
||||
/** 页头在线信息(active_now 是 online 的兼容别名,二者恒相等) */
|
||||
export interface OverviewHeader {
|
||||
online: 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 {
|
||||
date: string
|
||||
pv: number
|
||||
uv: number
|
||||
sessions: number
|
||||
new_visitors: number
|
||||
}
|
||||
|
||||
/** 今日来源 TOP8(referrer_domain 为空归并为"直接访问") */
|
||||
export interface SourceItem {
|
||||
source: string
|
||||
sessions: number
|
||||
}
|
||||
|
||||
/** 今日渠道构成 TOP8(session_entry 分类聚合,sessions 去重计数;
|
||||
* channel 为 CHANNELS 枚举,无分类行不计入——未重算的历史会话不在此口径内) */
|
||||
/** 渠道聚合行:channel 为 CHANNELS 枚举(含 direct/unknown);
|
||||
* pct = 占已分类会话比(4 位小数,列表内总和 ≈ 1;无分类行的历史会话不计入) */
|
||||
export interface ChannelItem {
|
||||
channel: string
|
||||
sessions: number
|
||||
}
|
||||
|
||||
/** 今日热门页面 TOP8(page_view 按 path 聚合) */
|
||||
export interface PageItem {
|
||||
path: string
|
||||
pv: number
|
||||
pct: number
|
||||
}
|
||||
|
||||
/** 总览响应 */
|
||||
export interface OverviewResp {
|
||||
today: TodayCounts
|
||||
header: OverviewHeader
|
||||
cards: OverviewCards
|
||||
trend: TrendPoint[]
|
||||
sources: SourceItem[]
|
||||
/** 来源渠道聚合(sources 的归因升级版,原始域名口径保留在 sources) */
|
||||
channels: ChannelItem[]
|
||||
top_pages: PageItem[]
|
||||
}
|
||||
|
||||
/** 总览数据(site 可选,传 site_id) */
|
||||
export const getOverview = (site?: string) =>
|
||||
request.get<OverviewResp>({ url: '/admin/dashboard/overview', params: site ? { site } : {} })
|
||||
/** 总览数据(site 传 site_id 可选;range 默认 7d) */
|
||||
export const getOverview = (params: { site?: string; range?: OverviewRange } = {}) =>
|
||||
request.get<OverviewResp>({ url: '/admin/dashboard/overview', params })
|
||||
|
||||
@@ -34,6 +34,8 @@ export interface VisitorRow {
|
||||
last_source: string
|
||||
/** 首次访问落地页(空串=未采集,如直达首页;列表 tooltip / 详情识别卡用) */
|
||||
first_landing_path: string
|
||||
/** 最近一条事件的 path(总览「浏览页面」列;无事件访客为空串) */
|
||||
last_path: string
|
||||
/** 首触来源归因(first_touch 分类;无分类行/未重算的历史访客为 '',前端回落 first_source 展示) */
|
||||
channel: string
|
||||
platform: string
|
||||
|
||||
@@ -80,6 +80,29 @@ export function channelBadge(row: ChannelBadgeRow): string {
|
||||
export const formatDateTime = (iso?: string | null): string =>
|
||||
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秒) */
|
||||
export const formatStay = (ms?: number | null): string => {
|
||||
const totalSec = Math.max(0, Math.floor((ms ?? 0) / 1000))
|
||||
|
||||
@@ -1,19 +1,23 @@
|
||||
<!-- 访客统计 · 数据总览 -->
|
||||
<!-- 访客统计 · 数据总览(客迹视觉 V1:周期四卡 + 趋势双线 + 渠道条形 + 最近访问) -->
|
||||
<template>
|
||||
<div class="tracker-dashboard-page">
|
||||
<!-- 筛选栏:站点切换 + 刷新 -->
|
||||
<div class="art-card mb-4 flex-c flex-wrap gap-3 px-5 py-4">
|
||||
<ArtSvgIcon icon="ri:bar-chart-box-line" class="text-lg text-theme" />
|
||||
<h2 class="m-0 text-base font-semibold">数据总览</h2>
|
||||
<span class="text-xs text-g-500">今日指标为上海自然日口径,已排除爬虫</span>
|
||||
<div class="ml-auto flex-c gap-2">
|
||||
<span class="text-sm text-g-600">站点</span>
|
||||
<div class="tracker-dashboard-page p-5">
|
||||
<!-- 页头:标题/副标题 + 在线胶囊 + 站点/周期筛选 -->
|
||||
<div class="db-header">
|
||||
<div class="db-header-main">
|
||||
<h2 class="db-title">数据概览</h2>
|
||||
<p class="db-subtitle">了解每一次访问,连接每一个触点</p>
|
||||
</div>
|
||||
<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-* 类 -->
|
||||
<ElSelect
|
||||
v-model="selectedSiteId"
|
||||
style="width: 256px"
|
||||
style="width: 220px"
|
||||
:loading="sitesLoading"
|
||||
@change="loadOverview"
|
||||
@change="onSiteChange"
|
||||
>
|
||||
<ElOption label="全部站点" value="ALL_SITES" />
|
||||
<ElOption
|
||||
@@ -23,120 +27,153 @@
|
||||
:value="site.site_id"
|
||||
/>
|
||||
</ElSelect>
|
||||
<ElTooltip content="刷新数据" placement="top">
|
||||
<ElButton circle :icon="RefreshIcon" :loading="overviewLoading" @click="refreshAll" />
|
||||
</ElTooltip>
|
||||
<ElSelect v-model="range" style="width: 132px" @change="loadOverview">
|
||||
<ElOption label="今天" value="today" />
|
||||
<ElOption label="近 7 天" value="7d" />
|
||||
<ElOption label="近 30 天" value="30d" />
|
||||
</ElSelect>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 今日五指标 -->
|
||||
<div
|
||||
v-loading="overviewLoading"
|
||||
class="grid grid-cols-2 gap-4 mb-4 md:grid-cols-3 xl:grid-cols-5"
|
||||
<!-- 4 周期统计卡 -->
|
||||
<div v-loading="overviewLoading" class="db-stats">
|
||||
<div v-for="card in statCards" :key="card.label" class="vd-card db-stat-card">
|
||||
<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
|
||||
v-for="card in statCards"
|
||||
:key="card.label"
|
||||
class="art-card relative h-30 flex flex-col justify-center px-5"
|
||||
{{ card.delta >= 0 ? '↑' : '↓' }}{{ Math.abs(card.delta) }}%
|
||||
</span>
|
||||
<span class="db-delta-caption">较上一周期</span>
|
||||
</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>
|
||||
<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>
|
||||
<ElTableColumn label="访客" min-width="210">
|
||||
<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>
|
||||
</ElTableColumn>
|
||||
<ElTableColumn prop="sessions" label="会话数" width="90" align="right" />
|
||||
</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">
|
||||
<ElTableColumn label="本次来源" min-width="150" show-overflow-tooltip>
|
||||
<template #default="{ row }">
|
||||
<span class="font-mono text-xs">{{ row.path }}</span>
|
||||
<span class="db-source-text">{{ row.last_source || '—' }}</span>
|
||||
</template>
|
||||
</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>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { Refresh as RefreshIcon } from '@element-plus/icons-vue'
|
||||
import { useChartOps } from '@/hooks/core/useChart'
|
||||
import { markRaw } from 'vue'
|
||||
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 { getOverview, type OverviewResp } from '@/api/tracker/dashboard'
|
||||
import { CHANNEL_LABELS } from '@/utils/tracker'
|
||||
import {
|
||||
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' })
|
||||
|
||||
/** 图表色板:模板主题色为主,剔除过浅色与重复色(useChartOps 默认第 3 位 #EDF2FF 在浅色底不可见) */
|
||||
const themeColor = useChartOps().themeColor
|
||||
const lineColors = [themeColor, '#4ABEFF', '#14DEBA', '#FFAF20']
|
||||
// 客迹视觉 V1 图表用色(双线:主蓝 + 青;与详情页色板同源)
|
||||
const TREND_COLORS = { uv: '#2563EB', sessions: '#06B6D4' }
|
||||
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 sites = ref<SiteRow[]>([])
|
||||
const sitesLoading = ref(false)
|
||||
const selectedSiteId = ref(ALL_SITES)
|
||||
|
||||
// 总览数据
|
||||
const overview = ref<OverviewResp | null>(null)
|
||||
const overviewLoading = ref(false)
|
||||
const range = ref<OverviewRange>('7d')
|
||||
|
||||
const loadSites = async () => {
|
||||
sitesLoading.value = true
|
||||
@@ -149,74 +186,528 @@
|
||||
}
|
||||
}
|
||||
|
||||
// 请求令牌:快速切换站点时丢弃过期响应,避免旧数据覆盖新数据/旧 finally 提前清 loading
|
||||
// ---------- 总览(周期卡 + 趋势 + 渠道) ----------
|
||||
const overview = ref<OverviewResp | null>(null)
|
||||
const overviewLoading = ref(false)
|
||||
|
||||
// 请求令牌:快速切周期/站点时丢弃过期响应,避免旧数据覆盖新数据/旧 finally 提前清 loading
|
||||
let reqSeq = 0
|
||||
const loadOverview = async () => {
|
||||
const id = ++reqSeq
|
||||
overviewLoading.value = true
|
||||
try {
|
||||
const resp = await getOverview(
|
||||
selectedSiteId.value === ALL_SITES ? undefined : selectedSiteId.value
|
||||
)
|
||||
const resp = await getOverview({
|
||||
site: selectedSiteId.value === ALL_SITES ? undefined : selectedSiteId.value,
|
||||
range: range.value
|
||||
})
|
||||
if (id === reqSeq) overview.value = resp
|
||||
} catch {
|
||||
// 失败已由请求层统一 ElMessage 提示;overview 置空让图表/表格走空态
|
||||
// 失败已由请求层统一 ElMessage 提示;overview 置空走空态
|
||||
if (id === reqSeq) overview.value = null
|
||||
} finally {
|
||||
if (id === reqSeq) overviewLoading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
const refreshAll = () => {
|
||||
loadSites()
|
||||
// 站点切换:总览与最近访问都按站点过滤,需同时重拉
|
||||
const onSiteChange = () => {
|
||||
loadOverview()
|
||||
loadRecentVisitors()
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
loadSites()
|
||||
loadOverview()
|
||||
})
|
||||
const onlineCount = computed(() => overview.value?.header.online ?? 0)
|
||||
|
||||
// 今日五指标卡
|
||||
const statCards = computed(() => [
|
||||
{ label: '今日 PV', value: overview.value?.today.pv ?? 0, icon: 'ri:eye-line' },
|
||||
{ label: '今日 UV', value: overview.value?.today.uv ?? 0, icon: 'ri:user-line' },
|
||||
{ label: '今日会话', value: overview.value?.today.sessions ?? 0, icon: 'ri:door-open-line' },
|
||||
/** 千分位(访客数/访问次数/回访访客) */
|
||||
const fmtInt = (n: number) => n.toLocaleString('en-US')
|
||||
|
||||
// 四张周期卡:值 + 环比(delta 为 null 显示灰 —,负值红 ↓)
|
||||
const statCards = computed<{ label: string; icon: Component; value: string; delta: number | null }[]>(() => [
|
||||
{
|
||||
label: '今日新访客',
|
||||
value: overview.value?.today.new_visitors ?? 0,
|
||||
icon: 'ri:user-add-line'
|
||||
label: '访客数',
|
||||
icon: markRaw(UserIcon),
|
||||
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 trendSeries = computed(() => {
|
||||
const trend = overview.value?.trend ?? []
|
||||
return [
|
||||
{ name: '浏览量', data: trend.map((p) => p.pv) },
|
||||
{ name: '访客数', data: trend.map((p) => p.uv) },
|
||||
{ name: '会话数', data: trend.map((p) => p.sessions) },
|
||||
{ name: '新访客', data: trend.map((p) => p.new_visitors) }
|
||||
// ---------- 访问趋势双线图(主蓝=访客数 / 青=访问次数) ----------
|
||||
const trend = useChart()
|
||||
const trendRef = trend.chartRef
|
||||
const { isDark } = trend
|
||||
|
||||
const areaGradient = (hex: string, topAlpha: number) =>
|
||||
new graphic.LinearGradient(0, 0, 0, 1, [
|
||||
{ offset: 0, color: `rgba(${hex}, ${topAlpha})` },
|
||||
{ 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——
|
||||
// 颜色随 data 同序映射,渠道缺席时也不会错位到别的渠道色
|
||||
const channelData = computed(() =>
|
||||
(overview.value?.channels ?? []).map((c) => ({
|
||||
name: CHANNEL_LABELS[c.channel]?.label ?? c.channel,
|
||||
value: c.sessions
|
||||
}))
|
||||
)
|
||||
const channelColors = computed(() =>
|
||||
(overview.value?.channels ?? []).map((c) => CHANNEL_LABELS[c.channel]?.color ?? '#C0C4CC')
|
||||
// ---------- 来源分布横向条形图(条长严格按 pct,x 轴定死 0-100) ----------
|
||||
const channelChart = useChart()
|
||||
const channelRef = channelChart.chartRef
|
||||
|
||||
const renderChannelChart = () => {
|
||||
const channels = overview.value?.channels ?? []
|
||||
if (!channels.length) {
|
||||
channelChart.initChart({}, true)
|
||||
return
|
||||
}
|
||||
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 域名口径,与渠道归因互补)
|
||||
const platformSources = computed(() => overview.value?.sources ?? [])
|
||||
/** 渠道显示名(CHANNEL_LABELS 已把 direct 标为「直接或未知」;未枚举值原样) */
|
||||
const channelLabel = (channel: string): string =>
|
||||
CHANNEL_LABELS[channel]?.label ?? channel
|
||||
|
||||
// 热门页面
|
||||
const topPages = computed(() => overview.value?.top_pages ?? [])
|
||||
// 数据到位后重绘两张图(nextTick 等容器就绪;卸载销毁由 useChart 的 onUnmounted 负责)
|
||||
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>
|
||||
|
||||
<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>
|
||||
|
||||
@@ -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 django.db.models import Count, Sum
|
||||
from django.utils import timezone
|
||||
from rest_framework.views import APIView
|
||||
from rest_framework.response import Response
|
||||
from rest_framework.views import APIView
|
||||
|
||||
from tracker.api.envelope import EnvelopeMixin, ok
|
||||
from tracker.models import DailyStat, Event, Site, SourceClassification, VisitSession, Visitor
|
||||
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):
|
||||
"""上海自然日窗口,与 daily_counts 同款 make_aware。"""
|
||||
start = timezone.make_aware(datetime.combine(date, time.min))
|
||||
return start, start + timedelta(days=1)
|
||||
def _window_stats(site, start, end):
|
||||
"""窗口四指标(site=None 聚合全部站点;全口径排除爬虫)。
|
||||
|
||||
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):
|
||||
@@ -25,14 +67,25 @@ class OverviewView(EnvelopeMixin, APIView):
|
||||
if not site:
|
||||
return Response({"code": 404, "data": None,
|
||||
"message": "site_not_found"}, status=404)
|
||||
days = RANGE_DAYS.get(request.query_params.get("range", "7d"), 7)
|
||||
today = timezone.localdate()
|
||||
counts = daily_counts(site, today) # 与 refresh_daily 共享口径(Task8 审查结论)
|
||||
counts["active_now"] = self._active_now(site)
|
||||
return ok({"today": counts,
|
||||
"trend": self._trend(site, today, counts),
|
||||
"sources": self._today_sources(site, today),
|
||||
"channels": self._today_channels(site, today),
|
||||
"top_pages": self._today_pages(site, today)})
|
||||
# 当前窗口与上期等长窗口(上海自然日对齐:[T-N+1, T+1) 与 [T-2N+1, T-N+1))
|
||||
cur_start = timezone.make_aware(
|
||||
datetime.combine(today - timedelta(days=days - 1), time.min))
|
||||
cur_end = timezone.make_aware(
|
||||
datetime.combine(today + timedelta(days=1), time.min))
|
||||
prev_start = cur_start - timedelta(days=days)
|
||||
|
||||
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
|
||||
def _active_now(site):
|
||||
@@ -43,62 +96,43 @@ class OverviewView(EnvelopeMixin, APIView):
|
||||
q = q.filter(site=site)
|
||||
return q.count()
|
||||
|
||||
def _trend(self, site, today, today_counts):
|
||||
"""DailyStat 近 30 天(缺日补零;今天一位用 daily_counts 实时值覆盖——聚合作业未跑也不缺当日)。"""
|
||||
days = 30
|
||||
def _trend(self, site, today, days):
|
||||
"""窗口每日 [{date, uv, sessions}]:历史位读 DailyStat(缺日补零),
|
||||
今天一位用 daily_counts 实时值覆盖——聚合作业未跑也不缺当日。"""
|
||||
start = today - timedelta(days=days - 1)
|
||||
qs = DailyStat.objects.filter(date__gte=start, date__lte=today)
|
||||
if site is not None:
|
||||
qs = qs.filter(site=site)
|
||||
rows = {r["date"]: r for r in qs.values("date").annotate(
|
||||
pv=Sum("pv"), uv=Sum("uv"), sessions=Sum("sessions"),
|
||||
new_visitors=Sum("new_visitors"))}
|
||||
uv=Sum("uv"), sessions=Sum("sessions"))}
|
||||
out = []
|
||||
for i in range(days):
|
||||
date = start + timedelta(days=i)
|
||||
if date == today:
|
||||
counts = daily_counts(site, today)
|
||||
out.append({"date": date.isoformat(),
|
||||
**{k: today_counts[k] for k in METRICS}})
|
||||
"uv": counts["uv"], "sessions": counts["sessions"]})
|
||||
else:
|
||||
r = rows.get(date, {})
|
||||
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
|
||||
|
||||
def _today_sources(self, site, today):
|
||||
start, end = _day_bounds(today)
|
||||
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 的升级版,原始口径保留)。
|
||||
def _channels(self, site, start, end):
|
||||
"""窗口内会话按 session_entry 分类聚合(session.started_at 落窗)。
|
||||
从分类表正向聚合 + Count(distinct session):无唯一约束下分类表偶发同键重复行
|
||||
也不会把同一会话计两次。无分类行的会话(未重算的历史数据)不计入。"""
|
||||
start, end = _day_bounds(today)
|
||||
也不会把同一会话计两次。pct = 占已分类会话比(含 direct/unknown,总和 ≈ 1;
|
||||
无分类行的会话——未重算的历史数据——不计入)。"""
|
||||
q = SourceClassification.objects.filter(
|
||||
scope="session_entry",
|
||||
session__visitor__is_bot=False,
|
||||
session__started_at__gte=start, session__started_at__lt=end)
|
||||
if site is not None:
|
||||
q = q.filter(site=site)
|
||||
return [{"channel": r["channel"] or "unknown", "sessions": r["n"]}
|
||||
for r in q.values("channel")
|
||||
rows = list(q.values("channel")
|
||||
.annotate(n=Count("session", distinct=True))
|
||||
.order_by("-n", "channel")[:8]] # 平局时次序跨请求稳定
|
||||
|
||||
def _today_pages(self, site, today):
|
||||
start, end = _day_bounds(today)
|
||||
q = Event.objects.filter(is_bot=False, event_name="page_view",
|
||||
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]] # 平局时次序跨请求稳定
|
||||
.order_by("-n", "channel")) # 平局时次序跨请求稳定
|
||||
total = sum(r["n"] for r in rows)
|
||||
return [{"channel": r["channel"] or "unknown", "sessions": r["n"],
|
||||
"pct": round(r["n"] / total, 4) if total else 0.0}
|
||||
for r in rows]
|
||||
|
||||
@@ -37,7 +37,8 @@ def _iso(dt):
|
||||
|
||||
def _row(visitor, last_session, online_cutoff, ft=None):
|
||||
"""列表/详情共用行字段。last_source 依赖最近会话(由调用方批量取好传入,防 N+1)。
|
||||
channel 等归因五字段来自该访客 first_touch 分类(无分类行 → 空串,前端回落 first_source 展示)。"""
|
||||
channel 等归因五字段来自该访客 first_touch 分类(无分类行 → 空串,前端回落 first_source 展示)。
|
||||
last_path 是最近一条事件的 path(_annotate 的 Subquery 注解,同样无 N+1)。"""
|
||||
return {
|
||||
"visitor_id": visitor.visitor_id,
|
||||
# 站点归属: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)
|
||||
if last_session else "直接访问", # 无会话访客(仅采集异常场景)
|
||||
"first_landing_path": visitor.first_landing_path, # 列表首触来源 tooltip 用(详情本就输出)
|
||||
"last_path": visitor.last_event_path or "", # 最近一条事件 path(总览"浏览页面"列)
|
||||
"channel": ft.channel if ft else "",
|
||||
"platform": ft.platform if ft else "",
|
||||
"variant": ft.variant if ft else "",
|
||||
@@ -87,18 +89,23 @@ def _session_summary(s):
|
||||
|
||||
|
||||
def _annotate(qs):
|
||||
"""会话数/有效停留/最近会话标记一次注解到位。
|
||||
"""会话数/有效停留/最近会话/最近事件路径一次注解到位。
|
||||
|
||||
last_source 不逐行 sessions.order_by("-started_at").first()(页大小 20 即 N+1):
|
||||
先用相关子查询把每个访客最近会话的 pk 标到行上(走 (visitor,-started_at) 索引),
|
||||
分页后一次 IN 查询取回整页会话——全程只多 2 条 SQL。
|
||||
last_path 同理用相关子查询取最近一条事件的 path(走 (site,visitor,-occurred_at) 索引),
|
||||
-pk 平局次序跨请求稳定;无事件访客注解为 NULL,_row 收敛为空串。
|
||||
"""
|
||||
latest = (VisitSession.objects.filter(visitor=OuterRef("pk"))
|
||||
.order_by("-started_at", "-pk")) # -pk 平局次序跨请求稳定
|
||||
last_event = (Event.objects.filter(visitor=OuterRef("pk"))
|
||||
.order_by("-occurred_at", "-pk"))
|
||||
return qs.annotate(
|
||||
session_count=Count("sessions"),
|
||||
total_stay_ms=Sum("sessions__stay_ms"), # 有效停留口径(非 duration_ms)
|
||||
latest_session_pk=Subquery(latest.values("pk")[:1]),
|
||||
last_event_path=Subquery(last_event.values("path")[:1]),
|
||||
)
|
||||
|
||||
|
||||
|
||||
@@ -14,6 +14,8 @@ User = get_user_model()
|
||||
NOW = timezone.localtime(timezone.now())
|
||||
TODAY = NOW.date()
|
||||
YESTERDAY = TODAY - timedelta(days=1)
|
||||
AT_3D = NOW - timedelta(days=3) # 7d 当前窗口内(近 3 天)
|
||||
AT_10D = NOW - timedelta(days=10) # 7d 上期窗口内(前 7 天)
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
@@ -32,16 +34,23 @@ def _auth(client, user):
|
||||
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)
|
||||
if last_seen is not None: # last_seen_at 是 auto_now_add,只能 queryset.update 绕开
|
||||
Visitor.objects.filter(pk=v.pk).update(last_seen_at=last_seen)
|
||||
# first/last_seen_at 是 auto_now_add,只能 queryset.update 绕开
|
||||
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
|
||||
|
||||
|
||||
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,
|
||||
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"):
|
||||
@@ -55,106 +64,174 @@ def test_overview_requires_auth(client):
|
||||
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)
|
||||
site = Site.objects.create(name="s", domains=["a.com"])
|
||||
v1 = _visitor(site, "v1", last_seen=timezone.now()) # 活跃(5 分钟内)
|
||||
v2 = _visitor(site, "v2", last_seen=timezone.now() - timedelta(hours=1)) # 不活跃
|
||||
s1 = _session(site, v1, "s1", referrer="google.com")
|
||||
s2 = _session(site, v2, "s2", referrer="google.com")
|
||||
s3 = _session(site, v1, "s3", referrer="") # 空来源 → 直接访问
|
||||
_events(site, v1, s1, 6, "/pricing", prefix="p") # /pricing x6
|
||||
_events(site, v1, s3, 2, "/", prefix="r") # / x2
|
||||
_events(site, v2, s2, 2, "/docs", prefix="d") # /docs x2
|
||||
# 本期(近 7 天):v1/v2 今天首访(新访客),v3 是 10 天前的老访客 3 天前回访
|
||||
v1 = _visitor(site, "v1", last_seen=timezone.now())
|
||||
v2 = _visitor(site, "v2", last_seen=timezone.now())
|
||||
v3 = _visitor(site, "v3", last_seen=timezone.now(), first_seen=AT_10D)
|
||||
s1 = _session(site, v1, "s1", stay_ms=60000)
|
||||
s2 = _session(site, v2, "s2", stay_ms=30000)
|
||||
s3 = _session(site, v3, "s3", at=AT_3D) # stay 0
|
||||
_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
|
||||
d = r.json()["data"]
|
||||
assert d["today"] == {"pv": 10, "uv": 2, "sessions": 3,
|
||||
"new_visitors": 2, "active_now": 1}
|
||||
# 本期:visitors=3(v1/v2/v3)、visits=3、avg=(60k+30k+0)/3=30k、returning=3-2 新=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"]
|
||||
assert len(trend) == 30
|
||||
assert trend[-1]["date"] == TODAY.isoformat()
|
||||
assert trend[-1]["pv"] == 10 # 今天位用实时值
|
||||
assert trend[-2]["pv"] == 0 # 昨天无 DailyStat → 补零
|
||||
assert d["sources"][0] == {"source": "google.com", "sessions": 2}
|
||||
assert d["sources"][-1] == {"source": "直接访问", "sessions": 1}
|
||||
assert d["top_pages"][0] == {"path": "/pricing", "pv": 6}
|
||||
assert len(trend) == 7
|
||||
assert trend[0]["date"] == (TODAY - timedelta(days=6)).isoformat()
|
||||
assert trend[-1] == {"date": TODAY.isoformat(), "uv": 2, "sessions": 2}
|
||||
assert trend[-2] == {"date": YESTERDAY.isoformat(), "uv": 0, "sessions": 0}
|
||||
assert d["channels"] == [] # 无分类行 → 空列表
|
||||
|
||||
|
||||
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):
|
||||
"""7d 趋势历史位读 DailyStat 聚合(跨站求和),今天位用实时值。"""
|
||||
_auth(client, member_user)
|
||||
site_a = Site.objects.create(name="a", domains=["a.com"])
|
||||
site_b = Site.objects.create(name="b", domains=["b.com"])
|
||||
yn = NOW - timedelta(days=1) # 昨天同一本地时刻,必落上海昨日窗口
|
||||
# a 站昨天 3 事件走真实 refresh_daily 落 DailyStat
|
||||
va = Visitor.objects.create(site=site_a, visitor_id="va")
|
||||
sa = _session(site_a, va, "sa", at=yn)
|
||||
_events(site_a, va, sa, 3, "/", at=yn, prefix="ya")
|
||||
refresh_daily(site_a, YESTERDAY)
|
||||
# b 站昨天 4 事件同样走 refresh_daily——两站各落一行,验证 site=None 的跨站 Sum
|
||||
vb = Visitor.objects.create(site=site_b, visitor_id="vb")
|
||||
sb = _session(site_b, vb, "sb", at=yn)
|
||||
_events(site_b, vb, sb, 4, "/", at=yn, prefix="yb")
|
||||
refresh_daily(site_b, YESTERDAY)
|
||||
|
||||
d = client.get("/api/admin/dashboard/overview").json()["data"]
|
||||
assert d["trend"][-2] == {"date": YESTERDAY.isoformat(), "pv": 7, "uv": 2,
|
||||
"sessions": 2, "new_visitors": 0} # 3+4 跨站点求和
|
||||
assert d["trend"][-1]["pv"] == 0 # 今天无事件,实时覆盖为 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} # 原口径保留
|
||||
trend = d["trend"]
|
||||
assert len(trend) == 7
|
||||
assert trend[-2] == {"date": YESTERDAY.isoformat(), "uv": 2, "sessions": 2} # 跨站求和
|
||||
assert trend[-1] == {"date": TODAY.isoformat(), "uv": 0, "sessions": 0} # 今天无事件
|
||||
|
||||
|
||||
def test_overview_site_filter_and_404(client, member_user):
|
||||
_auth(client, member_user)
|
||||
site_a = Site.objects.create(name="a", domains=["a.com"])
|
||||
site_b = Site.objects.create(name="b", domains=["b.com"])
|
||||
va = _visitor(site_a, "va")
|
||||
vb = _visitor(site_b, "vb")
|
||||
va = _visitor(site_a, "va", last_seen=timezone.now())
|
||||
vb = _visitor(site_b, "vb", last_seen=timezone.now())
|
||||
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_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 时不得混入
|
||||
yn = NOW - timedelta(days=1)
|
||||
_events(site_a, va, sa, 3, "/", at=yn, prefix="ay")
|
||||
refresh_daily(site_a, YESTERDAY)
|
||||
|
||||
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"]
|
||||
assert d["today"]["pv"] == 5 and d["today"]["uv"] == 1
|
||||
assert d["today"]["active_now"] == 1 # 两访客 last_seen 均为 now,但只计本站
|
||||
assert d["sources"] == [{"source": "直接访问", "sessions": 1}]
|
||||
assert d["top_pages"][0] == {"path": "/b", "pv": 5}
|
||||
assert d["trend"][-2]["pv"] == 0 # a 站昨日行被站点过滤排除
|
||||
assert d["trend"][-1]["pv"] == 5 # 今天位仍是本站实时值
|
||||
assert d["cards"]["visitors"]["value"] == 1 and d["cards"]["visits"]["value"] == 1
|
||||
assert d["header"]["online"] == 1 # 只计本站
|
||||
assert d["channels"] == [{"channel": "direct", "sessions": 1,
|
||||
"pct": pytest.approx(1.0)}]
|
||||
assert d["trend"][-2] == {"date": YESTERDAY.isoformat(), "uv": 0, "sessions": 0}
|
||||
assert d["trend"][-1] == {"date": TODAY.isoformat(), "uv": 1, "sessions": 1}
|
||||
|
||||
r = client.get("/api/admin/dashboard/overview", {"site": "site_nope"})
|
||||
assert r.status_code == 404
|
||||
|
||||
@@ -78,8 +78,8 @@ def test_list_fields_and_default_bot_excluded(client, member_user):
|
||||
"device_type", "os", "browser", "language", "first_seen_at",
|
||||
"last_seen_at", "visit_count", "active_days", "session_count",
|
||||
"total_stay_ms", "first_source", "last_source", "first_landing_path",
|
||||
"online", "channel", "platform", "variant", "search_term", "resolution",
|
||||
"device_model"}
|
||||
"last_path", "online", "channel", "platform", "variant", "search_term",
|
||||
"resolution", "device_model"}
|
||||
assert row["country"] == "中国" and row["os"] == "Windows" and row["browser"] == "Chrome"
|
||||
assert row["session_count"] == 2
|
||||
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"] # 只剩本站访客
|
||||
|
||||
|
||||
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):
|
||||
_auth(client, member_user)
|
||||
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["first_source"] == "google.com"
|
||||
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"] # 最近在前
|
||||
srow = d["sessions"][0]
|
||||
assert set(srow) == {"session_id", "started_at", "ended_at", "referrer_domain", "utm",
|
||||
|
||||
Reference in New Issue
Block a user