You can not select more than 25 topics Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.
 
 
 
 
 
 

219 lines
10 KiB

<template>
<div>
<div class="flex items-center justify-between mb-4 flex-wrap gap-3">
<div>
<h2 class="text-lg font-bold text-slate-700">统计日志</h2>
<p class="text-xs text-slate-400 mt-0.5">console 从 NATS 收到的「当日统计快照」接收流水(最新在前,按接收时间分天存 Redis)。各业务部署每个同步周期/启动时/0点 推送一次当日绝对快照,console 收到后覆盖落库。用于核对每个部署是否在正常汇报;带 <span class="unreg">未注册</span> 的应用快照已收到但<b>不会落库</b>(应用名需与 console 注册表完全一致)。</p>
</div>
<div class="flex items-center gap-2 flex-wrap">
<select v-model.number="fDay" class="select select-bordered select-sm" @change="reload">
<option v-for="opt in dayOptions" :key="opt.value" :value="opt.value">{{ opt.label }}</option>
</select>
<select v-model="fLevel" class="select select-bordered select-sm">
<option value="">全部级别</option>
<option value="info">已落库</option>
<option value="warn">未落库</option>
<option value="error">解析失败</option>
</select>
<input v-model="fKw" class="input input-bordered input-sm w-44" placeholder="筛选 应用/区域/产品" />
<label class="flex items-center gap-1 text-xs text-slate-500 cursor-pointer">
<input v-model="fAuto" type="checkbox" class="checkbox checkbox-xs" @change="toggleAuto" /> 自动刷新
</label>
<button class="btn btn-primary btn-sm text-white" @click="reload">刷新</button>
<button class="btn btn-outline btn-error btn-sm" title="清空本日核对流水(不影响已落库的统计数据),清空后可重新观察埋点是否正常进来" @click="clearLogs">清空本日</button>
</div>
</div>
<div class="flex items-center gap-4 mb-3 text-xs text-slate-500">
<span>该日保留 <b class="text-slate-700">{{ statTotal }}</b> 条</span>
<span>当前显示 <b class="text-slate-700">{{ filteredRows.length }}</b> 条</span>
<span>最近接收 <b class="text-slate-700">{{ statLast }}</b></span>
<span class="muted">{{ statTip }}</span>
</div>
<div class="panel overflow-hidden">
<div id="logHead">
<div>接收时间</div><div>级别</div><div>应用 · 区域 · 产品</div><div>统计日</div><div>指标摘要 / 说明</div><div>快照时间</div>
</div>
<div id="logBody" style="max-height:calc(100vh - 230px); overflow-y:auto;">
<div v-if="loading" class="text-center text-slate-400 py-10">加载中...</div>
<div v-else-if="loadError" class="text-center text-red-400 py-10">加载失败:{{ loadError }}</div>
<div v-else-if="!filteredRows.length" class="text-center text-slate-400 py-10">{{ all.length ? '无匹配记录' : '该日暂无接收记录' }}</div>
<template v-else>
<div v-for="(e, i) in filteredRows" :key="i" class="logrow">
<div class="t">{{ fmtTime(e.ts) }}</div>
<div><span class="lv" :class="levelInfo(e.level).cls">{{ levelInfo(e.level).txt }}</span></div>
<div>
<template v-if="e.app">
<span class="app-name">{{ e.app }}</span>
<span v-if="!e.registered" class="unreg" title="该应用名未在 console 注册,快照已收到但不会写入统计库">未注册</span>
</template>
<span v-else class="muted">(无应用名)</span>
<span class="muted"> · {{ regionName(e.region) }}</span>
<span class="muted"> · {{ fmtProduct(e.product_id) }}</span>
</div>
<div class="muted">{{ e.stat_day ? fmtDay(e.stat_day) : '-' }}</div>
<div>
<template v-if="e.msg">
<span :class="e.level === 'error' ? 'text-red-600' : 'text-amber-600'">{{ e.msg }}</span>
<span v-if="e.summary" class="muted"> · {{ e.summary }}</span>
</template>
<template v-else>{{ e.summary || '无增量' }}</template>
</div>
<div class="t">{{ fmtTime(e.snap_ts) }}<span v-if="isLate(e)" class="late">迟到{{ e.ts - e.snap_ts }}s</span></div>
</div>
</template>
</div>
</div>
</div>
</template>
<script setup lang="ts">
import { ref, computed, onMounted, onBeforeUnmount } from 'vue'
import { useApi } from '~/composables/useApi'
import { useToast } from '~/composables/useToast'
definePageMeta({ title: '统计日志' })
interface StatLogEntry {
ts: number
snap_ts: number
level: string
app: string
region: string
product_id: number
stat_day: number
registered: boolean
msg: string
summary: string
}
const { webApi } = useApi()
const { success, error } = useToast()
// 区域代码 → 名称(与后端 comm.RegionCode / pb.Region 一致)。快照 region 为代码字符串(cn/us/...)。
const REGION_NAMES: Record<string, string> = { cn: '中国', us: '美国', ea: '东亚', sea: '东南亚', sa: '南亚', me: '中东', eu: '欧洲', ru: '俄罗斯', af: '非洲', sam: '南美', nam: '北美', oc: '大洋洲', br: '巴西', in: '印度' }
function regionName(c?: string) { return c ? (REGION_NAMES[c] || c) : '-' }
const all = ref<StatLogEntry[]>([])
const loading = ref(false)
const loadError = ref('')
const statTotal = ref<number | string>('-')
const statLast = ref('-')
const statTip = ref('')
const fDay = ref(0)
const fLevel = ref('')
const fKw = ref('')
const fAuto = ref(false)
let autoTimer: ReturnType<typeof setInterval> | null = null
// 以统计时区(Asia/Shanghai)计算 YYYYMMDD,避开浏览器时区与跨日边界问题。
function shYmd(offsetDays = 0) {
const d = new Date(Date.now() + offsetDays * 86400000)
const s = new Intl.DateTimeFormat('en-CA', { timeZone: 'Asia/Shanghai', year: 'numeric', month: '2-digit', day: '2-digit' }).format(d)
return parseInt(s.replace(/-/g, ''), 10)
}
function fmtDay(yyyymmdd: number) { const s = '' + yyyymmdd; return s.length === 8 ? s.slice(0, 4) + '-' + s.slice(4, 6) + '-' + s.slice(6, 8) : s }
function fmtTime(ts?: number) {
if (!ts) return '--'
const d = new Date(ts * 1000)
const p = (n: number) => (n < 10 ? '0' + n : '' + n)
return p(d.getHours()) + ':' + p(d.getMinutes()) + ':' + p(d.getSeconds())
}
// 产品ID 16 进制显示(无产品维度=0 显示"全部")。
function fmtProduct(pid?: number) { return pid ? ('0x' + (pid >>> 0).toString(16).toUpperCase()) : '全部' }
const dayOptions = computed(() => [
{ value: shYmd(0), label: '今天 ' + fmtDay(shYmd(0)) },
{ value: shYmd(-1), label: '昨天 ' + fmtDay(shYmd(-1)) },
])
function levelInfo(level: string) {
if (level === 'error') return { cls: 'lv-error', txt: '解析失败' }
if (level === 'warn') return { cls: 'lv-warn', txt: '未落库' }
return { cls: 'lv-info', txt: '已落库' }
}
// 迟到判断:快照生成时间与接收时间相差较大(推送/网络积压)。
function isLate(e: StatLogEntry) { return !!(e.snap_ts && e.ts && (e.ts - e.snap_ts) > 120) }
const filteredRows = computed(() => {
const level = fLevel.value
const kw = fKw.value.trim().toLowerCase()
return all.value.filter((e) => {
if (level && e.level !== level) return false
if (kw) {
const hay = ((e.app || '') + ' ' + (e.region || '') + ' ' + regionName(e.region) + ' ' + (e.product_id || '') + ' ' + fmtProduct(e.product_id) + ' ' + (e.stat_day || '')).toLowerCase()
if (hay.indexOf(kw) < 0) return false
}
return true
})
})
async function reload() {
loading.value = true
loadError.value = ''
try {
const d = await webApi<{ entries?: StatLogEntry[]; total?: number }>('getstatlogs', { day: fDay.value || 0 })
all.value = d?.entries || []
statTotal.value = d?.total != null ? d.total : all.value.length
statLast.value = all.value.length ? fmtTime(all.value[0]!.ts) : '--'
} catch (e: any) {
loadError.value = e?.message || '网络异常'
} finally {
loading.value = false
}
}
// 清空当前所选日的接收流水:仅清核对面板记录,不影响已落库的统计数据。
async function clearLogs() {
const day = fDay.value || 0
const opt = dayOptions.value.find((o) => o.value === day)
const label = opt ? opt.label : '' + day
if (!window.confirm('确定清空「' + label + '」的接收流水吗?\n\n只清除本核对面板的记录,不影响已落库的统计数据。\n清空后可重新触发埋点,观察是否正常进来。')) return
try {
const res = await webApi<{ cleared?: number }>('clearstatlogs', { day })
success('已清空 ' + (res && res.cleared != null ? res.cleared : 0) + ' 条')
reload()
} catch (e: any) {
error('清空失败:' + (e?.message || '网络异常'))
}
}
function toggleAuto() {
if (fAuto.value) {
autoTimer = setInterval(reload, 15000)
statTip.value = '· 每 15 秒自动刷新'
} else {
if (autoTimer) clearInterval(autoTimer)
autoTimer = null
statTip.value = ''
}
}
onMounted(() => {
fDay.value = shYmd(0)
reload()
})
onBeforeUnmount(() => {
if (autoTimer) clearInterval(autoTimer)
})
</script>
<style scoped>
.panel { background: #fff; border: 1px solid #f1f5f9; border-radius: 16px; box-shadow: 0 1px 2px rgba(0, 0, 0, 0.04); }
.logrow { display: grid; grid-template-columns: 80px 64px 210px 92px 1fr 104px; gap: 10px; align-items: start; padding: 6px 12px; border-bottom: 1px solid #f1f5f9; font-size: 12px; }
.logrow:hover { background: #f8fafc; }
.logrow .t { font-family: ui-monospace, Menlo, monospace; color: #64748b; }
.lv { font-size: 10px; font-weight: 700; padding: 1px 6px; border-radius: 6px; white-space: nowrap; }
.lv-info { background: #dcfce7; color: #15803d; }
.lv-warn { background: #fef9c3; color: #a16207; }
.lv-error { background: #fee2e2; color: #b91c1c; }
.late { font-size: 10px; color: #a16207; background: #fef3c7; border-radius: 5px; padding: 0 4px; margin-left: 4px; }
.muted { color: #94a3b8; }
.app-name { font-weight: 600; color: #0f172a; }
.unreg { font-size: 10px; color: #b45309; background: #fef3c7; border-radius: 5px; padding: 0 5px; margin-left: 5px; white-space: nowrap; }
#logHead { display: grid; grid-template-columns: 80px 64px 210px 92px 1fr 104px; gap: 10px; padding: 7px 12px; font-size: 11px; color: #94a3b8; border-bottom: 1px solid #e2e8f0; background: #f8fafc; }
</style>