Files
fangke/admin/src/views/tracker/dashboard/index.vue
T

714 lines
20 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.
<!-- 访客统计 · 数据总览(客迹视觉 V1:周期四卡 + 趋势双线 + 渠道条形 + 最近访问) -->
<template>
<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: 220px"
:loading="sitesLoading"
@change="onSiteChange"
>
<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>
<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>
<!-- 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'"
>
{{ 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"
>
<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 { 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 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' })
// 客迹视觉 V1 图表用色(双线:主蓝 + 青;与详情页色板同源)
const TREND_COLORS = { uv: '#2563EB', sessions: '#06B6D4' }
const RANGE_HINTS: Record<OverviewRange, string> = {
today: '今天',
'7d': '近 7 天',
'30d': '近 30 天'
}
// ---------- 站点 / 周期筛选 ----------
// 哨兵值 ALL_SITES = 全部站点(空串会被 ElSelect 当空值显示 placeholder)
const ALL_SITES = 'ALL_SITES'
const sites = ref<SiteRow[]>([])
const sitesLoading = ref(false)
const selectedSiteId = ref(ALL_SITES)
const range = ref<OverviewRange>('7d')
const loadSites = async () => {
sitesLoading.value = true
try {
sites.value = await getSites()
} catch {
// 失败已由请求层统一 ElMessage 提示,下拉保持"全部站点"
} finally {
sitesLoading.value = false
}
}
// ---------- 总览(周期卡 + 趋势 + 渠道) ----------
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({
site: selectedSiteId.value === ALL_SITES ? undefined : selectedSiteId.value,
range: range.value
})
if (id === reqSeq) overview.value = resp
} catch {
// 失败已由请求层统一 ElMessage 提示;overview 置空走空态
if (id === reqSeq) overview.value = null
} finally {
if (id === reqSeq) overviewLoading.value = false
}
}
// 站点切换:总览与最近访问都按站点过滤,需同时重拉
const onSiteChange = () => {
loadOverview()
loadRecentVisitors()
}
const onlineCount = computed(() => overview.value?.header.online ?? 0)
/** 千分位(访客数/访问次数/回访访客) */
const fmtInt = (n: number) => n.toLocaleString('en-US')
// 四张周期卡:值 + 环比(delta 为 null 显示灰 —,负值红 ↓)
const statCards = computed<{ label: string; icon: Component; value: string; delta: number | null }[]>(() => [
{
label: '访客数',
icon: markRaw(UserIcon),
value: fmtInt(overview.value?.cards.visitors.value ?? 0),
delta: overview.value?.cards.visitors.delta_pct ?? null
},
{
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
}
])
// ---------- 访问趋势双线图(主蓝=访客数 / 青=访问次数) ----------
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()
})
})
// ---------- 最近访问(前 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>