合作页面的内容
This commit is contained in:
@@ -10,11 +10,13 @@
|
|||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@types/markdown-it": "^14.1.2",
|
"@types/markdown-it": "^14.1.2",
|
||||||
|
"echarts": "^6.0.0",
|
||||||
"highlight": "^0.2.4",
|
"highlight": "^0.2.4",
|
||||||
"highlight.js": "^11.11.1",
|
"highlight.js": "^11.11.1",
|
||||||
"markdown-it": "^14.1.0",
|
"markdown-it": "^14.1.0",
|
||||||
"md-editor-v3": "^6.3.1",
|
"md-editor-v3": "^6.3.1",
|
||||||
"vue": "^3.5.13",
|
"vue": "^3.5.13",
|
||||||
|
"vue-echarts": "^8.0.1",
|
||||||
"vue-router": "^4.4.5"
|
"vue-router": "^4.4.5"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
|
|||||||
37
client/pnpm-lock.yaml
generated
37
client/pnpm-lock.yaml
generated
@@ -11,6 +11,9 @@ importers:
|
|||||||
'@types/markdown-it':
|
'@types/markdown-it':
|
||||||
specifier: ^14.1.2
|
specifier: ^14.1.2
|
||||||
version: 14.1.2
|
version: 14.1.2
|
||||||
|
echarts:
|
||||||
|
specifier: ^6.0.0
|
||||||
|
version: 6.0.0
|
||||||
highlight:
|
highlight:
|
||||||
specifier: ^0.2.4
|
specifier: ^0.2.4
|
||||||
version: 0.2.4
|
version: 0.2.4
|
||||||
@@ -26,6 +29,9 @@ importers:
|
|||||||
vue:
|
vue:
|
||||||
specifier: ^3.5.13
|
specifier: ^3.5.13
|
||||||
version: 3.5.26(typescript@5.6.3)
|
version: 3.5.26(typescript@5.6.3)
|
||||||
|
vue-echarts:
|
||||||
|
specifier: ^8.0.1
|
||||||
|
version: 8.0.1(echarts@6.0.0)(vue@3.5.26(typescript@5.6.3))
|
||||||
vue-router:
|
vue-router:
|
||||||
specifier: ^4.4.5
|
specifier: ^4.4.5
|
||||||
version: 4.6.4(vue@3.5.26(typescript@5.6.3))
|
version: 4.6.4(vue@3.5.26(typescript@5.6.3))
|
||||||
@@ -692,6 +698,9 @@ packages:
|
|||||||
dlv@1.1.3:
|
dlv@1.1.3:
|
||||||
resolution: {integrity: sha512-+HlytyjlPKnIG8XuRG8WvmBP8xs8P71y+SKKS6ZXWoEgLuePxtDoUEiH7WkdePWrQ5JBpE6aoVqfZfJUQkjXwA==}
|
resolution: {integrity: sha512-+HlytyjlPKnIG8XuRG8WvmBP8xs8P71y+SKKS6ZXWoEgLuePxtDoUEiH7WkdePWrQ5JBpE6aoVqfZfJUQkjXwA==}
|
||||||
|
|
||||||
|
echarts@6.0.0:
|
||||||
|
resolution: {integrity: sha512-Tte/grDQRiETQP4xz3iZWSvoHrkCQtwqd6hs+mifXcjrCuo2iKWbajFObuLJVBlDIJlOzgQPd1hsaKt/3+OMkQ==}
|
||||||
|
|
||||||
electron-to-chromium@1.5.267:
|
electron-to-chromium@1.5.267:
|
||||||
resolution: {integrity: sha512-0Drusm6MVRXSOJpGbaSVgcQsuB4hEkMpHXaVstcPmhu5LIedxs1xNK/nIxmQIU/RPC0+1/o0AVZfBTkTNJOdUw==}
|
resolution: {integrity: sha512-0Drusm6MVRXSOJpGbaSVgcQsuB4hEkMpHXaVstcPmhu5LIedxs1xNK/nIxmQIU/RPC0+1/o0AVZfBTkTNJOdUw==}
|
||||||
|
|
||||||
@@ -1023,6 +1032,9 @@ packages:
|
|||||||
ts-interface-checker@0.1.13:
|
ts-interface-checker@0.1.13:
|
||||||
resolution: {integrity: sha512-Y/arvbn+rrz3JCKl9C4kVNfTfSm2/mEp5FSz5EsZSANGPSlQrpRI5M4PKF+mJnE52jOO90PnPSc3Ur3bTQw0gA==}
|
resolution: {integrity: sha512-Y/arvbn+rrz3JCKl9C4kVNfTfSm2/mEp5FSz5EsZSANGPSlQrpRI5M4PKF+mJnE52jOO90PnPSc3Ur3bTQw0gA==}
|
||||||
|
|
||||||
|
tslib@2.3.0:
|
||||||
|
resolution: {integrity: sha512-N82ooyxVNm6h1riLCoyS9e3fuJ3AMG2zIZs2Gd1ATcSFjSA23Q0fzjjZeh0jbJvWVDZ0cJT8yaNNaaXHzueNjg==}
|
||||||
|
|
||||||
typescript@5.6.3:
|
typescript@5.6.3:
|
||||||
resolution: {integrity: sha512-hjcS1mhfuyi4WW8IWtjP7brDrG2cuDZukyrYrSauoXGNgx0S7zceP07adYkJycEr56BOUTNPzbInooiN3fn1qw==}
|
resolution: {integrity: sha512-hjcS1mhfuyi4WW8IWtjP7brDrG2cuDZukyrYrSauoXGNgx0S7zceP07adYkJycEr56BOUTNPzbInooiN3fn1qw==}
|
||||||
engines: {node: '>=14.17'}
|
engines: {node: '>=14.17'}
|
||||||
@@ -1083,6 +1095,12 @@ packages:
|
|||||||
vscode-uri@3.1.0:
|
vscode-uri@3.1.0:
|
||||||
resolution: {integrity: sha512-/BpdSx+yCQGnCvecbyXdxHDkuk55/G3xwnC0GqY4gmQ3j+A+g8kzzgB4Nk/SINjqn6+waqw3EgbVF2QKExkRxQ==}
|
resolution: {integrity: sha512-/BpdSx+yCQGnCvecbyXdxHDkuk55/G3xwnC0GqY4gmQ3j+A+g8kzzgB4Nk/SINjqn6+waqw3EgbVF2QKExkRxQ==}
|
||||||
|
|
||||||
|
vue-echarts@8.0.1:
|
||||||
|
resolution: {integrity: sha512-23rJTFLu1OUEGRWjJGmdGt8fP+8+ja1gVgzMYPIPaHWpXegcO1viIAaeu2H4QHESlVeHzUAHIxKXGrwjsyXAaA==}
|
||||||
|
peerDependencies:
|
||||||
|
echarts: ^6.0.0
|
||||||
|
vue: ^3.3.0
|
||||||
|
|
||||||
vue-router@4.6.4:
|
vue-router@4.6.4:
|
||||||
resolution: {integrity: sha512-Hz9q5sa33Yhduglwz6g9skT8OBPii+4bFn88w6J+J4MfEo4KRRpmiNG/hHHkdbRFlLBOqxN8y8gf2Fb0MTUgVg==}
|
resolution: {integrity: sha512-Hz9q5sa33Yhduglwz6g9skT8OBPii+4bFn88w6J+J4MfEo4KRRpmiNG/hHHkdbRFlLBOqxN8y8gf2Fb0MTUgVg==}
|
||||||
peerDependencies:
|
peerDependencies:
|
||||||
@@ -1110,6 +1128,9 @@ packages:
|
|||||||
engines: {node: '>= 0.10.0'}
|
engines: {node: '>= 0.10.0'}
|
||||||
hasBin: true
|
hasBin: true
|
||||||
|
|
||||||
|
zrender@6.0.0:
|
||||||
|
resolution: {integrity: sha512-41dFXEEXuJpNecuUQq6JlbybmnHaqqpGlbH1yxnA5V9MMP4SbohSVZsJIwz+zdjQXSSlR1Vc34EgH1zxyTDvhg==}
|
||||||
|
|
||||||
snapshots:
|
snapshots:
|
||||||
|
|
||||||
'@alloc/quick-lru@5.2.0': {}
|
'@alloc/quick-lru@5.2.0': {}
|
||||||
@@ -1843,6 +1864,11 @@ snapshots:
|
|||||||
|
|
||||||
dlv@1.1.3: {}
|
dlv@1.1.3: {}
|
||||||
|
|
||||||
|
echarts@6.0.0:
|
||||||
|
dependencies:
|
||||||
|
tslib: 2.3.0
|
||||||
|
zrender: 6.0.0
|
||||||
|
|
||||||
electron-to-chromium@1.5.267: {}
|
electron-to-chromium@1.5.267: {}
|
||||||
|
|
||||||
entities@4.5.0: {}
|
entities@4.5.0: {}
|
||||||
@@ -2207,6 +2233,8 @@ snapshots:
|
|||||||
|
|
||||||
ts-interface-checker@0.1.13: {}
|
ts-interface-checker@0.1.13: {}
|
||||||
|
|
||||||
|
tslib@2.3.0: {}
|
||||||
|
|
||||||
typescript@5.6.3: {}
|
typescript@5.6.3: {}
|
||||||
|
|
||||||
uc.micro@2.1.0: {}
|
uc.micro@2.1.0: {}
|
||||||
@@ -2233,6 +2261,11 @@ snapshots:
|
|||||||
|
|
||||||
vscode-uri@3.1.0: {}
|
vscode-uri@3.1.0: {}
|
||||||
|
|
||||||
|
vue-echarts@8.0.1(echarts@6.0.0)(vue@3.5.26(typescript@5.6.3)):
|
||||||
|
dependencies:
|
||||||
|
echarts: 6.0.0
|
||||||
|
vue: 3.5.26(typescript@5.6.3)
|
||||||
|
|
||||||
vue-router@4.6.4(vue@3.5.26(typescript@5.6.3)):
|
vue-router@4.6.4(vue@3.5.26(typescript@5.6.3)):
|
||||||
dependencies:
|
dependencies:
|
||||||
'@vue/devtools-api': 6.6.4
|
'@vue/devtools-api': 6.6.4
|
||||||
@@ -2260,3 +2293,7 @@ snapshots:
|
|||||||
dependencies:
|
dependencies:
|
||||||
commander: 2.20.3
|
commander: 2.20.3
|
||||||
cssfilter: 0.0.10
|
cssfilter: 0.0.10
|
||||||
|
|
||||||
|
zrender@6.0.0:
|
||||||
|
dependencies:
|
||||||
|
tslib: 2.3.0
|
||||||
|
|||||||
@@ -45,12 +45,7 @@
|
|||||||
</li>
|
</li>
|
||||||
</ul>
|
</ul>
|
||||||
</nav>
|
</nav>
|
||||||
<div class="sidebar-footer">
|
<!-- Sidebar Footer removed as per request -->
|
||||||
<button @click="handleLogout" class="logout-btn">
|
|
||||||
<span class="nav-icon">🚪</span>
|
|
||||||
<span class="nav-text">退出登录</span>
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</aside>
|
</aside>
|
||||||
|
|
||||||
<!-- Main Content Area -->
|
<!-- Main Content Area -->
|
||||||
@@ -63,9 +58,33 @@
|
|||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
<div class="header-right">
|
<div class="header-right">
|
||||||
<div class="user-info">
|
<div class="user-dropdown relative" ref="dropdownRef">
|
||||||
<span class="username">{{ currentUser.username }}</span>
|
<button class="user-info-btn" @click="toggleUserDropdown">
|
||||||
<span class="role">({{ currentUser.role }})</span>
|
<span class="username">{{ currentUser.username }}</span>
|
||||||
|
<span class="role">({{ currentUser.role }})</span>
|
||||||
|
<span class="arrow" :class="{ 'rotated': isUserDropdownOpen }">▼</span>
|
||||||
|
</button>
|
||||||
|
|
||||||
|
<div v-if="isUserDropdownOpen" class="dropdown-menu">
|
||||||
|
<div class="dropdown-header">
|
||||||
|
<span class="user-email">管理员</span>
|
||||||
|
</div>
|
||||||
|
<ul class="dropdown-list">
|
||||||
|
<li>
|
||||||
|
<button @click="handleChangePassword" class="dropdown-item">
|
||||||
|
<span class="icon">🔒</span>
|
||||||
|
修改密码
|
||||||
|
</button>
|
||||||
|
</li>
|
||||||
|
<li class="divider"></li>
|
||||||
|
<li>
|
||||||
|
<button @click="handleLogout" class="dropdown-item text-red-400">
|
||||||
|
<span class="icon">🚪</span>
|
||||||
|
退出登录
|
||||||
|
</button>
|
||||||
|
</li>
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</header>
|
</header>
|
||||||
@@ -79,7 +98,7 @@
|
|||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { ref, onMounted } from 'vue'
|
import { ref, onMounted, onUnmounted } from 'vue'
|
||||||
import { useRouter } from 'vue-router'
|
import { useRouter } from 'vue-router'
|
||||||
import { useToast } from '../../composables/useToast'
|
import { useToast } from '../../composables/useToast'
|
||||||
|
|
||||||
@@ -88,6 +107,26 @@ const toast = useToast()
|
|||||||
const isSidebarOpen = ref(true)
|
const isSidebarOpen = ref(true)
|
||||||
const currentUser = ref(JSON.parse(localStorage.getItem('user') || '{}'))
|
const currentUser = ref(JSON.parse(localStorage.getItem('user') || '{}'))
|
||||||
|
|
||||||
|
// Dropdown logic
|
||||||
|
const isUserDropdownOpen = ref(false)
|
||||||
|
const dropdownRef = ref<HTMLElement | null>(null)
|
||||||
|
|
||||||
|
const toggleUserDropdown = () => {
|
||||||
|
isUserDropdownOpen.value = !isUserDropdownOpen.value
|
||||||
|
}
|
||||||
|
|
||||||
|
const closeDropdown = (e: MouseEvent) => {
|
||||||
|
if (dropdownRef.value && !dropdownRef.value.contains(e.target as Node)) {
|
||||||
|
isUserDropdownOpen.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleChangePassword = () => {
|
||||||
|
// Implement change password logic or navigate to page
|
||||||
|
toast.showToast('功能开发中...', 'info')
|
||||||
|
isUserDropdownOpen.value = false
|
||||||
|
}
|
||||||
|
|
||||||
interface MenuItem {
|
interface MenuItem {
|
||||||
title: string
|
title: string
|
||||||
icon: string
|
icon: string
|
||||||
@@ -173,6 +212,11 @@ onMounted(() => {
|
|||||||
if (!token) {
|
if (!token) {
|
||||||
router.push('/login')
|
router.push('/login')
|
||||||
}
|
}
|
||||||
|
document.addEventListener('click', closeDropdown)
|
||||||
|
})
|
||||||
|
|
||||||
|
onUnmounted(() => {
|
||||||
|
document.removeEventListener('click', closeDropdown)
|
||||||
})
|
})
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
@@ -184,6 +228,105 @@ onMounted(() => {
|
|||||||
color: #ececec;
|
color: #ececec;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* User Dropdown Styles */
|
||||||
|
.user-dropdown {
|
||||||
|
position: relative;
|
||||||
|
}
|
||||||
|
|
||||||
|
.user-info-btn {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.75rem;
|
||||||
|
background: transparent;
|
||||||
|
border: none;
|
||||||
|
cursor: pointer;
|
||||||
|
padding: 0.5rem;
|
||||||
|
border-radius: 0.375rem;
|
||||||
|
transition: background-color 0.2s;
|
||||||
|
}
|
||||||
|
|
||||||
|
.user-info-btn:hover {
|
||||||
|
background-color: rgba(255, 255, 255, 0.05);
|
||||||
|
}
|
||||||
|
|
||||||
|
.arrow {
|
||||||
|
font-size: 0.7rem;
|
||||||
|
color: rgba(255, 255, 255, 0.5);
|
||||||
|
transition: transform 0.2s;
|
||||||
|
}
|
||||||
|
|
||||||
|
.arrow.rotated {
|
||||||
|
transform: rotate(180deg);
|
||||||
|
}
|
||||||
|
|
||||||
|
.dropdown-menu {
|
||||||
|
position: absolute;
|
||||||
|
top: 100%;
|
||||||
|
right: 0;
|
||||||
|
margin-top: 0.5rem;
|
||||||
|
width: 200px;
|
||||||
|
background: #1a1a1a;
|
||||||
|
border: 1px solid rgba(255, 255, 255, 0.1);
|
||||||
|
border-radius: 0.5rem;
|
||||||
|
box-shadow: 0 4px 20px rgba(0, 0, 0, 0.3);
|
||||||
|
z-index: 100;
|
||||||
|
overflow: hidden;
|
||||||
|
animation: slideIn 0.2s ease-out;
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes slideIn {
|
||||||
|
from { opacity: 0; transform: translateY(-10px); }
|
||||||
|
to { opacity: 1; transform: translateY(0); }
|
||||||
|
}
|
||||||
|
|
||||||
|
.dropdown-header {
|
||||||
|
padding: 1rem;
|
||||||
|
border-bottom: 1px solid rgba(255, 255, 255, 0.05);
|
||||||
|
background: rgba(255, 255, 255, 0.02);
|
||||||
|
}
|
||||||
|
|
||||||
|
.user-email {
|
||||||
|
font-size: 0.85rem;
|
||||||
|
color: rgba(255, 255, 255, 0.5);
|
||||||
|
display: block;
|
||||||
|
}
|
||||||
|
|
||||||
|
.dropdown-list {
|
||||||
|
list-style: none;
|
||||||
|
padding: 0.5rem 0;
|
||||||
|
margin: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.dropdown-item {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.75rem;
|
||||||
|
width: 100%;
|
||||||
|
padding: 0.75rem 1rem;
|
||||||
|
background: transparent;
|
||||||
|
border: none;
|
||||||
|
color: rgba(255, 255, 255, 0.8);
|
||||||
|
font-size: 0.9rem;
|
||||||
|
cursor: pointer;
|
||||||
|
text-align: left;
|
||||||
|
transition: background-color 0.2s;
|
||||||
|
}
|
||||||
|
|
||||||
|
.dropdown-item:hover {
|
||||||
|
background-color: rgba(212, 179, 131, 0.1);
|
||||||
|
color: #d4b383;
|
||||||
|
}
|
||||||
|
|
||||||
|
.dropdown-item .icon {
|
||||||
|
font-size: 1.1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.divider {
|
||||||
|
height: 1px;
|
||||||
|
background-color: rgba(255, 255, 255, 0.05);
|
||||||
|
margin: 0.5rem 0;
|
||||||
|
}
|
||||||
|
|
||||||
/* Sidebar Styles */
|
/* Sidebar Styles */
|
||||||
.admin-sidebar {
|
.admin-sidebar {
|
||||||
width: 250px;
|
width: 250px;
|
||||||
|
|||||||
@@ -1,362 +0,0 @@
|
|||||||
<template>
|
|
||||||
<div class="dashboard-container">
|
|
||||||
<h1 class="page-title">仪表盘</h1>
|
|
||||||
|
|
||||||
<!-- Stats Cards -->
|
|
||||||
<div class="stats-grid">
|
|
||||||
<div class="stat-card">
|
|
||||||
<div class="stat-icon users">👥</div>
|
|
||||||
<div class="stat-content">
|
|
||||||
<h3 class="stat-title">用户总数</h3>
|
|
||||||
<p class="stat-value">{{ stats.users }}</p>
|
|
||||||
<span class="stat-change positive">+2.5%</span>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="stat-card">
|
|
||||||
<div class="stat-icon posts">📝</div>
|
|
||||||
<div class="stat-content">
|
|
||||||
<h3 class="stat-title">文章总数</h3>
|
|
||||||
<p class="stat-value">{{ stats.posts }}</p>
|
|
||||||
<span class="stat-change positive">+5.2%</span>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="stat-card">
|
|
||||||
<div class="stat-icon works">🎨</div>
|
|
||||||
<div class="stat-content">
|
|
||||||
<h3 class="stat-title">作品总数</h3>
|
|
||||||
<p class="stat-value">{{ stats.works }}</p>
|
|
||||||
<span class="stat-change positive">+3.1%</span>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="stat-card">
|
|
||||||
<div class="stat-icon snippets">💻</div>
|
|
||||||
<div class="stat-content">
|
|
||||||
<h3 class="stat-title">代码片段</h3>
|
|
||||||
<p class="stat-value">{{ stats.snippets }}</p>
|
|
||||||
<span class="stat-change negative">-1.2%</span>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- Recent Activities -->
|
|
||||||
<div class="dashboard-grid">
|
|
||||||
<div class="panel">
|
|
||||||
<h2 class="panel-title">最近操作</h2>
|
|
||||||
<div class="activity-list">
|
|
||||||
<div class="activity-item" v-for="activity in recentActivities" :key="activity.id">
|
|
||||||
<div class="activity-icon">
|
|
||||||
{{ activity.icon }}
|
|
||||||
</div>
|
|
||||||
<div class="activity-content">
|
|
||||||
<p class="activity-text">{{ activity.text }}</p>
|
|
||||||
<span class="activity-time">{{ activity.time }}</span>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- Quick Actions -->
|
|
||||||
<div class="panel">
|
|
||||||
<h2 class="panel-title">快速操作</h2>
|
|
||||||
<div class="quick-actions">
|
|
||||||
<button class="action-btn" @click="router.push('/admin/posts/create')">
|
|
||||||
<span class="action-icon">📝</span>
|
|
||||||
<span class="action-text">新建文章</span>
|
|
||||||
</button>
|
|
||||||
<button class="action-btn" @click="router.push('/admin/works/create')">
|
|
||||||
<span class="action-icon">🎨</span>
|
|
||||||
<span class="action-text">新建作品</span>
|
|
||||||
</button>
|
|
||||||
<button class="action-btn" @click="router.push('/admin/snippets/create')">
|
|
||||||
<span class="action-icon">💻</span>
|
|
||||||
<span class="action-text">新建代码片段</span>
|
|
||||||
</button>
|
|
||||||
<button class="action-btn" @click="router.push('/admin/users/create')">
|
|
||||||
<span class="action-icon">👥</span>
|
|
||||||
<span class="action-text">新建用户</span>
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</template>
|
|
||||||
|
|
||||||
<script setup lang="ts">
|
|
||||||
import { ref, onMounted } from 'vue'
|
|
||||||
import { useRouter } from 'vue-router'
|
|
||||||
import { getDashboardStats, getRecentActivities } from '../../services/api'
|
|
||||||
|
|
||||||
const router = useRouter()
|
|
||||||
|
|
||||||
// Stats data
|
|
||||||
const stats = ref({
|
|
||||||
users: 0,
|
|
||||||
posts: 0,
|
|
||||||
works: 0,
|
|
||||||
snippets: 0
|
|
||||||
})
|
|
||||||
|
|
||||||
// Recent activities data
|
|
||||||
const recentActivities = ref([])
|
|
||||||
|
|
||||||
// Fetch dashboard data
|
|
||||||
const fetchDashboardData = async () => {
|
|
||||||
try {
|
|
||||||
// Get stats
|
|
||||||
const statsData = await getDashboardStats()
|
|
||||||
stats.value = statsData
|
|
||||||
|
|
||||||
// Get recent activities
|
|
||||||
const activitiesData = await getRecentActivities()
|
|
||||||
recentActivities.value = activitiesData
|
|
||||||
} catch (error) {
|
|
||||||
console.error('Failed to fetch dashboard data:', error)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
onMounted(() => {
|
|
||||||
fetchDashboardData()
|
|
||||||
})
|
|
||||||
</script>
|
|
||||||
|
|
||||||
<style scoped>
|
|
||||||
.dashboard-container {
|
|
||||||
width: 100%;
|
|
||||||
}
|
|
||||||
|
|
||||||
.page-title {
|
|
||||||
font-size: 1.75rem;
|
|
||||||
font-weight: 600;
|
|
||||||
color: #d4b383;
|
|
||||||
margin-bottom: 1.5rem;
|
|
||||||
font-family: 'Inter', sans-serif;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Stats Grid */
|
|
||||||
.stats-grid {
|
|
||||||
display: grid;
|
|
||||||
grid-template-columns: repeat(auto-fit, minmax(200px, 1fr));
|
|
||||||
gap: 1rem;
|
|
||||||
margin-bottom: 1.5rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
.stat-card {
|
|
||||||
background: rgba(255, 255, 255, 0.05);
|
|
||||||
backdrop-filter: blur(10px);
|
|
||||||
-webkit-backdrop-filter: blur(10px);
|
|
||||||
padding: 1.5rem;
|
|
||||||
border-radius: 0.5rem;
|
|
||||||
border: 1px solid rgba(255, 255, 255, 0.1);
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
transition: all 0.3s cubic-bezier(0.16, 1, 0.3, 1);
|
|
||||||
box-shadow: 0 4px 6px rgba(0, 0, 0, 0.1);
|
|
||||||
}
|
|
||||||
|
|
||||||
.stat-card:hover {
|
|
||||||
transform: translateY(-2px);
|
|
||||||
box-shadow: 0 8px 16px rgba(0, 0, 0, 0.15);
|
|
||||||
background: rgba(255, 255, 255, 0.08);
|
|
||||||
border-color: rgba(212, 179, 131, 0.3);
|
|
||||||
}
|
|
||||||
|
|
||||||
.stat-icon {
|
|
||||||
font-size: 2.5rem;
|
|
||||||
margin-right: 1rem;
|
|
||||||
width: 60px;
|
|
||||||
height: 60px;
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
justify-content: center;
|
|
||||||
border-radius: 0.5rem;
|
|
||||||
background-color: rgba(212, 179, 131, 0.1);
|
|
||||||
color: #d4b383;
|
|
||||||
}
|
|
||||||
|
|
||||||
.stat-icon.users {
|
|
||||||
background-color: rgba(59, 130, 246, 0.1);
|
|
||||||
color: rgba(59, 130, 246, 0.8);
|
|
||||||
}
|
|
||||||
|
|
||||||
.stat-icon.posts {
|
|
||||||
background-color: rgba(16, 185, 129, 0.1);
|
|
||||||
color: rgba(16, 185, 129, 0.8);
|
|
||||||
}
|
|
||||||
|
|
||||||
.stat-icon.works {
|
|
||||||
background-color: rgba(251, 191, 36, 0.1);
|
|
||||||
color: rgba(251, 191, 36, 0.8);
|
|
||||||
}
|
|
||||||
|
|
||||||
.stat-icon.snippets {
|
|
||||||
background-color: rgba(139, 92, 246, 0.1);
|
|
||||||
color: rgba(139, 92, 246, 0.8);
|
|
||||||
}
|
|
||||||
|
|
||||||
.stat-content {
|
|
||||||
flex: 1;
|
|
||||||
}
|
|
||||||
|
|
||||||
.stat-title {
|
|
||||||
font-size: 0.875rem;
|
|
||||||
font-weight: 500;
|
|
||||||
color: rgba(255, 255, 255, 0.6);
|
|
||||||
margin: 0 0 0.25rem 0;
|
|
||||||
font-family: 'Inter', sans-serif;
|
|
||||||
}
|
|
||||||
|
|
||||||
.stat-value {
|
|
||||||
font-size: 1.75rem;
|
|
||||||
font-weight: 600;
|
|
||||||
color: white;
|
|
||||||
margin: 0 0 0.25rem 0;
|
|
||||||
font-family: 'Inter', sans-serif;
|
|
||||||
}
|
|
||||||
|
|
||||||
.stat-change {
|
|
||||||
font-size: 0.75rem;
|
|
||||||
font-weight: 500;
|
|
||||||
}
|
|
||||||
|
|
||||||
.stat-change.positive {
|
|
||||||
color: #10b981;
|
|
||||||
}
|
|
||||||
|
|
||||||
.stat-change.negative {
|
|
||||||
color: #ef4444;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Dashboard Grid */
|
|
||||||
.dashboard-grid {
|
|
||||||
display: grid;
|
|
||||||
grid-template-columns: 2fr 1fr;
|
|
||||||
gap: 1.5rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Panel Styles */
|
|
||||||
.panel {
|
|
||||||
background: rgba(255, 255, 255, 0.05);
|
|
||||||
backdrop-filter: blur(10px);
|
|
||||||
-webkit-backdrop-filter: blur(10px);
|
|
||||||
border-radius: 0.5rem;
|
|
||||||
border: 1px solid rgba(255, 255, 255, 0.1);
|
|
||||||
padding: 1.5rem;
|
|
||||||
box-shadow: 0 4px 6px rgba(0, 0, 0, 0.1);
|
|
||||||
}
|
|
||||||
|
|
||||||
.panel-title {
|
|
||||||
font-size: 1.25rem;
|
|
||||||
font-weight: 600;
|
|
||||||
color: #d4b383;
|
|
||||||
margin: 0 0 1rem 0;
|
|
||||||
font-family: 'Playfair Display', serif;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Activity List */
|
|
||||||
.activity-list {
|
|
||||||
display: flex;
|
|
||||||
flex-direction: column;
|
|
||||||
gap: 1rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
.activity-item {
|
|
||||||
display: flex;
|
|
||||||
align-items: flex-start;
|
|
||||||
gap: 0.75rem;
|
|
||||||
padding-bottom: 1rem;
|
|
||||||
border-bottom: 1px solid rgba(255, 255, 255, 0.1);
|
|
||||||
}
|
|
||||||
|
|
||||||
.activity-item:last-child {
|
|
||||||
border-bottom: none;
|
|
||||||
padding-bottom: 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
.activity-icon {
|
|
||||||
font-size: 1.25rem;
|
|
||||||
margin-top: 0.25rem;
|
|
||||||
width: auto;
|
|
||||||
height: auto;
|
|
||||||
background: transparent;
|
|
||||||
color: #d4b383;
|
|
||||||
}
|
|
||||||
|
|
||||||
.activity-content {
|
|
||||||
flex: 1;
|
|
||||||
}
|
|
||||||
|
|
||||||
.activity-text {
|
|
||||||
font-size: 0.95rem;
|
|
||||||
color: rgba(255, 255, 255, 0.8);
|
|
||||||
margin: 0 0 0.25rem 0;
|
|
||||||
font-family: 'Inter', sans-serif;
|
|
||||||
}
|
|
||||||
|
|
||||||
.activity-time {
|
|
||||||
font-size: 0.8rem;
|
|
||||||
color: rgba(255, 255, 255, 0.4);
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Quick Actions */
|
|
||||||
.quick-actions {
|
|
||||||
display: flex;
|
|
||||||
flex-direction: column;
|
|
||||||
gap: 0.75rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
.action-btn {
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
gap: 0.75rem;
|
|
||||||
padding: 1rem;
|
|
||||||
background: rgba(255, 255, 255, 0.05);
|
|
||||||
border: 1px solid rgba(255, 255, 255, 0.1);
|
|
||||||
border-radius: 0.5rem;
|
|
||||||
cursor: pointer;
|
|
||||||
transition: all 0.3s cubic-bezier(0.16, 1, 0.3, 1);
|
|
||||||
text-align: left;
|
|
||||||
color: rgba(255, 255, 255, 0.8);
|
|
||||||
font-family: 'Inter', sans-serif;
|
|
||||||
}
|
|
||||||
|
|
||||||
.action-btn:hover {
|
|
||||||
background: rgba(212, 179, 131, 0.1);
|
|
||||||
border-color: #d4b383;
|
|
||||||
transform: translateX(4px);
|
|
||||||
box-shadow: 0 5px 15px rgba(212, 179, 131, 0.1);
|
|
||||||
}
|
|
||||||
|
|
||||||
.action-icon {
|
|
||||||
font-size: 1.25rem;
|
|
||||||
background: transparent;
|
|
||||||
color: #d4b383;
|
|
||||||
}
|
|
||||||
|
|
||||||
.action-text {
|
|
||||||
font-size: 0.95rem;
|
|
||||||
color: rgba(255, 255, 255, 0.8);
|
|
||||||
font-weight: 500;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Responsive Design */
|
|
||||||
@media (max-width: 1024px) {
|
|
||||||
.dashboard-grid {
|
|
||||||
grid-template-columns: 1fr;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
@media (max-width: 768px) {
|
|
||||||
.stats-grid {
|
|
||||||
grid-template-columns: repeat(2, 1fr);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
@media (max-width: 480px) {
|
|
||||||
.stats-grid {
|
|
||||||
grid-template-columns: 1fr;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
</style>
|
|
||||||
273
client/src/pages/admin/dashboard/Overview.vue
Normal file
273
client/src/pages/admin/dashboard/Overview.vue
Normal file
@@ -0,0 +1,273 @@
|
|||||||
|
<template>
|
||||||
|
<div class="dashboard-overview">
|
||||||
|
<!-- Top Stats Cards -->
|
||||||
|
<div class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-6 mb-8">
|
||||||
|
<div class="stat-card">
|
||||||
|
<div class="stat-icon bg-blue-500/10 text-blue-400">📝</div>
|
||||||
|
<div class="stat-info">
|
||||||
|
<h3>总文章数</h3>
|
||||||
|
<p>{{ stats.posts || 0 }}</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="stat-card">
|
||||||
|
<div class="stat-icon bg-green-500/10 text-green-400">👥</div>
|
||||||
|
<div class="stat-info">
|
||||||
|
<h3>今日访客 (UV)</h3>
|
||||||
|
<!-- 这里应该展示今日UV,目前API返回的是列表,需要处理 -->
|
||||||
|
<p>{{ todayUV }}</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="stat-card">
|
||||||
|
<div class="stat-icon bg-purple-500/10 text-purple-400">📩</div>
|
||||||
|
<div class="stat-info">
|
||||||
|
<h3>合作咨询</h3>
|
||||||
|
<p>{{ stats.inquiryCount || 0 }}</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="stat-card">
|
||||||
|
<div class="stat-icon bg-yellow-500/10 text-yellow-400">🎨</div>
|
||||||
|
<div class="stat-info">
|
||||||
|
<h3>作品展示</h3>
|
||||||
|
<!-- 暂时没有返回作品数,先占位 -->
|
||||||
|
<p>-</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Charts Row 1 -->
|
||||||
|
<div class="grid grid-cols-1 lg:grid-cols-2 gap-6 mb-8">
|
||||||
|
<!-- New Posts Trend -->
|
||||||
|
<div class="chart-card">
|
||||||
|
<h3 class="chart-title">近7天新增博文</h3>
|
||||||
|
<div class="h-80">
|
||||||
|
<v-chart class="chart" :option="postsTrendOption" autoresize />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- UV Trend -->
|
||||||
|
<div class="chart-card">
|
||||||
|
<h3 class="chart-title">近7天访客趋势</h3>
|
||||||
|
<div class="h-80">
|
||||||
|
<v-chart class="chart" :option="uvTrendOption" autoresize />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Charts Row 2 -->
|
||||||
|
<div class="grid grid-cols-1 lg:grid-cols-2 gap-6">
|
||||||
|
<!-- User Regions -->
|
||||||
|
<div class="chart-card">
|
||||||
|
<h3 class="chart-title">用户地域分布</h3>
|
||||||
|
<div class="h-80">
|
||||||
|
<v-chart class="chart" :option="regionOption" autoresize />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Top Posts -->
|
||||||
|
<div class="chart-card">
|
||||||
|
<h3 class="chart-title">热门文章 Top 5</h3>
|
||||||
|
<div class="h-80">
|
||||||
|
<v-chart class="chart" :option="topPostsOption" autoresize />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup lang="ts">
|
||||||
|
import { ref, onMounted, computed } from 'vue'
|
||||||
|
import { use } from 'echarts/core'
|
||||||
|
import { CanvasRenderer } from 'echarts/renderers'
|
||||||
|
import { BarChart, LineChart, PieChart } from 'echarts/charts'
|
||||||
|
import {
|
||||||
|
GridComponent,
|
||||||
|
TooltipComponent,
|
||||||
|
LegendComponent,
|
||||||
|
TitleComponent
|
||||||
|
} from 'echarts/components'
|
||||||
|
import VChart from 'vue-echarts'
|
||||||
|
import { getDashboardStats } from '../../../services/api'
|
||||||
|
|
||||||
|
use([
|
||||||
|
CanvasRenderer,
|
||||||
|
BarChart,
|
||||||
|
LineChart,
|
||||||
|
PieChart,
|
||||||
|
GridComponent,
|
||||||
|
TooltipComponent,
|
||||||
|
LegendComponent,
|
||||||
|
TitleComponent
|
||||||
|
])
|
||||||
|
|
||||||
|
const stats = ref<any>({})
|
||||||
|
|
||||||
|
const todayUV = computed(() => {
|
||||||
|
if (!stats.value.uvTrend || stats.value.uvTrend.length === 0) return 0
|
||||||
|
return stats.value.uvTrend[stats.value.uvTrend.length - 1].Count
|
||||||
|
})
|
||||||
|
|
||||||
|
// Chart Options
|
||||||
|
const postsTrendOption = computed(() => ({
|
||||||
|
tooltip: { trigger: 'axis' },
|
||||||
|
xAxis: {
|
||||||
|
type: 'category',
|
||||||
|
data: stats.value.postsTrend?.map((i: any) => i.Date) || [],
|
||||||
|
axisLabel: { color: '#ccc' }
|
||||||
|
},
|
||||||
|
yAxis: {
|
||||||
|
type: 'value',
|
||||||
|
axisLabel: { color: '#ccc' },
|
||||||
|
splitLine: { lineStyle: { color: 'rgba(255,255,255,0.1)' } }
|
||||||
|
},
|
||||||
|
series: [{
|
||||||
|
data: stats.value.postsTrend?.map((i: any) => i.Count) || [],
|
||||||
|
type: 'line',
|
||||||
|
smooth: true,
|
||||||
|
itemStyle: { color: '#3b82f6' },
|
||||||
|
areaStyle: { color: 'rgba(59, 130, 246, 0.1)' }
|
||||||
|
}]
|
||||||
|
}))
|
||||||
|
|
||||||
|
const uvTrendOption = computed(() => ({
|
||||||
|
tooltip: { trigger: 'axis' },
|
||||||
|
xAxis: {
|
||||||
|
type: 'category',
|
||||||
|
data: stats.value.uvTrend?.map((i: any) => i.Date) || [],
|
||||||
|
axisLabel: { color: '#ccc' }
|
||||||
|
},
|
||||||
|
yAxis: {
|
||||||
|
type: 'value',
|
||||||
|
axisLabel: { color: '#ccc' },
|
||||||
|
splitLine: { lineStyle: { color: 'rgba(255,255,255,0.1)' } }
|
||||||
|
},
|
||||||
|
series: [{
|
||||||
|
data: stats.value.uvTrend?.map((i: any) => i.Count) || [],
|
||||||
|
type: 'bar',
|
||||||
|
itemStyle: { color: '#10b981' },
|
||||||
|
barWidth: '40%'
|
||||||
|
}]
|
||||||
|
}))
|
||||||
|
|
||||||
|
const regionOption = computed(() => ({
|
||||||
|
tooltip: { trigger: 'item' },
|
||||||
|
legend: {
|
||||||
|
orient: 'vertical',
|
||||||
|
left: 'left',
|
||||||
|
textStyle: { color: '#ccc' }
|
||||||
|
},
|
||||||
|
series: [
|
||||||
|
{
|
||||||
|
name: '访问来源',
|
||||||
|
type: 'pie',
|
||||||
|
radius: ['40%', '70%'],
|
||||||
|
avoidLabelOverlap: false,
|
||||||
|
itemStyle: {
|
||||||
|
borderRadius: 10,
|
||||||
|
borderColor: '#050505',
|
||||||
|
borderWidth: 2
|
||||||
|
},
|
||||||
|
label: { show: false, position: 'center' },
|
||||||
|
emphasis: {
|
||||||
|
label: { show: true, fontSize: 20, fontWeight: 'bold', color: '#fff' }
|
||||||
|
},
|
||||||
|
labelLine: { show: false },
|
||||||
|
data: stats.value.userRegions?.map((i: any) => ({ value: i.Count, name: i.Region })) || []
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}))
|
||||||
|
|
||||||
|
const topPostsOption = computed(() => ({
|
||||||
|
tooltip: { trigger: 'axis', axisPointer: { type: 'shadow' } },
|
||||||
|
grid: { left: '3%', right: '4%', bottom: '3%', containLabel: true },
|
||||||
|
xAxis: {
|
||||||
|
type: 'value',
|
||||||
|
axisLabel: { color: '#ccc' },
|
||||||
|
splitLine: { lineStyle: { color: 'rgba(255,255,255,0.1)' } }
|
||||||
|
},
|
||||||
|
yAxis: {
|
||||||
|
type: 'category',
|
||||||
|
data: stats.value.topPosts?.map((i: any) => i.title.substring(0, 10) + '...') || [],
|
||||||
|
axisLabel: { color: '#ccc' }
|
||||||
|
},
|
||||||
|
series: [
|
||||||
|
{
|
||||||
|
name: '阅读量',
|
||||||
|
type: 'bar',
|
||||||
|
data: stats.value.topPosts?.map((i: any) => i.readCount) || [],
|
||||||
|
itemStyle: { color: '#d4b383' }
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}))
|
||||||
|
|
||||||
|
const loadData = async () => {
|
||||||
|
try {
|
||||||
|
stats.value = await getDashboardStats()
|
||||||
|
} catch (error) {
|
||||||
|
console.error(error)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
onMounted(() => {
|
||||||
|
loadData()
|
||||||
|
})
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.dashboard-overview {
|
||||||
|
width: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
.stat-card {
|
||||||
|
background: rgba(255, 255, 255, 0.05);
|
||||||
|
backdrop-filter: blur(10px);
|
||||||
|
border: 1px solid rgba(255, 255, 255, 0.1);
|
||||||
|
border-radius: 0.75rem;
|
||||||
|
padding: 1.5rem;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.stat-icon {
|
||||||
|
width: 3rem;
|
||||||
|
height: 3rem;
|
||||||
|
border-radius: 0.5rem;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
font-size: 1.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.stat-info h3 {
|
||||||
|
font-size: 0.875rem;
|
||||||
|
color: rgba(255, 255, 255, 0.6);
|
||||||
|
margin: 0 0 0.25rem 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.stat-info p {
|
||||||
|
font-size: 1.5rem;
|
||||||
|
font-weight: 600;
|
||||||
|
color: #fff;
|
||||||
|
margin: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.chart-card {
|
||||||
|
background: rgba(255, 255, 255, 0.05);
|
||||||
|
backdrop-filter: blur(10px);
|
||||||
|
border: 1px solid rgba(255, 255, 255, 0.1);
|
||||||
|
border-radius: 0.75rem;
|
||||||
|
padding: 1.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.chart-title {
|
||||||
|
font-size: 1.1rem;
|
||||||
|
color: #d4b383;
|
||||||
|
margin: 0 0 1rem 0;
|
||||||
|
font-weight: 500;
|
||||||
|
}
|
||||||
|
|
||||||
|
.chart {
|
||||||
|
height: 100%;
|
||||||
|
width: 100%;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@@ -21,7 +21,7 @@ const routes = [
|
|||||||
children: [
|
children: [
|
||||||
// 仪表盘
|
// 仪表盘
|
||||||
{ path: '', redirect: '/admin/dashboard' },
|
{ path: '', redirect: '/admin/dashboard' },
|
||||||
{ path: 'dashboard', name: 'admin-dashboard', component: () => import('./pages/admin/Dashboard.vue') },
|
{ path: 'dashboard', name: 'admin-dashboard', component: () => import('./pages/admin/dashboard/Overview.vue') },
|
||||||
|
|
||||||
// 用户管理
|
// 用户管理
|
||||||
{ path: 'users', name: 'admin-users', component: () => import('./pages/admin/Users.vue') },
|
{ path: 'users', name: 'admin-users', component: () => import('./pages/admin/Users.vue') },
|
||||||
|
|||||||
@@ -25,6 +25,7 @@ require (
|
|||||||
github.com/json-iterator/go v1.1.12 // indirect
|
github.com/json-iterator/go v1.1.12 // indirect
|
||||||
github.com/klauspost/cpuid/v2 v2.3.0 // indirect
|
github.com/klauspost/cpuid/v2 v2.3.0 // indirect
|
||||||
github.com/leodido/go-urn v1.4.0 // indirect
|
github.com/leodido/go-urn v1.4.0 // indirect
|
||||||
|
github.com/lionsoul2014/ip2region/binding/golang v0.0.0-20260109033043-398149f17e54 // indirect
|
||||||
github.com/mattn/go-isatty v0.0.20 // indirect
|
github.com/mattn/go-isatty v0.0.20 // indirect
|
||||||
github.com/modern-go/concurrent v0.0.0-20180228061459-e0a39a4cb421 // indirect
|
github.com/modern-go/concurrent v0.0.0-20180228061459-e0a39a4cb421 // indirect
|
||||||
github.com/modern-go/reflect2 v1.0.2 // indirect
|
github.com/modern-go/reflect2 v1.0.2 // indirect
|
||||||
|
|||||||
@@ -42,6 +42,8 @@ github.com/klauspost/cpuid/v2 v2.3.0 h1:S4CRMLnYUhGeDFDqkGriYKdfoFlDnMtqTiI/sFzh
|
|||||||
github.com/klauspost/cpuid/v2 v2.3.0/go.mod h1:hqwkgyIinND0mEev00jJYCxPNVRVXFQeu1XKlok6oO0=
|
github.com/klauspost/cpuid/v2 v2.3.0/go.mod h1:hqwkgyIinND0mEev00jJYCxPNVRVXFQeu1XKlok6oO0=
|
||||||
github.com/leodido/go-urn v1.4.0 h1:WT9HwE9SGECu3lg4d/dIA+jxlljEa1/ffXKmRjqdmIQ=
|
github.com/leodido/go-urn v1.4.0 h1:WT9HwE9SGECu3lg4d/dIA+jxlljEa1/ffXKmRjqdmIQ=
|
||||||
github.com/leodido/go-urn v1.4.0/go.mod h1:bvxc+MVxLKB4z00jd1z+Dvzr47oO32F/QSNjSBOlFxI=
|
github.com/leodido/go-urn v1.4.0/go.mod h1:bvxc+MVxLKB4z00jd1z+Dvzr47oO32F/QSNjSBOlFxI=
|
||||||
|
github.com/lionsoul2014/ip2region/binding/golang v0.0.0-20260109033043-398149f17e54 h1:R0OB+D7w26BOVPdoOva58AgTKmJR8tK2KI15XeeLdG8=
|
||||||
|
github.com/lionsoul2014/ip2region/binding/golang v0.0.0-20260109033043-398149f17e54/go.mod h1:+mNMTBuDMdEGhWzoQgc6kBdqeaQpWh5ba8zqmp2MxCU=
|
||||||
github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY=
|
github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY=
|
||||||
github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y=
|
github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y=
|
||||||
github.com/modern-go/concurrent v0.0.0-20180228061459-e0a39a4cb421 h1:ZqeYNhU3OHLH3mGKHDcjJRFFRrJa6eAM5H+CtDdOsPc=
|
github.com/modern-go/concurrent v0.0.0-20180228061459-e0a39a4cb421 h1:ZqeYNhU3OHLH3mGKHDcjJRFFRrJa6eAM5H+CtDdOsPc=
|
||||||
|
|||||||
@@ -4,43 +4,56 @@ import (
|
|||||||
"net/http"
|
"net/http"
|
||||||
|
|
||||||
"github.com/gin-gonic/gin"
|
"github.com/gin-gonic/gin"
|
||||||
|
"github.com/niangaodev/art-code/config"
|
||||||
"github.com/niangaodev/art-code/repositories"
|
"github.com/niangaodev/art-code/repositories"
|
||||||
)
|
)
|
||||||
|
|
||||||
// 仪表盘数据处理函数
|
// GetDashboardStats 获取仪表盘统计数据
|
||||||
func AdminGetDashboardStats(c *gin.Context) {
|
func GetDashboardStats(c *gin.Context) {
|
||||||
// 获取统计数据
|
// 1. 获取博文增长趋势 (最近7天新增)
|
||||||
userCount, err := repositories.GetUserCount()
|
postsTrend, err := repositories.GetNewPostsTrend()
|
||||||
if err != nil {
|
if err != nil {
|
||||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to get user count"})
|
postsTrend = []struct {
|
||||||
return
|
Date string
|
||||||
|
Count int
|
||||||
|
}{} // Fallback empty
|
||||||
}
|
}
|
||||||
|
|
||||||
postCount, err := repositories.GetPostCount()
|
// 2. 获取今日访客UV (根据IP)
|
||||||
|
// 这里可以复用GetDailyUV获取最近7天,取最后一天即为今日(或者根据前端需要展示7天趋势)
|
||||||
|
uvTrend, err := repositories.GetDailyUV()
|
||||||
if err != nil {
|
if err != nil {
|
||||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to get post count"})
|
uvTrend = []struct {
|
||||||
return
|
Date string
|
||||||
|
Count int
|
||||||
|
}{}
|
||||||
}
|
}
|
||||||
|
|
||||||
workCount, err := repositories.GetWorkCount()
|
// 3. 用户画像-地域分布
|
||||||
|
userRegions, err := repositories.GetUserRegions()
|
||||||
if err != nil {
|
if err != nil {
|
||||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to get work count"})
|
userRegions = []struct {
|
||||||
return
|
Region string
|
||||||
|
Count int
|
||||||
|
}{}
|
||||||
}
|
}
|
||||||
|
|
||||||
snippetCount, err := repositories.GetSnippetCount()
|
// 4. 热门文章 Top 5
|
||||||
|
// 需要在Post repository增加方法
|
||||||
|
topPosts, err := repositories.GetTopPosts(5)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to get snippet count"})
|
topPosts = nil // Fallback
|
||||||
return
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// 构建响应
|
// 5. 合作咨询总数
|
||||||
stats := gin.H{
|
var inquiryCount int
|
||||||
"users": userCount,
|
config.DB.QueryRow("SELECT COUNT(*) FROM inquiries").Scan(&inquiryCount)
|
||||||
"posts": postCount,
|
|
||||||
"works": workCount,
|
|
||||||
"snippets": snippetCount,
|
|
||||||
}
|
|
||||||
|
|
||||||
c.JSON(http.StatusOK, stats)
|
c.JSON(http.StatusOK, gin.H{
|
||||||
|
"postsTrend": postsTrend,
|
||||||
|
"uvTrend": uvTrend,
|
||||||
|
"userRegions": userRegions,
|
||||||
|
"topPosts": topPosts,
|
||||||
|
"inquiryCount": inquiryCount,
|
||||||
|
})
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -2,14 +2,12 @@ package main
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"log"
|
"log"
|
||||||
"net/http"
|
|
||||||
"os"
|
|
||||||
"strings"
|
|
||||||
|
|
||||||
"github.com/gin-gonic/gin"
|
"github.com/gin-gonic/gin"
|
||||||
"github.com/niangaodev/art-code/config"
|
"github.com/niangaodev/art-code/config"
|
||||||
"github.com/niangaodev/art-code/handlers"
|
"github.com/niangaodev/art-code/handlers"
|
||||||
"github.com/niangaodev/art-code/middleware"
|
"github.com/niangaodev/art-code/middleware"
|
||||||
|
"github.com/niangaodev/art-code/utils"
|
||||||
)
|
)
|
||||||
|
|
||||||
func main() {
|
func main() {
|
||||||
@@ -17,40 +15,17 @@ func main() {
|
|||||||
config.InitDB()
|
config.InitDB()
|
||||||
defer config.CloseDB()
|
defer config.CloseDB()
|
||||||
|
|
||||||
|
// 初始化ip2region (如果文件不存在,将降级为普通IP记录)
|
||||||
|
// 请确保在server根目录或合适位置放入 ip2region.xdb
|
||||||
|
utils.InitIP2Region("ip2region.xdb")
|
||||||
|
|
||||||
// 创建Gin引擎
|
// 创建Gin引擎
|
||||||
router := gin.Default()
|
router := gin.New()
|
||||||
|
|
||||||
// 配置CORS
|
// 配置中间件
|
||||||
router.Use(func(c *gin.Context) {
|
router.Use(middleware.CorsMiddleware())
|
||||||
// 获取允许的域名,优先从环境变量获取,否则使用默认值
|
router.Use(middleware.AccessLogMiddleware()) // 添加访问日志中间件
|
||||||
allowedOrigins := os.Getenv("ALLOWED_ORIGINS")
|
router.Use(gin.Recovery())
|
||||||
if allowedOrigins == "" {
|
|
||||||
allowedOrigins = "http://localhost:5173,http://localhost:3000" // 开发环境默认值
|
|
||||||
}
|
|
||||||
|
|
||||||
// 检查请求来源是否在允许列表中
|
|
||||||
requestOrigin := c.Request.Header.Get("Origin")
|
|
||||||
if requestOrigin != "" {
|
|
||||||
// 简单的CORS origin检查
|
|
||||||
for _, origin := range strings.Split(allowedOrigins, ",") {
|
|
||||||
if strings.TrimSpace(origin) == requestOrigin {
|
|
||||||
c.Writer.Header().Set("Access-Control-Allow-Origin", requestOrigin)
|
|
||||||
break
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
c.Writer.Header().Set("Access-Control-Allow-Methods", "GET, POST, PUT, DELETE, OPTIONS")
|
|
||||||
c.Writer.Header().Set("Access-Control-Allow-Headers", "Origin, Content-Type, Content-Length, Accept-Encoding, X-CSRF-Token, Authorization")
|
|
||||||
c.Writer.Header().Set("Access-Control-Allow-Credentials", "true")
|
|
||||||
|
|
||||||
if c.Request.Method == "OPTIONS" {
|
|
||||||
c.AbortWithStatus(http.StatusNoContent)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
c.Next()
|
|
||||||
})
|
|
||||||
|
|
||||||
// API路由组
|
// API路由组
|
||||||
api := router.Group("/api")
|
api := router.Group("/api")
|
||||||
@@ -132,9 +107,12 @@ func main() {
|
|||||||
authAdmin.PUT("/settings", middleware.PermissionMiddleware("settings", "update"), handlers.AdminUpdateSetting)
|
authAdmin.PUT("/settings", middleware.PermissionMiddleware("settings", "update"), handlers.AdminUpdateSetting)
|
||||||
authAdmin.DELETE("/settings/:key", middleware.PermissionMiddleware("settings", "delete"), handlers.AdminDeleteSetting)
|
authAdmin.DELETE("/settings/:key", middleware.PermissionMiddleware("settings", "delete"), handlers.AdminDeleteSetting)
|
||||||
|
|
||||||
|
// 仪表盘统计
|
||||||
|
authAdmin.GET("/dashboard/stats", middleware.PermissionMiddleware("dashboard", "read"), handlers.GetDashboardStats)
|
||||||
|
|
||||||
// 文章管理
|
// 文章管理
|
||||||
authAdmin.GET("/posts", middleware.PermissionMiddleware("posts", "read"), handlers.AdminGetPosts)
|
authAdmin.GET("/posts", middleware.PermissionMiddleware("posts", "read"), handlers.AdminGetPosts)
|
||||||
authAdmin.POST("/posts", middleware.PermissionMiddleware("posts", "create"), handlers.AdminCreatePost)
|
authAdmin.POST("/posts", middleware.PermissionMiddleware("posts", "create"), handlers.CreatePost)
|
||||||
authAdmin.PUT("/posts/:id", middleware.PermissionMiddleware("posts", "update"), handlers.AdminUpdatePost)
|
authAdmin.PUT("/posts/:id", middleware.PermissionMiddleware("posts", "update"), handlers.AdminUpdatePost)
|
||||||
authAdmin.DELETE("/posts/:id", middleware.PermissionMiddleware("posts", "delete"), handlers.AdminDeletePost)
|
authAdmin.DELETE("/posts/:id", middleware.PermissionMiddleware("posts", "delete"), handlers.AdminDeletePost)
|
||||||
|
|
||||||
|
|||||||
44
server/middleware/access_log.go
Normal file
44
server/middleware/access_log.go
Normal file
@@ -0,0 +1,44 @@
|
|||||||
|
package middleware
|
||||||
|
|
||||||
|
import (
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/gin-gonic/gin"
|
||||||
|
"github.com/niangaodev/art-code/models"
|
||||||
|
"github.com/niangaodev/art-code/repositories"
|
||||||
|
"github.com/niangaodev/art-code/utils"
|
||||||
|
)
|
||||||
|
|
||||||
|
// AccessLogMiddleware 访问日志中间件 (用于流量统计)
|
||||||
|
func AccessLogMiddleware() gin.HandlerFunc {
|
||||||
|
return func(c *gin.Context) {
|
||||||
|
start := time.Now()
|
||||||
|
|
||||||
|
c.Next()
|
||||||
|
|
||||||
|
// 过滤静态资源和API调用
|
||||||
|
// 这里只记录主要页面访问,或者我们可以记录所有非静态资源
|
||||||
|
// 简化起见,记录所有请求,分析时再过滤
|
||||||
|
|
||||||
|
// 获取IP
|
||||||
|
ip := c.ClientIP()
|
||||||
|
|
||||||
|
// 获取归属地 (需要先初始化ip2region)
|
||||||
|
region := utils.GetRegion(ip)
|
||||||
|
|
||||||
|
log := &models.AccessLog{
|
||||||
|
IP: ip,
|
||||||
|
UserAgent: c.Request.UserAgent(),
|
||||||
|
Path: c.Request.URL.Path,
|
||||||
|
Method: c.Request.Method,
|
||||||
|
StatusCode: c.Writer.Status(),
|
||||||
|
ResponseTime: time.Since(start).Milliseconds(),
|
||||||
|
Region: region,
|
||||||
|
}
|
||||||
|
|
||||||
|
// 异步入库
|
||||||
|
go func() {
|
||||||
|
repositories.CreateAccessLog(log)
|
||||||
|
}()
|
||||||
|
}
|
||||||
|
}
|
||||||
16
server/models/access_log.go
Normal file
16
server/models/access_log.go
Normal file
@@ -0,0 +1,16 @@
|
|||||||
|
package models
|
||||||
|
|
||||||
|
import "time"
|
||||||
|
|
||||||
|
// AccessLog 访问日志 (用于统计流量)
|
||||||
|
type AccessLog struct {
|
||||||
|
ID uint `json:"id" gorm:"primaryKey"`
|
||||||
|
IP string `json:"ip"`
|
||||||
|
UserAgent string `json:"user_agent"`
|
||||||
|
Path string `json:"path"`
|
||||||
|
Method string `json:"method"`
|
||||||
|
StatusCode int `json:"status_code"`
|
||||||
|
ResponseTime int64 `json:"response_time"` // 毫秒
|
||||||
|
Region string `json:"region"` // IP归属地
|
||||||
|
CreatedAt time.Time `json:"created_at"`
|
||||||
|
}
|
||||||
91
server/repositories/log_repository.go
Normal file
91
server/repositories/log_repository.go
Normal file
@@ -0,0 +1,91 @@
|
|||||||
|
package repositories
|
||||||
|
|
||||||
|
import (
|
||||||
|
"github.com/niangaodev/art-code/config"
|
||||||
|
"github.com/niangaodev/art-code/models"
|
||||||
|
)
|
||||||
|
|
||||||
|
// CreateAccessLog 创建访问日志
|
||||||
|
func CreateAccessLog(log *models.AccessLog) error {
|
||||||
|
query := `INSERT INTO access_logs (ip, user_agent, path, method, status_code, response_time, region) VALUES (?, ?, ?, ?, ?, ?, ?)`
|
||||||
|
_, err := config.DB.Exec(query, log.IP, log.UserAgent, log.Path, log.Method, log.StatusCode, log.ResponseTime, log.Region)
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetDailyUV 获取最近7天UV
|
||||||
|
func GetDailyUV() ([]struct {
|
||||||
|
Date string
|
||||||
|
Count int
|
||||||
|
}, error) {
|
||||||
|
query := `
|
||||||
|
SELECT DATE_FORMAT(created_at, '%Y-%m-%d') as date, COUNT(DISTINCT ip) as count
|
||||||
|
FROM access_logs
|
||||||
|
WHERE created_at >= DATE_SUB(CURDATE(), INTERVAL 6 DAY)
|
||||||
|
GROUP BY date
|
||||||
|
ORDER BY date ASC
|
||||||
|
`
|
||||||
|
rows, err := config.DB.Query(query)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
defer rows.Close()
|
||||||
|
|
||||||
|
var results []struct {
|
||||||
|
Date string
|
||||||
|
Count int
|
||||||
|
}
|
||||||
|
for rows.Next() {
|
||||||
|
var r struct {
|
||||||
|
Date string
|
||||||
|
Count int
|
||||||
|
}
|
||||||
|
if err := rows.Scan(&r.Date, &r.Count); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
results = append(results, r)
|
||||||
|
}
|
||||||
|
return results, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetUserRegions 获取用户地域分布
|
||||||
|
func GetUserRegions() ([]struct {
|
||||||
|
Region string
|
||||||
|
Count int
|
||||||
|
}, error) {
|
||||||
|
// 简单的截取省份逻辑,实际可能需要更复杂的解析,这里假设region格式为 国家|区域|省份|城市|ISP
|
||||||
|
// 我们可以取省份 (第3部分)
|
||||||
|
query := `
|
||||||
|
SELECT
|
||||||
|
SUBSTRING_INDEX(SUBSTRING_INDEX(region, '|', 3), '|', -1) as province,
|
||||||
|
COUNT(DISTINCT ip) as count
|
||||||
|
FROM access_logs
|
||||||
|
WHERE region != 'Internal' AND region != 'Unknown' AND region IS NOT NULL
|
||||||
|
GROUP BY province
|
||||||
|
ORDER BY count DESC
|
||||||
|
LIMIT 20
|
||||||
|
`
|
||||||
|
rows, err := config.DB.Query(query)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
defer rows.Close()
|
||||||
|
|
||||||
|
var results []struct {
|
||||||
|
Region string
|
||||||
|
Count int
|
||||||
|
}
|
||||||
|
for rows.Next() {
|
||||||
|
var r struct {
|
||||||
|
Region string
|
||||||
|
Count int
|
||||||
|
}
|
||||||
|
if err := rows.Scan(&r.Region, &r.Count); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
// 过滤掉无效数据
|
||||||
|
if r.Region != "" && r.Region != "0" {
|
||||||
|
results = append(results, r)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return results, nil
|
||||||
|
}
|
||||||
@@ -375,3 +375,75 @@ func BuildPostHistoryResponses(history []models.PostHistory) []models.PostHistor
|
|||||||
}
|
}
|
||||||
return responses
|
return responses
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// GetNewPostsTrend 获取最近7天新增文章趋势
|
||||||
|
func GetNewPostsTrend() ([]struct {
|
||||||
|
Date string
|
||||||
|
Count int
|
||||||
|
}, error) {
|
||||||
|
query := `
|
||||||
|
SELECT DATE_FORMAT(created_at, '%Y-%m-%d') as date, COUNT(*) as count
|
||||||
|
FROM posts
|
||||||
|
WHERE created_at >= DATE_SUB(CURDATE(), INTERVAL 6 DAY)
|
||||||
|
GROUP BY date
|
||||||
|
ORDER BY date ASC
|
||||||
|
`
|
||||||
|
rows, err := config.DB.Query(query)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
defer rows.Close()
|
||||||
|
|
||||||
|
var results []struct {
|
||||||
|
Date string
|
||||||
|
Count int
|
||||||
|
}
|
||||||
|
for rows.Next() {
|
||||||
|
var r struct {
|
||||||
|
Date string
|
||||||
|
Count int
|
||||||
|
}
|
||||||
|
if err := rows.Scan(&r.Date, &r.Count); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
results = append(results, r)
|
||||||
|
}
|
||||||
|
return results, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetTopPosts 获取热门文章 (按阅读量)
|
||||||
|
func GetTopPosts(limit int) ([]models.Post, error) {
|
||||||
|
query := `
|
||||||
|
SELECT id, title, category, date, excerpt, content, read_count, is_published, created_at, updated_at
|
||||||
|
FROM posts
|
||||||
|
WHERE is_published = 1
|
||||||
|
ORDER BY read_count DESC
|
||||||
|
LIMIT ?
|
||||||
|
`
|
||||||
|
rows, err := config.DB.Query(query, limit)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
defer rows.Close()
|
||||||
|
|
||||||
|
var posts []models.Post
|
||||||
|
for rows.Next() {
|
||||||
|
var post models.Post
|
||||||
|
if err := rows.Scan(
|
||||||
|
&post.ID,
|
||||||
|
&post.Title,
|
||||||
|
&post.Category,
|
||||||
|
&post.Date,
|
||||||
|
&post.Excerpt,
|
||||||
|
&post.Content,
|
||||||
|
&post.ReadCount,
|
||||||
|
&post.IsPublished,
|
||||||
|
&post.CreatedAt,
|
||||||
|
&post.UpdatedAt,
|
||||||
|
); err != nil {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
posts = append(posts, post)
|
||||||
|
}
|
||||||
|
return posts, nil
|
||||||
|
}
|
||||||
|
|||||||
77
server/utils/ip.go
Normal file
77
server/utils/ip.go
Normal file
@@ -0,0 +1,77 @@
|
|||||||
|
package utils
|
||||||
|
|
||||||
|
import (
|
||||||
|
"fmt"
|
||||||
|
"log"
|
||||||
|
"net"
|
||||||
|
"strings"
|
||||||
|
"sync"
|
||||||
|
|
||||||
|
"github.com/lionsoul2014/ip2region/binding/golang/xdb"
|
||||||
|
)
|
||||||
|
|
||||||
|
var (
|
||||||
|
searcher *xdb.Searcher
|
||||||
|
once sync.Once
|
||||||
|
)
|
||||||
|
|
||||||
|
// InitIP2Region 初始化 ip2region
|
||||||
|
// 需要 ip2region.xdb 文件,如果不存在则仅支持基本IP解析
|
||||||
|
func InitIP2Region(dbPath string) {
|
||||||
|
once.Do(func() {
|
||||||
|
var err error
|
||||||
|
// 1. 尝试加载整个xdb到内存,性能最好
|
||||||
|
cBuff, err := xdb.LoadContentFromFile(dbPath)
|
||||||
|
if err != nil {
|
||||||
|
log.Printf("Failed to load ip2region.xdb: %v. Region lookup will be disabled.", err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
searcher, err = xdb.NewWithBuffer(cBuff)
|
||||||
|
if err != nil {
|
||||||
|
log.Printf("Failed to create searcher: %v", err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
log.Println("IP2Region loaded successfully")
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetRegion 获取IP归属地
|
||||||
|
// 返回格式: 国家|区域|省份|城市|ISP
|
||||||
|
func GetRegion(ip string) string {
|
||||||
|
if searcher == nil {
|
||||||
|
return "Unknown"
|
||||||
|
}
|
||||||
|
|
||||||
|
// 过滤内网IP
|
||||||
|
if isPrivateIP(ip) {
|
||||||
|
return "Internal"
|
||||||
|
}
|
||||||
|
|
||||||
|
region, err := searcher.SearchByStr(ip)
|
||||||
|
if err != nil {
|
||||||
|
return "Unknown"
|
||||||
|
}
|
||||||
|
return region
|
||||||
|
}
|
||||||
|
|
||||||
|
// 简单判断内网IP
|
||||||
|
func isPrivateIP(ipStr string) bool {
|
||||||
|
ip := net.ParseIP(ipStr)
|
||||||
|
if ip == nil {
|
||||||
|
return false // 不是有效IP
|
||||||
|
}
|
||||||
|
|
||||||
|
if ip.IsLoopback() {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
ip4 := ip.To4()
|
||||||
|
if ip4 == nil {
|
||||||
|
return false // 暂不处理IPv6内网判断
|
||||||
|
}
|
||||||
|
|
||||||
|
return ip4[0] == 10 ||
|
||||||
|
(ip4[0] == 172 && ip4[1] >= 16 && ip4[1] <= 31) ||
|
||||||
|
(ip4[0] == 192 && ip4[1] == 168)
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user