feat(admin): 访客详情模板重组三栏Tabs——时间线/会话/原始数据合并左栏

Co-Authored-By: Claude Code <noreply@anthropic.com>
This commit is contained in:
浅念
2026-09-18 16:40:24 +08:00
parent 47e8f1bd01
commit 550e180318
+202 -201
View File
@@ -1,4 +1,4 @@
<!-- 访客统计 · 访客详情(客迹视觉 V1:头部卡 + 4 统计卡 + 双栏时间线/来源证据 + 会话与原始数据) -->
<!-- 访客统计 · 访客详情(客迹视觉 V1:头部卡 + 三栏=时间线/会话/原始数据 Tab + 统计卡/来源证据 + 访问环境) -->
<!-- keepAlive=true 且路由 path 固定:访客切换复用同一实例,刷新依赖 onActivated + query watch -->
<template>
<div class="visitor-detail-page p-5">
@@ -59,113 +59,211 @@
<ElEmpty v-else-if="!profileLoading" description="访客数据加载失败或不存在" />
</div>
<!-- 4 统计卡:图标块 + 小标签 + 大数字 -->
<div class="vd-stats">
<div v-for="tile in statTiles" :key="tile.label" class="vd-card vd-stat-card">
<div class="vd-stat-icon">
<ElIcon :size="20">
<component :is="tile.icon" />
</ElIcon>
</div>
<div class="vd-stat-body">
<span class="vd-stat-label">{{ tile.label }}</span>
<span class="vd-stat-value">{{ tile.value }}</span>
</div>
</div>
</div>
<!-- 主体双栏:时间线(左) + 来源证据/访问环境(右) -->
<!-- 主体三栏:左=时间线/会话/原始数据三 Tab,中=统计卡+来源证据,右=访问环境 -->
<div class="vd-main">
<!-- 访问时间线 -->
<div class="vd-card vd-timeline-card">
<div class="vd-card-head">
<div class="vd-card-title">
<ElIcon :size="16"><ClockIcon /></ElIcon>
<span>访问时间线</span>
</div>
<div class="vd-card-tools">
<span class="vd-tool-label">时间范围</span>
<ElRadioGroup v-model="days" size="small" @change="loadTimeline()">
<ElRadioButton :value="7">近 7 天</ElRadioButton>
<ElRadioButton :value="30">近 30 天</ElRadioButton>
<ElRadioButton :value="90">近 90 天</ElRadioButton>
</ElRadioGroup>
<!-- 正序/倒序真实切换:倒序=日期组与组内事件都最新在前;正序=都最早在前 -->
<div class="vd-order-toggle" role="group" aria-label="时间线排序">
<button
type="button"
class="vd-order-btn"
:class="{ 'is-active': order === 'desc' }"
@click="order = 'desc'"
>
倒序
</button>
<button
type="button"
class="vd-order-btn"
:class="{ 'is-active': order === 'asc' }"
@click="order = 'asc'"
>
正序
</button>
</div>
</div>
</div>
<ElAlert
v-if="timeline?.truncated"
type="warning"
show-icon
:closable="false"
class="mb-3"
title="事件较多,仅显示最近 1000 条"
/>
<div v-loading="timelineLoading" class="tl-body">
<template v-if="orderedDays.length">
<div v-for="day in orderedDays" :key="day.date" class="tl-day">
<!-- 日期组头:蓝点 + 09月16日 + 今天 tag -->
<div class="tl-day-header">
<i class="tl-dot" />
<span class="tl-date">{{ formatDayLabel(day.date) }}</span>
<span v-if="isToday(day.date)" class="tl-today-tag">今天</span>
<span class="tl-day-meta">{{ day.sessions.length }} 次会话</span>
<!-- 左栏:三 Tab(访问时间线 / 会话 / 原始数据) -->
<div class="vd-card vd-tabs-card">
<ElTabs v-model="activeTab" class="detail-tabs">
<!-- Tab 1:访问时间线 -->
<ElTabPane label="访问时间线" name="timeline">
<!-- 工具行:原卡头工具迁入(标题职能由 tab label 承担) -->
<div class="vd-tl-tools">
<span class="vd-tool-label">时间范围</span>
<ElRadioGroup v-model="days" size="small" @change="loadTimeline()">
<ElRadioButton :value="7">近 7 天</ElRadioButton>
<ElRadioButton :value="30">近 30 天</ElRadioButton>
<ElRadioButton :value="90">近 90 天</ElRadioButton>
</ElRadioGroup>
<!-- 正序/倒序真实切换:倒序=日期组与组内事件都最新在前;正序=都最早在前 -->
<div class="vd-order-toggle" role="group" aria-label="时间线排序">
<button
type="button"
class="vd-order-btn"
:class="{ 'is-active': order === 'desc' }"
@click="order = 'desc'"
>
倒序
</button>
<button
type="button"
class="vd-order-btn"
:class="{ 'is-active': order === 'asc' }"
@click="order = 'asc'"
>
正序
</button>
</div>
</div>
<!-- 会话块:会话头(时间·来源·停留)+ 事件序列 -->
<div
v-for="(session, si) in day.sessions"
:key="session.session_id ?? `orphan-${si}`"
class="tl-session"
>
<div class="tl-session-header">
<span class="tl-session-time">
{{ session.started_at ? formatTime(session.started_at) : '未知会话' }}
</span>
<span class="tl-session-meta">{{ timelineSessionSource(session) }}</span>
<span class="tl-session-meta">停留 {{ formatStay(sessionStayMs(session)) }}</span>
<ElAlert
v-if="timeline?.truncated"
type="warning"
show-icon
:closable="false"
class="mb-3"
title="事件较多,仅显示最近 1000 条"
/>
<div v-loading="timelineLoading" class="tl-body">
<template v-if="orderedDays.length">
<div v-for="day in orderedDays" :key="day.date" class="tl-day">
<!-- 日期组头:蓝点 + 09月16日 + 今天 tag -->
<div class="tl-day-header">
<i class="tl-dot" />
<span class="tl-date">{{ formatDayLabel(day.date) }}</span>
<span v-if="isToday(day.date)" class="tl-today-tag">今天</span>
<span class="tl-day-meta">{{ day.sessions.length }} 次会话</span>
</div>
<!-- 会话块:会话头(时间·来源·停留)+ 事件序列 -->
<div
v-for="(session, si) in day.sessions"
:key="session.session_id ?? `orphan-${si}`"
class="tl-session"
>
<div class="tl-session-header">
<span class="tl-session-time">
{{ session.started_at ? formatTime(session.started_at) : '未知会话' }}
</span>
<span class="tl-session-meta">{{ timelineSessionSource(session) }}</span>
<span class="tl-session-meta">
停留 {{ formatStay(sessionStayMs(session)) }}
</span>
</div>
<EventTimeline :events="session.events" />
</div>
</div>
<EventTimeline :events="session.events" />
</div>
</template>
<!-- 失败:请求已结束但无数据对象(首屏加载中不显示,避免闪现) -->
<ElEmpty
v-else-if="timeline === null && !timelineLoading"
description="时间线加载失败"
:image-size="80"
/>
<!-- 成功但窗口内无事件 -->
<ElEmpty
v-else-if="!timelineLoading"
description="该时间范围内暂无访问轨迹"
:image-size="80"
/>
</div>
</template>
<!-- 失败:请求已结束但无数据对象(首屏加载中不显示,避免闪现) -->
<ElEmpty
v-else-if="timeline === null && !timelineLoading"
description="时间线加载失败"
:image-size="80"
/>
<!-- 成功但窗口内无事件 -->
<ElEmpty
v-else-if="!timelineLoading"
description="该时间范围内暂无访问轨迹"
:image-size="80"
/>
</div>
</ElTabPane>
<!-- Tab 2:会话列表 -->
<ElTabPane :label="`会话(${detail?.sessions.length ?? 0})`" name="sessions">
<div class="flex-c gap-2 mb-3">
<h3 class="m-0 text-sm font-semibold">会话列表</h3>
<span class="text-xs text-g-500">点击行查看会话事件序列</span>
</div>
<ElTable
:data="detail?.sessions ?? []"
v-loading="profileLoading"
empty-text="暂无会话数据"
row-class-name="session-row"
@row-click="openSession"
>
<ElTableColumn label="会话 ID" min-width="130">
<template #default="{ row }">
<code class="session-id-code">{{ row.session_id.slice(0, 8) }}</code>
</template>
</ElTableColumn>
<ElTableColumn label="开始时间" width="150">
<template #default="{ row }">{{ formatDateTime(row.started_at) }}</template>
</ElTableColumn>
<ElTableColumn label="结束时间" width="150">
<template #default="{ row }">{{ formatDateTime(row.ended_at) }}</template>
</ElTableColumn>
<ElTableColumn label="来源" min-width="120">
<template #default="{ row }">
<span class="whitespace-nowrap">{{ sessionSourceText(row) }}</span>
</template>
</ElTableColumn>
<ElTableColumn label="落地页" min-width="200" show-overflow-tooltip>
<template #default="{ row }">
<span class="mono-path">{{ row.landing_path || '—' }}</span>
</template>
</ElTableColumn>
<ElTableColumn prop="event_count" label="事件数" width="80" align="right" />
<ElTableColumn label="停留" width="130">
<template #default="{ row }">{{ formatStay(row.stay_ms) }}</template>
</ElTableColumn>
</ElTable>
</ElTabPane>
<!-- Tab 3:原始上报批次(raw_batch,排查"上报了为何没进报表") -->
<ElTabPane label="原始数据" name="raw">
<div class="flex-c flex-wrap gap-2 mb-3">
<h3 class="m-0 text-sm font-semibold">原始上报批次</h3>
<span class="text-xs text-g-500">
每次接收的原样存档(保留 30 天);敏感字段已脱敏为 [FILTERED],被拒批次超 4KB 截断
</span>
</div>
<ElTable
:data="rawBatches?.list ?? []"
v-loading="rawLoading"
empty-text="暂无原始上报数据"
row-class-name="session-row"
@row-click="openRawDrawer"
>
<ElTableColumn label="接收时间" width="170">
<template #default="{ row }">{{ formatDateTime(row.received_at) }}</template>
</ElTableColumn>
<ElTableColumn label="批次 ID" min-width="150">
<template #default="{ row }">
<div v-if="row.batch_id" class="flex-c gap-1">
<code class="session-id-code">{{ row.batch_id.slice(0, 8) }}</code>
<ElButton
link
type="primary"
size="small"
@click.stop="copyBatchId(row.batch_id)"
>
复制
</ElButton>
</div>
<span v-else>—</span>
</template>
</ElTableColumn>
<ElTableColumn label="状态" width="100">
<template #default="{ row }">
<ElTag :type="rawStatus(row.status).type" size="small">
{{ rawStatus(row.status).label }}
</ElTag>
</template>
</ElTableColumn>
<ElTableColumn prop="event_count" label="入库数" width="80" align="right" />
<ElTableColumn prop="rejected_count" label="拒绝数" width="80" align="right" />
</ElTable>
<div class="flex-c justify-end mt-3">
<ElPagination
:total="rawBatches?.total ?? 0"
:page-size="RAW_PAGE_SIZE"
:current-page="rawPage"
layout="total, prev, pager, next"
@current-change="onRawPageChange"
/>
</div>
</ElTabPane>
</ElTabs>
</div>
<!-- 右列:来源证据 + 访问环境 -->
<!-- 中栏:4 统计卡(2×2)+ 来源证据 -->
<div class="vd-side">
<!-- 4 统计卡:图标块 + 小标签 + 大数字 -->
<div class="vd-stats">
<div v-for="tile in statTiles" :key="tile.label" class="vd-card vd-stat-card">
<div class="vd-stat-icon">
<ElIcon :size="20">
<component :is="tile.icon" />
</ElIcon>
</div>
<div class="vd-stat-body">
<span class="vd-stat-label">{{ tile.label }}</span>
<span class="vd-stat-value">{{ tile.value }}</span>
</div>
</div>
</div>
<!-- 来源证据:三口径渠道徽章 + 证据详情子面板 -->
<div class="vd-card vd-evidence-card">
<div class="vd-card-head">
@@ -201,7 +299,10 @@
</div>
</div>
</div>
</div>
<!-- 右栏:访问环境 -->
<div class="vd-side">
<!-- 访问环境:档案字段行式展示 -->
<div class="vd-card vd-env-card">
<div class="vd-card-head">
@@ -227,106 +328,6 @@
</div>
</div>
<!-- 会话与原始数据(设计图外保留的既有能力,界面按新色板统一) -->
<div class="vd-card vd-bottom-card">
<ElTabs v-model="activeTab" class="detail-tabs">
<!-- Tab 1:会话列表 -->
<ElTabPane :label="`会话(${detail?.sessions.length ?? 0})`" name="sessions">
<div class="flex-c gap-2 mb-3">
<h3 class="m-0 text-sm font-semibold">会话列表</h3>
<span class="text-xs text-g-500">点击行查看会话事件序列</span>
</div>
<ElTable
:data="detail?.sessions ?? []"
v-loading="profileLoading"
empty-text="暂无会话数据"
row-class-name="session-row"
@row-click="openSession"
>
<ElTableColumn label="会话 ID" min-width="130">
<template #default="{ row }">
<code class="session-id-code">{{ row.session_id.slice(0, 8) }}</code>
</template>
</ElTableColumn>
<ElTableColumn label="开始时间" width="150">
<template #default="{ row }">{{ formatDateTime(row.started_at) }}</template>
</ElTableColumn>
<ElTableColumn label="结束时间" width="150">
<template #default="{ row }">{{ formatDateTime(row.ended_at) }}</template>
</ElTableColumn>
<ElTableColumn label="来源" min-width="120">
<template #default="{ row }">
<span class="whitespace-nowrap">{{ sessionSourceText(row) }}</span>
</template>
</ElTableColumn>
<ElTableColumn label="落地页" min-width="200" show-overflow-tooltip>
<template #default="{ row }">
<span class="mono-path">{{ row.landing_path || '—' }}</span>
</template>
</ElTableColumn>
<ElTableColumn prop="event_count" label="事件数" width="80" align="right" />
<ElTableColumn label="停留" width="130">
<template #default="{ row }">{{ formatStay(row.stay_ms) }}</template>
</ElTableColumn>
</ElTable>
</ElTabPane>
<!-- Tab 2:原始上报批次(raw_batch,排查"上报了为何没进报表") -->
<ElTabPane label="原始数据" name="raw">
<div class="flex-c flex-wrap gap-2 mb-3">
<h3 class="m-0 text-sm font-semibold">原始上报批次</h3>
<span class="text-xs text-g-500">
每次接收的原样存档(保留 30 天);敏感字段已脱敏为 [FILTERED],被拒批次超 4KB 截断
</span>
</div>
<ElTable
:data="rawBatches?.list ?? []"
v-loading="rawLoading"
empty-text="暂无原始上报数据"
row-class-name="session-row"
@row-click="openRawDrawer"
>
<ElTableColumn label="接收时间" width="170">
<template #default="{ row }">{{ formatDateTime(row.received_at) }}</template>
</ElTableColumn>
<ElTableColumn label="批次 ID" min-width="150">
<template #default="{ row }">
<div v-if="row.batch_id" class="flex-c gap-1">
<code class="session-id-code">{{ row.batch_id.slice(0, 8) }}</code>
<ElButton
link
type="primary"
size="small"
@click.stop="copyBatchId(row.batch_id)"
>
复制
</ElButton>
</div>
<span v-else>—</span>
</template>
</ElTableColumn>
<ElTableColumn label="状态" width="100">
<template #default="{ row }">
<ElTag :type="rawStatus(row.status).type" size="small">
{{ rawStatus(row.status).label }}
</ElTag>
</template>
</ElTableColumn>
<ElTableColumn prop="event_count" label="入库数" width="80" align="right" />
<ElTableColumn prop="rejected_count" label="拒绝数" width="80" align="right" />
</ElTable>
<div class="flex-c justify-end mt-3">
<ElPagination
:total="rawBatches?.total ?? 0"
:page-size="RAW_PAGE_SIZE"
:current-page="rawPage"
layout="total, prev, pager, next"
@current-change="onRawPageChange"
/>
</div>
</ElTabPane>
</ElTabs>
</div>
</template>
<!-- 原始批次详情抽屉:body 是服务端脱敏后的 JSON 文本,<pre> 文本插值渲染(禁 v-html) -->
@@ -692,7 +693,7 @@
})
// ---------- 跨天时间线 ----------
const activeTab = ref<'sessions' | 'raw'>('sessions')
const activeTab = ref<'timeline' | 'sessions' | 'raw'>('timeline')
const days = ref(30)
/** 排序:desc=日期组最新在前+组内事件最新在前;asc=都最早在前 */
const order = ref<'desc' | 'asc'>('desc')