feat: 原始上报数据存储(raw_batch 表/脱敏/访客关联/详情页 Tab/30天清理)
Co-Authored-By: Claude Code <noreply@anthropic.com>
This commit is contained in:
@@ -1,4 +1,4 @@
|
||||
<!-- 访客统计 · 访客详情(档案卡 + 跨天时间线 + 会话列表/抽屉) -->
|
||||
<!-- 访客统计 · 访客详情(档案卡 + 跨天时间线 + 会话列表/抽屉 + 原始上报批次) -->
|
||||
<!-- keepAlive=true 且路由 path 固定:访客切换复用同一实例,刷新依赖 onActivated + query watch -->
|
||||
<template>
|
||||
<div class="visitor-detail-page p-5">
|
||||
@@ -207,9 +207,105 @@
|
||||
</ElTable>
|
||||
</div>
|
||||
</ElTabPane>
|
||||
|
||||
<!-- Tab 3:原始上报批次(raw_batch,排查"上报了为何没进报表") -->
|
||||
<ElTabPane label="原始数据" name="raw">
|
||||
<div class="art-card px-5 py-4">
|
||||
<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>
|
||||
</div>
|
||||
</ElTabPane>
|
||||
</ElTabs>
|
||||
</template>
|
||||
|
||||
<!-- 原始批次详情抽屉:body 是服务端脱敏后的 JSON 文本,<pre> 文本插值渲染(禁 v-html) -->
|
||||
<ElDrawer v-model="rawDrawerVisible" title="原始上报批次" size="46%">
|
||||
<template v-if="rawCurrent">
|
||||
<div class="drawer-summary">
|
||||
<div class="flex-c flex-wrap gap-x-4 gap-y-1.5">
|
||||
<span class="text-g-600 text-sm">批次 ID</span>
|
||||
<template v-if="rawCurrent.batch_id">
|
||||
<code class="session-id-code">{{ rawCurrent.batch_id }}</code>
|
||||
<ElButton link type="primary" size="small" @click="copyBatchId(rawCurrent.batch_id)">
|
||||
复制
|
||||
</ElButton>
|
||||
</template>
|
||||
<span v-else class="text-sm">—</span>
|
||||
</div>
|
||||
<div class="summary-grid">
|
||||
<div class="flex-c gap-2">
|
||||
<span class="text-xs text-g-600">接收时间</span>
|
||||
<span class="text-sm">{{ formatDateTime(rawCurrent.received_at) }}</span>
|
||||
</div>
|
||||
<div class="flex-c gap-2">
|
||||
<span class="text-xs text-g-600">状态</span>
|
||||
<ElTag :type="rawStatus(rawCurrent.status).type" size="small">
|
||||
{{ rawStatus(rawCurrent.status).label }}
|
||||
</ElTag>
|
||||
</div>
|
||||
<div class="flex-c gap-2">
|
||||
<span class="text-xs text-g-600">入库数</span>
|
||||
<span class="text-sm">{{ rawCurrent.event_count }}</span>
|
||||
</div>
|
||||
<div class="flex-c gap-2">
|
||||
<span class="text-xs text-g-600">拒绝数</span>
|
||||
<span class="text-sm">{{ rawCurrent.rejected_count }}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<pre class="raw-json-body">{{ prettyBody(rawCurrent.body) }}</pre>
|
||||
</template>
|
||||
</ElDrawer>
|
||||
|
||||
<!-- 会话详情抽屉 -->
|
||||
<ElDrawer v-model="drawerVisible" title="会话详情" size="34%">
|
||||
<div v-loading="sessionLoading" class="drawer-body">
|
||||
@@ -268,9 +364,11 @@
|
||||
<script setup lang="ts">
|
||||
import { Back as BackIcon } from '@element-plus/icons-vue'
|
||||
import {
|
||||
getRawBatches,
|
||||
getSession,
|
||||
getTimeline,
|
||||
getVisitor,
|
||||
type RawBatchRow,
|
||||
type SessionSummary,
|
||||
type TimelineEvent,
|
||||
type TimelineResp,
|
||||
@@ -346,7 +444,7 @@
|
||||
])
|
||||
|
||||
// ---------- 跨天时间线 ----------
|
||||
const activeTab = ref<'timeline' | 'sessions'>('timeline')
|
||||
const activeTab = ref<'timeline' | 'sessions' | 'raw'>('timeline')
|
||||
const days = ref(30)
|
||||
const timeline = ref<TimelineResp | null>(null)
|
||||
const timelineLoading = ref(false)
|
||||
@@ -408,6 +506,75 @@
|
||||
}
|
||||
}
|
||||
|
||||
// ---------- 原始上报批次(Tab 3) ----------
|
||||
const RAW_PAGE_SIZE = 10 // 与后端 visitors.py RAW_PAGE_SIZE 同步
|
||||
const rawBatches = ref<{ list: RawBatchRow[]; total: number; page: number } | null>(null)
|
||||
const rawLoading = ref(false)
|
||||
const rawPage = ref(1)
|
||||
let rawSeq = 0
|
||||
|
||||
const loadRawBatches = async (page = rawPage.value) => {
|
||||
const vid = loadedVid
|
||||
if (!vid) return
|
||||
const seq = ++rawSeq
|
||||
rawPage.value = page
|
||||
rawLoading.value = true
|
||||
try {
|
||||
const resp = await getRawBatches(vid, page)
|
||||
if (seq === rawSeq) rawBatches.value = resp
|
||||
} catch {
|
||||
if (seq === rawSeq) rawBatches.value = null
|
||||
} finally {
|
||||
if (seq === rawSeq) rawLoading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
const onRawPageChange = (page: number) => {
|
||||
loadRawBatches(page)
|
||||
}
|
||||
|
||||
// 首次切到原始 Tab 才拉取(懒加载);失败置 null,切走再切回可重试
|
||||
watch(activeTab, (tab) => {
|
||||
if (tab === 'raw' && rawBatches.value === null && loadedVid) loadRawBatches()
|
||||
})
|
||||
|
||||
/** 状态三色映射(accepted/partial/rejected;未知值兜底 info) */
|
||||
const rawStatusMap: Record<
|
||||
string,
|
||||
{ label: string; type: 'success' | 'warning' | 'danger' | 'info' }
|
||||
> = {
|
||||
accepted: { label: '已入库', type: 'success' },
|
||||
partial: { label: '部分拒绝', type: 'warning' },
|
||||
rejected: { label: '已拒绝', type: 'danger' }
|
||||
}
|
||||
const rawStatus = (status: string) => rawStatusMap[status] ?? { label: status, type: 'info' }
|
||||
|
||||
/** 被拒批次 body 被 4KB 截断后不是合法 JSON——parse 失败原文直出 */
|
||||
const prettyBody = (body: string): string => {
|
||||
try {
|
||||
return JSON.stringify(JSON.parse(body), null, 2)
|
||||
} catch {
|
||||
return body
|
||||
}
|
||||
}
|
||||
|
||||
// 原始批次抽屉(body 随行带出,无需再请求)
|
||||
const rawDrawerVisible = ref(false)
|
||||
const rawCurrent = ref<RawBatchRow | null>(null)
|
||||
const openRawDrawer = (row: RawBatchRow) => {
|
||||
rawCurrent.value = row
|
||||
rawDrawerVisible.value = true
|
||||
}
|
||||
|
||||
const copyBatchId = async (batchId: string) => {
|
||||
try {
|
||||
await copy(batchId)
|
||||
ElMessage.success('批次 ID 已复制到剪贴板')
|
||||
} catch {
|
||||
ElMessage.error('复制失败,请手动选择复制')
|
||||
}
|
||||
}
|
||||
|
||||
// ---------- keepAlive 访客切换 ----------
|
||||
/**
|
||||
* 路由 keepAlive 且 path 固定为 /tracker/visitor/detail:列表点不同访客进来复用同一实例。
|
||||
@@ -418,9 +585,14 @@
|
||||
const vid = routeVisitorId.value
|
||||
if (!vid || vid === loadedVid) return
|
||||
loadedVid = vid
|
||||
// 换访客:清档案/时间线/抽屉态,防旧访客 ID 在 loading 遮罩下闪现(days 保持所选范围重新拉取)
|
||||
// 换访客:清档案/时间线/原始批次/抽屉态,防旧访客 ID 在 loading 遮罩下闪现
|
||||
//(days 保持所选范围重新拉取;原始 Tab 懒加载——分页重置回第 1 页,切到 Tab 时再拉)
|
||||
detail.value = null
|
||||
timeline.value = null
|
||||
rawBatches.value = null
|
||||
rawPage.value = 1
|
||||
rawDrawerVisible.value = false
|
||||
rawCurrent.value = null
|
||||
drawerVisible.value = false
|
||||
sessionDetail.value = null
|
||||
loadDetail(vid)
|
||||
@@ -532,4 +704,19 @@
|
||||
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||
gap: 8px 12px;
|
||||
}
|
||||
|
||||
// 原始批次 JSON(脱敏后文本,pre 渲染)
|
||||
.raw-json-body {
|
||||
max-height: calc(100vh - 260px);
|
||||
padding: 12px 14px;
|
||||
margin: 12px 0 0;
|
||||
overflow: auto;
|
||||
font-family: var(--el-font-family-mono, monospace);
|
||||
font-size: 12px;
|
||||
line-height: 1.6;
|
||||
white-space: pre-wrap;
|
||||
word-break: break-all;
|
||||
border-radius: 6px;
|
||||
background: var(--el-fill-color-lighter);
|
||||
}
|
||||
</style>
|
||||
|
||||
Reference in New Issue
Block a user