Files
fangke/admin/src/views/tracker/dashboard/index.vue
T
浅念 5b3297337e fix: 总览筛选竞态守卫 + 时间戳统一上海时间序列化
- dashboard loadOverview 加请求令牌:快速切换站点时丢弃过期响应,
  防旧数据覆盖与旧 finally 提前清 loading
- 后端序列化统一 timezone.localtime:sites.created_at 与 visitors 的
  first/last_seen_at、会话 started/ended_at、时间线事件 time——
  USE_TZ 下 isoformat() 是 UTC,与上海自然日口径同屏矛盾(差 8 小时)
- 站点弹窗域名加轻量主机名正则校验与实时提示(拦含空格等垃圾输入)

Co-Authored-By: Claude Code <noreply@anthropic.com>
2026-09-15 18:57:41 +08:00

206 lines
7.1 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>
<ElSelect
v-model="selectedSiteId"
class="w-64"
: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">按会话数统计,无referrer 归为"直接访问"</span>
</div>
<ArtRingChart
:data="sourceData"
:colors="pieColors"
:loading="overviewLoading"
height="20rem"
show-legend
legend-position="right"
/>
</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'
defineOptions({ name: 'TrackerDashboard' })
/** 图表色板:模板主题色为主,剔除过浅色与重复色(useChartOps 默认第 3 位 #EDF2FF 在浅色底不可见) */
const themeColor = useChartOps().themeColor
const lineColors = [themeColor, '#4ABEFF', '#14DEBA', '#FFAF20']
const pieColors = [
themeColor,
'#4ABEFF',
'#14DEBA',
'#FFAF20',
'#FA8A6C',
'#67C23A',
'#E6A23C',
'#9AA5CE'
]
// 站点筛选(哨兵值 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) }
]
})
// 来源饼图("直接访问"由后端天然归并)
const sourceData = computed(() =>
(overview.value?.sources ?? []).map((s) => ({ name: s.source, value: s.sessions }))
)
// 热门页面
const topPages = computed(() => overview.value?.top_pages ?? [])
</script>