diff --git a/admin/src/views/Dashboard.vue b/admin/src/views/Dashboard.vue
index 654b010..c3a193c 100644
--- a/admin/src/views/Dashboard.vue
+++ b/admin/src/views/Dashboard.vue
@@ -7,6 +7,21 @@
@@ -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 = {} }
})
diff --git a/admin/src/views/Logs.vue b/admin/src/views/Logs.vue
index fe4e0a6..3620d5e 100644
--- a/admin/src/views/Logs.vue
+++ b/admin/src/views/Logs.vue
@@ -2,6 +2,7 @@
+
@@ -20,6 +21,16 @@
{{ [row.os, row.browser].filter(Boolean).join(' / ') || row.device || '—' }}
+
+ {{ fmt(row.createdAt) }}
+
+
+ {{ [row.os, row.browser].filter(Boolean).join(' / ') || '—' }}
+
+
+
+
+
{{ fmt(row.timestamp || row.createdAt) }}
@@ -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
diff --git a/server/src/index.js b/server/src/index.js
index d63e960..856d87c 100755
--- a/server/src/index.js
+++ b/server/src/index.js
@@ -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({
diff --git a/server/src/models/VisitLog.js b/server/src/models/VisitLog.js
new file mode 100644
index 0000000..6efff1e
--- /dev/null
+++ b/server/src/models/VisitLog.js
@@ -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)
diff --git a/server/src/models/index.js b/server/src/models/index.js
index 4122ce9..422b448 100755
--- a/server/src/models/index.js
+++ b/server/src/models/index.js
@@ -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
}
diff --git a/server/src/routes/admin.js b/server/src/routes/admin.js
index 63ea2ed..b7058fe 100755
--- a/server/src/routes/admin.js
+++ b/server/src/routes/admin.js
@@ -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()