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'
/**
* 总览 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 })
+2
View File
@@ -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
+23
View File
@@ -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))
+638 -147
View File
@@ -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"
>
<div
v-for="card in statCards"
:key="card.label"
class="art-card relative h-30 flex flex-col justify-center px-5"
>
<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>
<!-- 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>
<ElTable :data="platformSources" size="small" max-height="240">
<ElTableColumn prop="source" label="来源域名" min-width="160" show-overflow-tooltip>
<template #default="{ row }">
<span class="text-xs">{{ row.source }}</span>
</template>
</ElTableColumn>
<ElTableColumn prop="sessions" label="会话数" width="90" align="right" />
</ElTable>
<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'"
>
{{ 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="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 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>
<ElTable :data="topPages" height="20rem">
<ElTableColumn type="index" label="#" width="50" />
<ElTableColumn prop="path" label="页面路径" show-overflow-tooltip min-width="200">
<template #default="{ row }">
<span class="font-mono text-xs">{{ row.path }}</span>
</template>
</ElTableColumn>
<ElTableColumn prop="pv" label="PV" width="80" align="right" sortable />
</ElTable>
<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"
>
<ElTableColumn label="访客" min-width="210">
<template #default="{ row }">
<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 label="本次来源" min-width="150" show-overflow-tooltip>
<template #default="{ row }">
<span class="db-source-text">{{ row.last_source || '—' }}</span>
</template>
</ElTableColumn>
<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>
</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) }
}
]
})
}
// ---------- 来源分布横向条形图(条长严格按 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))}%`
}
}
]
})
}
/** 渠道显示名(CHANNEL_LABELS 已把 direct 标为「直接或未知」;未枚举值原样) */
const channelLabel = (channel: string): string =>
CHANNEL_LABELS[channel]?.label ?? channel
// 数据到位后重绘两张图(nextTick 等容器就绪;卸载销毁由 useChart 的 onUnmounted 负责)
watch(overview, () => {
nextTick(() => {
renderTrendChart()
renderChannelChart()
})
})
// 渠道构成饼图(归因口径):标签/颜色都按渠道绑定取自 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')
)
// ---------- 最近访问(前 5 行,既有访客列表接口) ----------
const recentVisitors = ref<VisitorRow[]>([])
const visitorsLoading = ref(false)
let visitorsSeq = 0
// 平台明细(原始 referrer 域名口径,与渠道归因互补)
const platformSources = computed(() => overview.value?.sources ?? [])
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 topPages = computed(() => overview.value?.top_pages ?? [])
/** 渠道徽章配色:渠道色 → 文字/浅底/描边(与列表/详情同款) */
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>