feat: 访问埋点统计——VisitLog模型+/api GET埋点中间件(小程序/H5/直接访问来源识别)+访问统计接口+看板PV/UV/来源分布卡+日志页访问埋点Tab
This commit is contained in:
@@ -7,6 +7,21 @@
|
||||
<div class="card"><b>{{ d.stats?.aiChatUsed ?? '-' }}</b><span>AI 问答次数</span><i>{{ d.stats?.aiChatUsers ?? 0 }} 人用过</i></div>
|
||||
<div class="card"><b>{{ d.stats?.totalTrends ?? '-' }}</b><span>趋势资讯</span><i>含人话解读 {{ d.stats?.trendsWithExplain ?? 0 }}</i></div>
|
||||
<div class="card"><b>{{ d.stats?.paidOrders ?? '-' }}</b><span>付费订单</span><i>共 {{ d.stats?.totalOrders ?? 0 }} 单</i></div>
|
||||
<div class="card"><b>{{ visit.pvToday ?? '-' }}</b><span>今日访问 PV</span><i>小程序 + H5</i></div>
|
||||
<div class="card"><b>{{ visit.uvToday ?? '-' }}</b><span>今日访客 UV</span><i>按 IP 去重</i></div>
|
||||
<div class="card"><b>{{ visit.h5Today ?? '-' }}</b><span>H5 访问</span><i>小程序 {{ visit.miniToday ?? 0 }} 次</i></div>
|
||||
</div>
|
||||
|
||||
<div class="row" v-if="(visit.sources7d || []).length">
|
||||
<div class="panel">
|
||||
<h3>访问来源分布(近 7 天)</h3>
|
||||
<div v-for="s in visit.sources7d" :key="s.source" class="bar-row">
|
||||
<span class="bar-name">{{ s.source }}</span>
|
||||
<div class="bar-track"><div class="bar-fill" :style="{ width: srcPct(s.count) }"></div></div>
|
||||
<span class="bar-num">{{ s.count }}</span>
|
||||
</div>
|
||||
</div>
|
||||
<div class="panel grow"></div>
|
||||
</div>
|
||||
|
||||
<div class="row">
|
||||
@@ -47,13 +62,19 @@
|
||||
import { ref, onMounted } from 'vue'
|
||||
import { apiGet } from '../api'
|
||||
const d = ref({})
|
||||
const visit = ref({})
|
||||
function pct(n) {
|
||||
const max = Math.max(1, ...(d.value.litByDim || []).map(b => b.count))
|
||||
return Math.round((n / max) * 100) + '%'
|
||||
}
|
||||
function srcPct(n) {
|
||||
const max = Math.max(1, ...(visit.value.sources7d || []).map(s => s.count))
|
||||
return Math.round((n / max) * 100) + '%'
|
||||
}
|
||||
function fmt(t) { return t ? new Date(t).toLocaleString('zh-CN', { hour12: false }) : '-' }
|
||||
onMounted(async () => {
|
||||
d.value = (await apiGet('/api/admin/dashboard-v2')) || {}
|
||||
try { visit.value = (await apiGet('/api/admin/visit-stats')) || {} } catch (e) { visit.value = {} }
|
||||
})
|
||||
</script>
|
||||
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
<div class="page">
|
||||
<el-tabs v-model="tab" @tab-change="load">
|
||||
<el-tab-pane label="登录/访问日志" name="login" />
|
||||
<el-tab-pane label="访问埋点" name="visit" />
|
||||
<el-tab-pane label="管理操作日志" name="admin" />
|
||||
</el-tabs>
|
||||
<el-table :data="list" stripe v-loading="loading" size="small">
|
||||
@@ -20,6 +21,16 @@
|
||||
<template #default="{ row }">{{ [row.os, row.browser].filter(Boolean).join(' / ') || row.device || '—' }}</template>
|
||||
</el-table-column>
|
||||
</template>
|
||||
<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 prop="source" label="来源" width="100" />
|
||||
<el-table-column label="终端" width="150">
|
||||
<template #default="{ row }">{{ [row.os, row.browser].filter(Boolean).join(' / ') || '—' }}</template>
|
||||
</el-table-column>
|
||||
<el-table-column prop="device" label="设备" width="90" />
|
||||
<el-table-column prop="path" label="路径" min-width="200" show-overflow-tooltip />
|
||||
<el-table-column prop="ip" label="IP" width="130" />
|
||||
</template>
|
||||
<template v-else>
|
||||
<el-table-column label="时间" width="160"><template #default="{ row }">{{ fmt(row.timestamp || row.createdAt) }}</template></el-table-column>
|
||||
<el-table-column prop="adminName" label="管理员" width="100" />
|
||||
@@ -44,7 +55,7 @@ function fmt(t) { return t ? new Date(t).toLocaleString('zh-CN', { hour12: false
|
||||
async function load() {
|
||||
loading.value = true
|
||||
try {
|
||||
const ep = tab.value === 'login' ? 'login-logs' : 'logs'
|
||||
const ep = tab.value === 'login' ? 'login-logs' : tab.value === 'visit' ? 'visits' : 'logs'
|
||||
const r = await apiGet(`/api/admin/${ep}?page=${page.value}&limit=${pageSize}`)
|
||||
list.value = r.items || []
|
||||
total.value = r.pagination?.total || 0
|
||||
|
||||
@@ -8,6 +8,7 @@ const morgan = require('morgan')
|
||||
const rateLimit = require('express-rate-limit')
|
||||
|
||||
const connectDB = require('./config/database')
|
||||
const { VisitLog } = require('./models')
|
||||
const initRoutes = require('./routes')
|
||||
const seoRoutes = require('./routes/seo')
|
||||
const registerWxMsgRoutes = require('./routes/wxmsg')
|
||||
@@ -65,6 +66,29 @@ const limiter = rateLimit({
|
||||
})
|
||||
app.use('/api/', limiter)
|
||||
|
||||
// 访问埋点:记录页面级 GET 请求(排除管理后台/认证/健康检查),小程序与 H5 访问均可统计
|
||||
app.use('/api', (req, res, next) => {
|
||||
try {
|
||||
if (req.method === 'GET' && !req.path.startsWith('/admin') && !req.path.startsWith('/auth')) {
|
||||
const ua = String(req.headers['user-agent'] || '')
|
||||
const referer = String(req.headers.referer || '')
|
||||
let source = '直接访问'
|
||||
if (referer.includes('servicewechat.com')) source = '小程序'
|
||||
else if (referer.includes('/h5') || referer.includes('wdkj.yuzhiran.com.cn')) source = 'H5'
|
||||
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' : ''
|
||||
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' : ''
|
||||
const device = /Mobile|iPhone/i.test(ua) || (/Android/i.test(ua) && /Mobile/i.test(ua)) ? '移动端' : '桌面端'
|
||||
VisitLog.create({
|
||||
path: String(req.originalUrl || req.path).slice(0, 120),
|
||||
source,
|
||||
ip: req.ip || '',
|
||||
os, browser, device,
|
||||
}).catch(() => {})
|
||||
}
|
||||
} catch (e) { /* 埋点失败不影响业务 */ }
|
||||
next()
|
||||
})
|
||||
|
||||
// 健康检查
|
||||
app.get('/health', (req, res) => {
|
||||
res.status(200).json({
|
||||
|
||||
@@ -0,0 +1,28 @@
|
||||
const mongoose = require('mongoose')
|
||||
const { Schema } = mongoose
|
||||
|
||||
/**
|
||||
* 访问埋点模型
|
||||
* 记录所有 /api 页面级请求(小程序 + H5 + 未知来源),供管理后台访问统计使用。
|
||||
* 与 LoginLog(登录行为)分离:匿名 H5 访问没有用户也能记录。
|
||||
*/
|
||||
const VisitLogSchema = new Schema({
|
||||
path: { type: String, default: '' },
|
||||
|
||||
// 来源:小程序 / H5 / 直接访问
|
||||
source: { type: String, default: '直接访问', index: true },
|
||||
|
||||
ip: { type: String, default: '', index: true },
|
||||
|
||||
userId: { type: Schema.Types.ObjectId, ref: 'User', default: null },
|
||||
|
||||
os: { type: String, default: '' },
|
||||
browser: { type: String, default: '' },
|
||||
device: { type: String, default: '' },
|
||||
|
||||
createdAt: { type: Date, default: Date.now, index: true }
|
||||
})
|
||||
|
||||
VisitLogSchema.index({ createdAt: -1 })
|
||||
|
||||
module.exports = mongoose.model('VisitLog', VisitLogSchema)
|
||||
@@ -12,6 +12,7 @@ const Feedback = require('./Feedback')
|
||||
const Trend = require('./Trend')
|
||||
const VerificationCode = require('./VerificationCode')
|
||||
const CoinLedger = require('./CoinLedger')
|
||||
const VisitLog = require('./VisitLog')
|
||||
|
||||
module.exports = {
|
||||
User,
|
||||
@@ -27,5 +28,6 @@ module.exports = {
|
||||
Feedback,
|
||||
Trend,
|
||||
VerificationCode,
|
||||
CoinLedger
|
||||
CoinLedger,
|
||||
VisitLog
|
||||
}
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
const express = require('express')
|
||||
const router = express.Router()
|
||||
const mongoose = require('mongoose')
|
||||
const { User, Order, Knowledge, ShopItem, AdminLog, AIChatQuota, Feedback, LoginLog, Trend, KnowledgeLit, CoinLedger } = require('../models')
|
||||
const { User, Order, Knowledge, ShopItem, AdminLog, AIChatQuota, Feedback, LoginLog, Trend, KnowledgeLit, CoinLedger, VisitLog } = require('../models')
|
||||
const { authMiddleware, requirePermission } = require('../middleware/auth')
|
||||
const ApiResponse = require('../utils/response')
|
||||
const logger = require('../utils/logger')
|
||||
@@ -33,6 +33,54 @@ const logAction = async (req, action, resource, resourceId = null, details = {})
|
||||
* 获取仪表盘数据
|
||||
* GET /api/admin/dashboard
|
||||
*/
|
||||
// 访问统计(含 H5 / 小程序 / 直接访问)
|
||||
router.get('/visit-stats', requirePermission('dashboard:read'), async (req, res) => {
|
||||
try {
|
||||
const startOfDay = new Date(new Date().toLocaleDateString('sv-SE') + 'T00:00:00+08:00')
|
||||
const sevenAgo = new Date(Date.now() - 6 * 86400000)
|
||||
const startOf7 = new Date(new Date(sevenAgo).toLocaleDateString('sv-SE') + 'T00:00:00+08:00')
|
||||
const [pvToday, uvAgg, loginToday, h5Today, miniToday] = await Promise.all([
|
||||
VisitLog.countDocuments({ createdAt: { $gte: startOfDay } }),
|
||||
VisitLog.distinct('ip', { createdAt: { $gte: startOfDay } }),
|
||||
LoginLog.countDocuments({ createdAt: { $gte: startOfDay } }),
|
||||
VisitLog.countDocuments({ createdAt: { $gte: startOfDay }, source: 'H5' }),
|
||||
VisitLog.countDocuments({ createdAt: { $gte: startOfDay }, source: '小程序' }),
|
||||
])
|
||||
const sources = await VisitLog.aggregate([
|
||||
{ $match: { createdAt: { $gte: startOf7 } } },
|
||||
{ $group: { _id: '$source', c: { $sum: 1 } } },
|
||||
{ $sort: { c: -1 } },
|
||||
])
|
||||
return ApiResponse.success(res, {
|
||||
pvToday,
|
||||
uvToday: uvAgg.length,
|
||||
loginToday,
|
||||
h5Today,
|
||||
miniToday,
|
||||
sources7d: sources.map(s => ({ source: s._id || '直接访问', count: s.c })),
|
||||
})
|
||||
} catch (error) {
|
||||
logger.error('访问统计失败:', error)
|
||||
return ApiResponse.serverError(res, error.message)
|
||||
}
|
||||
})
|
||||
|
||||
// 访问日志列表
|
||||
router.get('/visits', requirePermission('dashboard:read'), async (req, res) => {
|
||||
try {
|
||||
const page = Math.max(1, parseInt(req.query.page) || 1)
|
||||
const limit = Math.min(50, parseInt(req.query.limit) || 20)
|
||||
const [total, items] = await Promise.all([
|
||||
VisitLog.countDocuments({}),
|
||||
VisitLog.find({}).sort({ createdAt: -1 }).skip((page - 1) * limit).limit(limit).lean(),
|
||||
])
|
||||
return ApiResponse.success(res, { items, total, page, limit })
|
||||
} catch (error) {
|
||||
logger.error('访问日志查询失败:', error)
|
||||
return ApiResponse.serverError(res, error.message)
|
||||
}
|
||||
})
|
||||
|
||||
router.get('/dashboard', requirePermission('dashboard:read'), async (req, res) => {
|
||||
try {
|
||||
const today = new Date()
|
||||
|
||||
Reference in New Issue
Block a user