fix: 访问统计口径与体验——PV只计页面级接口(过滤轮询,今日221→真实口径);埋点记录userId并显示昵称;访问日志Tab合并用户列
This commit is contained in:
@@ -1,10 +1,8 @@
|
|||||||
<template>
|
<template>
|
||||||
<div class="page">
|
<div class="page">
|
||||||
<el-tabs v-model="tab" @tab-change="load">
|
<el-tabs v-model="tab" @tab-change="load">
|
||||||
<el-tab-pane label="登录/访问日志" name="login" />
|
<el-tab-pane label="登录日志" name="login" />
|
||||||
<el-tab-pane label="访问埋点" name="visit">
|
<el-tab-pane label="访问日志" name="visit" />
|
||||||
<template #label>访问埋点</template>
|
|
||||||
</el-tab-pane>
|
|
||||||
<el-tab-pane label="管理操作日志" name="admin" />
|
<el-tab-pane label="管理操作日志" name="admin" />
|
||||||
</el-tabs>
|
</el-tabs>
|
||||||
<div v-if="tab === 'visit'" class="visit-filters">
|
<div v-if="tab === 'visit'" class="visit-filters">
|
||||||
@@ -41,6 +39,7 @@
|
|||||||
</template>
|
</template>
|
||||||
<template v-else-if="tab === 'visit'">
|
<template v-else-if="tab === 'visit'">
|
||||||
<el-table-column label="时间" width="160"><template #default="{ row }">{{ fmt(row.createdAt) }}</template></el-table-column>
|
<el-table-column label="时间" width="160"><template #default="{ row }">{{ fmt(row.createdAt) }}</template></el-table-column>
|
||||||
|
<el-table-column label="用户" width="110"><template #default="{ row }">{{ row.userName || '游客' }}</template></el-table-column>
|
||||||
<el-table-column prop="source" label="来源" width="100" />
|
<el-table-column prop="source" label="来源" width="100" />
|
||||||
<el-table-column label="终端" width="150">
|
<el-table-column label="终端" width="150">
|
||||||
<template #default="{ row }">{{ [row.os, row.browser].filter(Boolean).join(' / ') || '—' }}</template>
|
<template #default="{ row }">{{ [row.os, row.browser].filter(Boolean).join(' / ') || '—' }}</template>
|
||||||
|
|||||||
+32
-16
@@ -66,25 +66,41 @@ const limiter = rateLimit({
|
|||||||
})
|
})
|
||||||
app.use('/api/', limiter)
|
app.use('/api/', limiter)
|
||||||
|
|
||||||
// 访问埋点:记录页面级 GET 请求(排除管理后台/认证/健康检查),小程序与 H5 访问均可统计
|
// 访问埋点:记录页面级 GET 请求(排除管理后台/认证/健康检查/高频轮询接口),小程序与 H5 访问均可统计
|
||||||
|
// PV 口径:只记"页面首屏"接口(每打开一页只计1次),轮询/统计类接口不计——避免一次访问刷出6-8条
|
||||||
|
const TRACKABLE_PAGES = [/^\/knowledge(\?|$)/, /^\/trend(\?|$)/, /^\/knowledge\/stats/, /^\/ai-chat\/quota/, /^\/user\/leaderboard/, /^\/feedback/]
|
||||||
app.use('/api', (req, res, next) => {
|
app.use('/api', (req, res, next) => {
|
||||||
try {
|
try {
|
||||||
if (req.method === 'GET' && !req.path.startsWith('/admin') && !req.path.startsWith('/auth')) {
|
if (req.method === 'GET' && !req.path.startsWith('/admin') && !req.path.startsWith('/auth')) {
|
||||||
const ua = String(req.headers['user-agent'] || '')
|
const path = String(req.originalUrl || req.path).split('?')[0]
|
||||||
const referer = String(req.headers.referer || '')
|
const isPage = TRACKABLE_PAGES.some((re) => re.test(path))
|
||||||
let source = '直接访问'
|
if (isPage) {
|
||||||
if (referer.includes('servicewechat.com')) source = '小程序'
|
const ua = String(req.headers['user-agent'] || '')
|
||||||
else if (referer.includes('/h5') || referer.includes('wdkj.yuzhiran.com.cn')) source = 'H5'
|
const referer = String(req.headers.referer || '')
|
||||||
const os = /windows/i.test(ua) ? 'Windows' : /iphone|ipad|ios/i.test(ua) ? 'iOS' : /android/i.test(ua) ? 'Android' : /mac os/i.test(ua) ? 'MacOS' : ''
|
let source = '直接访问'
|
||||||
const browser = /MicroMessenger/i.test(ua) ? '微信' : /Mobile Safari/i.test(ua) ? 'Safari' : /Edg\//i.test(ua) ? 'Edge' : /Chrome/i.test(ua) ? 'Chrome' : /Firefox/i.test(ua) ? 'Firefox' : ''
|
if (referer.includes('servicewechat.com')) source = '小程序'
|
||||||
const device = /Mobile|iPhone/i.test(ua) || (/Android/i.test(ua) && /Mobile/i.test(ua)) ? '移动端' : '桌面端'
|
else if (referer.includes('/h5') || referer.includes('wdkj.yuzhiran.com.cn')) source = 'H5'
|
||||||
VisitLog.create({
|
const os = /windows/i.test(ua) ? 'Windows' : /iphone|ipad|ios/i.test(ua) ? 'iOS' : /android/i.test(ua) ? 'Android' : /mac os/i.test(ua) ? 'MacOS' : ''
|
||||||
path: String(req.originalUrl || req.path).slice(0, 120),
|
const browser = /MicroMessenger/i.test(ua) ? '微信' : /Mobile Safari/i.test(ua) ? 'Safari' : /Edge\//i.test(ua) ? 'Edge' : /Chrome/i.test(ua) ? 'Chrome' : /Firefox/i.test(ua) ? 'Firefox' : ''
|
||||||
referer: String(req.headers.referer || '').slice(0, 200),
|
const device = /Mobile|iPhone/i.test(ua) || (/Android/i.test(ua) && /Mobile/i.test(ua)) ? '移动端' : '桌面端'
|
||||||
source,
|
// 解析登录用户(不强制,游客为空)
|
||||||
ip: req.ip || '',
|
let userId = null
|
||||||
os, browser, device,
|
const auth = String(req.headers.authorization || '')
|
||||||
}).catch(() => {})
|
if (auth.startsWith('Bearer ')) {
|
||||||
|
try {
|
||||||
|
const decoded = require('jsonwebtoken').verify(auth.slice(7), process.env.JWT_SECRET)
|
||||||
|
userId = decoded.userId || null
|
||||||
|
} catch { /* token 无效视为游客 */ }
|
||||||
|
}
|
||||||
|
VisitLog.create({
|
||||||
|
path: String(req.originalUrl || req.path).slice(0, 120),
|
||||||
|
referer: String(req.headers.referer || '').slice(0, 200),
|
||||||
|
source,
|
||||||
|
ip: req.ip || '',
|
||||||
|
userId,
|
||||||
|
os, browser, device,
|
||||||
|
}).catch(() => {})
|
||||||
|
}
|
||||||
}
|
}
|
||||||
} catch (e) { /* 埋点失败不影响业务 */ }
|
} catch (e) { /* 埋点失败不影响业务 */ }
|
||||||
next()
|
next()
|
||||||
|
|||||||
@@ -93,9 +93,12 @@ router.get('/visits', requirePermission('dashboard:read'), async (req, res) => {
|
|||||||
const query = { ...timeQuery, ...(source ? { source } : {}) }
|
const query = { ...timeQuery, ...(source ? { source } : {}) }
|
||||||
const [total, items] = await Promise.all([
|
const [total, items] = await Promise.all([
|
||||||
VisitLog.countDocuments(query),
|
VisitLog.countDocuments(query),
|
||||||
VisitLog.find(query).sort({ createdAt: -1 }).skip((page - 1) * limit).limit(limit).lean(),
|
VisitLog.find(query).sort({ createdAt: -1 }).skip((page - 1) * limit).limit(limit).populate('userId', 'nickName').lean(),
|
||||||
])
|
])
|
||||||
items.forEach(v => { v.ipLoc = ipLocation(v.ip) })
|
items.forEach(v => {
|
||||||
|
v.ipLoc = ipLocation(v.ip)
|
||||||
|
v.userName = v.userId?.nickName || ''
|
||||||
|
})
|
||||||
// 快捷统计(当日/昨日/7天/30天总量)
|
// 快捷统计(当日/昨日/7天/30天总量)
|
||||||
const cnt = (q) => VisitLog.countDocuments(q)
|
const cnt = (q) => VisitLog.countDocuments(q)
|
||||||
const y = new Date(now); y.setDate(y.getDate() - 1)
|
const y = new Date(now); y.setDate(y.getDate() - 1)
|
||||||
|
|||||||
Reference in New Issue
Block a user