2026-09-15 18:32:25 +08:00
|
|
|
|
<!-- 访客统计 · 数据总览 -->
|
2026-09-15 17:50:36 +08:00
|
|
|
|
<template>
|
2026-09-15 18:32:25 +08:00
|
|
|
|
<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>
|
2026-09-16 17:19:31 +08:00
|
|
|
|
<!-- 宽度用内联 style:EP 未分层 width 规则会压过 Tailwind utilities 层的 w-* 类 -->
|
2026-09-15 18:32:25 +08:00
|
|
|
|
<ElSelect
|
|
|
|
|
|
v-model="selectedSiteId"
|
2026-09-16 17:19:31 +08:00
|
|
|
|
style="width: 256px"
|
2026-09-15 18:32:25 +08:00
|
|
|
|
:loading="sitesLoading"
|
|
|
|
|
|
@change="loadOverview"
|
|
|
|
|
|
>
|
|
|
|
|
|
<ElOption label="全部站点" value="ALL_SITES" />
|
|
|
|
|
|
<ElOption
|
|
|
|
|
|
v-for="site in sites"
|
|
|
|
|
|
:key="site.id"
|
|
|
|
|
|
:label="`${site.name}(${site.site_id})`"
|
|
|
|
|
|
:value="site.site_id"
|
|
|
|
|
|
/>
|
|
|
|
|
|
</ElSelect>
|
|
|
|
|
|
<ElTooltip content="刷新数据" placement="top">
|
|
|
|
|
|
<ElButton circle :icon="RefreshIcon" :loading="overviewLoading" @click="refreshAll" />
|
|
|
|
|
|
</ElTooltip>
|
|
|
|
|
|
</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">
|
2026-09-16 17:19:31 +08:00
|
|
|
|
<h3 class="m-0 text-sm font-semibold">渠道构成(今日 TOP8)</h3>
|
|
|
|
|
|
<span class="text-xs text-g-500">按归因规则聚合会话数,未重算的历史会话不计入</span>
|
2026-09-15 18:32:25 +08:00
|
|
|
|
</div>
|
|
|
|
|
|
<ArtRingChart
|
2026-09-16 17:19:31 +08:00
|
|
|
|
:data="channelData"
|
|
|
|
|
|
:colors="channelColors"
|
2026-09-15 18:32:25 +08:00
|
|
|
|
:loading="overviewLoading"
|
2026-09-16 17:19:31 +08:00
|
|
|
|
height="15rem"
|
2026-09-15 18:32:25 +08:00
|
|
|
|
show-legend
|
|
|
|
|
|
legend-position="right"
|
|
|
|
|
|
/>
|
2026-09-16 17:19:31 +08:00
|
|
|
|
<!-- 平台明细:原始 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 }">
|
|
|
|
|
|
<span class="text-xs">{{ row.source }}</span>
|
|
|
|
|
|
</template>
|
|
|
|
|
|
</ElTableColumn>
|
|
|
|
|
|
<ElTableColumn prop="sessions" label="会话数" width="90" align="right" />
|
|
|
|
|
|
</ElTable>
|
|
|
|
|
|
</div>
|
2026-09-15 18:32:25 +08:00
|
|
|
|
</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 }">
|
|
|
|
|
|
<span class="font-mono text-xs">{{ row.path }}</span>
|
|
|
|
|
|
</template>
|
|
|
|
|
|
</ElTableColumn>
|
|
|
|
|
|
<ElTableColumn prop="pv" label="PV" width="80" align="right" sortable />
|
|
|
|
|
|
</ElTable>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
</div>
|
2026-09-15 17:50:36 +08:00
|
|
|
|
</div>
|
|
|
|
|
|
</template>
|
|
|
|
|
|
|
|
|
|
|
|
<script setup lang="ts">
|
2026-09-15 18:32:25 +08:00
|
|
|
|
import { Refresh as RefreshIcon } from '@element-plus/icons-vue'
|
|
|
|
|
|
import { useChartOps } from '@/hooks/core/useChart'
|
|
|
|
|
|
import { getSites, type SiteRow } from '@/api/tracker/sites'
|
|
|
|
|
|
import { getOverview, type OverviewResp } from '@/api/tracker/dashboard'
|
2026-09-16 17:19:31 +08:00
|
|
|
|
import { CHANNEL_LABELS } from '@/utils/tracker'
|
2026-09-15 18:32:25 +08:00
|
|
|
|
|
|
|
|
|
|
defineOptions({ name: 'TrackerDashboard' })
|
|
|
|
|
|
|
|
|
|
|
|
/** 图表色板:模板主题色为主,剔除过浅色与重复色(useChartOps 默认第 3 位 #EDF2FF 在浅色底不可见) */
|
|
|
|
|
|
const themeColor = useChartOps().themeColor
|
|
|
|
|
|
const lineColors = [themeColor, '#4ABEFF', '#14DEBA', '#FFAF20']
|
|
|
|
|
|
|
|
|
|
|
|
// 站点筛选(哨兵值 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 loadSites = async () => {
|
|
|
|
|
|
sitesLoading.value = true
|
|
|
|
|
|
try {
|
|
|
|
|
|
sites.value = await getSites()
|
|
|
|
|
|
} catch {
|
|
|
|
|
|
// 失败已由请求层统一 ElMessage 提示,下拉保持"全部站点"
|
|
|
|
|
|
} finally {
|
|
|
|
|
|
sitesLoading.value = false
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-09-15 18:57:41 +08:00
|
|
|
|
// 请求令牌:快速切换站点时丢弃过期响应,避免旧数据覆盖新数据/旧 finally 提前清 loading
|
|
|
|
|
|
let reqSeq = 0
|
2026-09-15 18:32:25 +08:00
|
|
|
|
const loadOverview = async () => {
|
2026-09-15 18:57:41 +08:00
|
|
|
|
const id = ++reqSeq
|
2026-09-15 18:32:25 +08:00
|
|
|
|
overviewLoading.value = true
|
|
|
|
|
|
try {
|
2026-09-15 18:57:41 +08:00
|
|
|
|
const resp = await getOverview(
|
2026-09-15 18:32:25 +08:00
|
|
|
|
selectedSiteId.value === ALL_SITES ? undefined : selectedSiteId.value
|
|
|
|
|
|
)
|
2026-09-15 18:57:41 +08:00
|
|
|
|
if (id === reqSeq) overview.value = resp
|
2026-09-15 18:32:25 +08:00
|
|
|
|
} catch {
|
|
|
|
|
|
// 失败已由请求层统一 ElMessage 提示;overview 置空让图表/表格走空态
|
2026-09-15 18:57:41 +08:00
|
|
|
|
if (id === reqSeq) overview.value = null
|
2026-09-15 18:32:25 +08:00
|
|
|
|
} finally {
|
2026-09-15 18:57:41 +08:00
|
|
|
|
if (id === reqSeq) overviewLoading.value = false
|
2026-09-15 18:32:25 +08:00
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
const refreshAll = () => {
|
|
|
|
|
|
loadSites()
|
|
|
|
|
|
loadOverview()
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
onMounted(() => {
|
|
|
|
|
|
loadSites()
|
|
|
|
|
|
loadOverview()
|
|
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
|
|
// 今日五指标卡
|
|
|
|
|
|
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' },
|
|
|
|
|
|
{
|
|
|
|
|
|
label: '今日新访客',
|
|
|
|
|
|
value: overview.value?.today.new_visitors ?? 0,
|
|
|
|
|
|
icon: 'ri:user-add-line'
|
|
|
|
|
|
},
|
|
|
|
|
|
{ label: '在线访客', value: overview.value?.today.active_now ?? 0, icon: 'ri:pulse-line' }
|
|
|
|
|
|
])
|
|
|
|
|
|
|
|
|
|
|
|
// 趋势折线: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) }
|
|
|
|
|
|
]
|
|
|
|
|
|
})
|
|
|
|
|
|
|
2026-09-16 17:19:31 +08:00
|
|
|
|
// 渠道构成饼图(归因口径):标签/颜色都按渠道绑定取自 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')
|
2026-09-15 18:32:25 +08:00
|
|
|
|
)
|
|
|
|
|
|
|
2026-09-16 17:19:31 +08:00
|
|
|
|
// 平台明细(原始 referrer 域名口径,与渠道归因互补)
|
|
|
|
|
|
const platformSources = computed(() => overview.value?.sources ?? [])
|
|
|
|
|
|
|
2026-09-15 18:32:25 +08:00
|
|
|
|
// 热门页面
|
|
|
|
|
|
const topPages = computed(() => overview.value?.top_pages ?? [])
|
2026-09-15 17:50:36 +08:00
|
|
|
|
</script>
|