diff --git a/admin/src/views/Dashboard.vue b/admin/src/views/Dashboard.vue
index 6b86301..990a2e6 100644
--- a/admin/src/views/Dashboard.vue
+++ b/admin/src/views/Dashboard.vue
@@ -48,6 +48,9 @@
{{ row.method || '-' }}
+
+ {{ row.ipLoc || '—' }}
+
diff --git a/admin/src/views/Logs.vue b/admin/src/views/Logs.vue
index 3620d5e..af1c6b7 100644
--- a/admin/src/views/Logs.vue
+++ b/admin/src/views/Logs.vue
@@ -8,7 +8,8 @@
{{ fmt(row.createdAt) }}
- {{ row.userName || '未知用户' }}
+ {{ row.userName || '未知用户' }}
+ {{ row.ipLoc || '—' }}
diff --git a/server/package-lock.json b/server/package-lock.json
index 9c7481f..cb40e01 100755
--- a/server/package-lock.json
+++ b/server/package-lock.json
@@ -19,6 +19,7 @@
"express-rate-limit": "^7.1.5",
"express-validator": "^7.0.1",
"helmet": "^7.1.0",
+ "ip2region": "^2.3.0",
"jsonwebtoken": "^9.0.2",
"moment": "^2.29.4",
"mongoose": "^8.0.3",
@@ -1096,7 +1097,6 @@
"version": "25.5.0",
"resolved": "https://registry.npmjs.org/@types/node/-/node-25.5.0.tgz",
"integrity": "sha512-jp2P3tQMSxWugkCUKLRPVUpGaL5MVFwF8RDuSRztfwgN1wmqJeMSbKlnEtQqU8UrhTmzEmZdu2I6v2dpp7XIxw==",
- "dev": true,
"license": "MIT",
"dependencies": {
"undici-types": "~7.18.0"
@@ -2819,6 +2819,14 @@
"integrity": "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==",
"license": "ISC"
},
+ "node_modules/ip2region": {
+ "version": "2.3.0",
+ "resolved": "https://registry.npmjs.org/ip2region/-/ip2region-2.3.0.tgz",
+ "integrity": "sha512-zV5Xsadzrx9Ej6heoyhbXMsfGWWQ3C6bAIYStrHhw9kzLpGpVNlnAyRBxxPgxA1GNqr1Ti7oUxcWsMWNN3jZBg==",
+ "peerDependencies": {
+ "@types/node": "*"
+ }
+ },
"node_modules/ipaddr.js": {
"version": "1.9.1",
"resolved": "https://registry.npmjs.org/ipaddr.js/-/ipaddr.js-1.9.1.tgz",
@@ -5510,7 +5518,6 @@
"version": "7.18.2",
"resolved": "https://registry.npmjs.org/undici-types/-/undici-types-7.18.2.tgz",
"integrity": "sha512-AsuCzffGHJybSaRrmr5eHr81mwJU3kjw6M+uprWvCXiNeN9SOGwQ3Jn8jb8m3Z6izVgknn1R0FTCEAP2QrLY/w==",
- "dev": true,
"license": "MIT"
},
"node_modules/unpipe": {
diff --git a/server/package.json b/server/package.json
index cba9194..59a612a 100755
--- a/server/package.json
+++ b/server/package.json
@@ -27,6 +27,7 @@
"express-rate-limit": "^7.1.5",
"express-validator": "^7.0.1",
"helmet": "^7.1.0",
+ "ip2region": "^2.3.0",
"jsonwebtoken": "^9.0.2",
"moment": "^2.29.4",
"mongoose": "^8.0.3",
diff --git a/server/src/routes/admin.js b/server/src/routes/admin.js
index 85c2443..24612bc 100755
--- a/server/src/routes/admin.js
+++ b/server/src/routes/admin.js
@@ -2,6 +2,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, VisitLog } = require('../models')
+const { ipLocation } = require('../utils/iploc')
const { authMiddleware, requirePermission } = require('../middleware/auth')
const ApiResponse = require('../utils/response')
const logger = require('../utils/logger')
@@ -74,6 +75,7 @@ router.get('/visits', requirePermission('dashboard:read'), async (req, res) => {
VisitLog.countDocuments({}),
VisitLog.find({}).sort({ createdAt: -1 }).skip((page - 1) * limit).limit(limit).lean(),
])
+ items.forEach(v => { v.ipLoc = ipLocation(v.ip) })
return ApiResponse.success(res, { items, total, page, limit, pagination: { total } })
} catch (error) {
logger.error('访问日志查询失败:', error)
@@ -1098,6 +1100,7 @@ router.get('/login-logs', requirePermission('user:read'), async (req, res) => {
const formattedLogs = logs.map(log => ({
id: log._id,
userName: log.userId?.nickName || '未知用户',
+ ipLoc: ipLocation(log.ip),
userAvatar: log.userId?.avatarUrl || '',
type: log.type,
method: log.method,
@@ -1221,6 +1224,35 @@ router.get('/dashboard-v2', requirePermission('dashboard:read'), async (req, res
const DIM_NAMES = { 1: 'AI 起源', 2: 'AI 发展', 3: 'AI 当前', 4: 'AI 学习', 5: 'AI 趋势' }
const ai = aiStatsRaw[0] || { used: 0, users: 0 }
+ // 合并最近访问记录(登录 + 访问埋点),统一附 IP 归属地
+ const merged = [
+ ...recentLogins.map(l => ({
+ kind: 'login',
+ createdAt: l.createdAt,
+ userName: l.userId?.nickName || l.openid,
+ openid: l.openid,
+ type: l.type === 'register' ? '注册' : '登录',
+ method: l.method === 'wechat_miniapp' ? '微信小程序' : (l.method || ''),
+ ip: l.ip,
+ success: l.success,
+ failReason: l.failReason,
+ })),
+ ...recentVisits.map(v => ({
+ kind: 'visit',
+ createdAt: v.createdAt,
+ userName: v.source,
+ openid: '',
+ type: '访问',
+ method: [v.os, v.browser].filter(Boolean).join(' / ') || v.device || '',
+ ip: v.ip,
+ path: v.path,
+ success: true,
+ })),
+ ]
+ .sort((a, b) => new Date(b.createdAt) - new Date(a.createdAt))
+ .slice(0, 10)
+ merged.forEach(r => { r.ipLoc = ipLocation(r.ip) })
+
return ApiResponse.success(res, {
stats: {
totalUsers, newUsers7d,
@@ -1231,34 +1263,7 @@ router.get('/dashboard-v2', requirePermission('dashboard:read'), async (req, res
aiChatUsed: ai.used, aiChatUsers: ai.users,
},
litByDim: Object.entries(litByDim).map(([dim, count]) => ({ dim: Number(dim), name: DIM_NAMES[dim], count })),
- recentLogins: [
- // 登录记录(显示昵称)
- ...recentLogins.map(l => ({
- kind: 'login',
- createdAt: l.createdAt,
- userName: l.userId?.nickName || l.openid,
- openid: l.openid,
- type: l.type === 'register' ? '注册' : '登录',
- method: l.method === 'wechat_miniapp' ? '微信小程序' : (l.method || ''),
- ip: l.ip,
- success: l.success,
- failReason: l.failReason,
- })),
- // 访问埋点(H5 / 小程序 / 直接访问)
- ...recentVisits.map(v => ({
- kind: 'visit',
- createdAt: v.createdAt,
- userName: v.source,
- openid: '',
- type: '访问',
- method: [v.os, v.browser].filter(Boolean).join(' / ') || v.device || '',
- ip: v.ip,
- path: v.path,
- success: true,
- })),
- ]
- .sort((a, b) => new Date(b.createdAt) - new Date(a.createdAt))
- .slice(0, 10),
+ recentLogins: merged,
})
} catch (error) {
logger.error('获取增强看板失败:', error)
diff --git a/server/src/utils/iploc.js b/server/src/utils/iploc.js
new file mode 100644
index 0000000..784f77f
--- /dev/null
+++ b/server/src/utils/iploc.js
@@ -0,0 +1,26 @@
+const mod = require('ip2region')
+const IP2Region = mod.default ?? mod
+
+let query = null
+
+/** IP 归属地(离线库 ip2region,城市级;内网/无效 IP 返回空) */
+function ipLocation(ip) {
+ if (!ip) return ''
+ try {
+ if (!query) query = new IP2Region()
+ const r = query.search(ip) || {}
+ if (r.city === '内网IP') return '内网'
+ // 中国省份缺失时附带运营商;境外显示国家
+ if (r.country === '中国') {
+ const prov = (r.province || '').replace(/省|市|自治区|特别行政区|壮族|回族|维吾尔/g, '')
+ const city = (r.city || '').replace(/市$/, '')
+ const base = [prov, city].filter(Boolean).join(' ')
+ return base ? base : (r.isp ? `中国 ${r.isp}` : '中国')
+ }
+ return r.country || ''
+ } catch (e) {
+ return ''
+ }
+}
+
+module.exports = { ipLocation }