Files
fangke/admin/src/views/tracker/dashboard/index.vue
T
浅念 f9c8c354ae feat(admin): 来源归因展示——渠道徽章/筛选/来源识别卡/渠道构成图
- 类型:VisitorRow 补 channel/platform/variant/search_term/resolution,新增
  AttributionInfo/AttributionBlock,详情响应带 attribution 块;dashboard
  响应补 channels(ChannelItem)
- 工具:CHANNEL_LABELS 八渠道元数据 + channelBadge 徽章串(搜索词/标签/
  冲突后缀,未归因回 —)
- 访客列表:首触来源列升级渠道徽章(first_source 进 tooltip 兜底),
  筛选栏加渠道下拉(channel= 枚举)
- 访客详情:首触来源升级来源识别卡(渠道徽章/平台/国家站/搜索词显著
  展示,label_only/conflict 诚实标注,冲突并列两路证据文本渲染),
  无归因旧数据降级原 first_source 口径
- 总览:来源饼图改渠道构成(标签/颜色按渠道绑定 CHANNEL_LABELS),
  下方保留平台明细小表(原 sources 域名口径)

Co-Authored-By: Claude Code <noreply@anthropic.com>
2026-09-16 17:19:31 +08:00

223 lines
8.5 KiB
Vue
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<!-- 访客统计 · 数据总览 -->
<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>
<!-- 宽度用内联 style:EP 未分层 width 规则会压过 Tailwind utilities 层的 w-* 类 -->
<ElSelect
v-model="selectedSiteId"
style="width: 256px"
: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">
<h3 class="m-0 text-sm font-semibold">渠道构成(今日 TOP8)</h3>
<span class="text-xs text-g-500">按归因规则聚合会话数,未重算的历史会话不计入</span>
</div>
<ArtRingChart
:data="channelData"
:colors="channelColors"
:loading="overviewLoading"
height="15rem"
show-legend
legend-position="right"
/>
<!-- 平台明细:原始 referrer 域名口径(与渠道归因互补,域名级下钻) -->
<div class="mt-4" v-loading="overviewLoading">
<div class="flex-c flex-wrap gap-2 mb-2">
<h4 class="m-0 text-xs font-semibold text-g-600">平台明细(今日 TOP8)</h4>
<span class="text-xs text-g-500">无 referrer 归为"直接访问"</span>
</div>
<ElTable :data="platformSources" size="small" max-height="240">
<ElTableColumn prop="source" label="来源域名" min-width="160" show-overflow-tooltip>
<template #default="{ row }">
<span class="text-xs">{{ row.source }}</span>
</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">
<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>
</div>
</template>
<script setup lang="ts">
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'
import { CHANNEL_LABELS } from '@/utils/tracker'
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
}
}
// 请求令牌:快速切换站点时丢弃过期响应,避免旧数据覆盖新数据/旧 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
)
if (id === reqSeq) overview.value = resp
} catch {
// 失败已由请求层统一 ElMessage 提示;overview 置空让图表/表格走空态
if (id === reqSeq) overview.value = null
} finally {
if (id === reqSeq) overviewLoading.value = false
}
}
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) }
]
})
// 渠道构成饼图(归因口径):标签/颜色都按渠道绑定取自 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')
)
// 平台明细(原始 referrer 域名口径,与渠道归因互补)
const platformSources = computed(() => overview.value?.sources ?? [])
// 热门页面
const topPages = computed(() => overview.value?.top_pages ?? [])
</script>