feat(analytics): 添加访问统计功能

实现页面访问统计功能,包括:
1. 前端组件挂载时上报访问数据
2. 后端接口记录访问信息并存储
3. 管理后台新增访问统计视图
4. 相关API文档和设计说明更新
This commit is contained in:
anghunk
2026-01-21 21:56:57 +08:00
parent aabf370f65
commit d5a7a15344
12 changed files with 1241 additions and 201 deletions

View File

@@ -83,6 +83,23 @@ export type CommentStatsResponse = {
}[];
};
export type VisitOverviewResponse = {
totalPv: number;
totalPages: number;
};
export type VisitPageItem = {
postSlug: string;
postTitle: string | null;
postUrl: string | null;
pv: number;
lastVisitAt: string | null;
};
export type VisitPagesResponse = {
items: VisitPageItem[];
};
export async function loginAdmin(name: string, password: string): Promise<string> {
const res = await post<AdminLoginResponse>('/admin/login', { name, password });
const key = res.data.key;
@@ -212,3 +229,11 @@ export function importComments(data: any[]): Promise<{ message: string }> {
export function fetchCommentStats(): Promise<CommentStatsResponse> {
return get<CommentStatsResponse>('/admin/stats/comments');
}
export function fetchVisitOverview(): Promise<VisitOverviewResponse> {
return get<VisitOverviewResponse>('/admin/analytics/overview');
}
export function fetchVisitPages(): Promise<VisitPagesResponse> {
return get<VisitPagesResponse>('/admin/analytics/pages');
}

View File

@@ -5,6 +5,7 @@ import CommentsView from '../views/CommentsView.vue';
import SettingsView from '../views/SettingsView.vue';
import DataView from '../views/DataView.vue';
import StatsView from '../views/StatsView.vue';
import AnalyticsVisitView from '../views/AnalyticsVisitView.vue';
const routes: RouteRecordRaw[] = [
{
@@ -30,6 +31,11 @@ const routes: RouteRecordRaw[] = [
name: 'stats',
component: StatsView
},
{
path: 'analytics',
name: 'analytics',
component: AnalyticsVisitView
},
{
path: 'settings',
name: 'settings',

View File

@@ -0,0 +1,266 @@
<template>
<div class="page">
<h2 class="page-title">访问统计</h2>
<div
v-if="toastVisible"
class="toast"
:class="toastType === 'error' ? 'toast-error' : 'toast-success'"
>
{{ toastMessage }}
</div>
<div class="card">
<h3 class="card-title">整体概览</h3>
<div v-if="loading" class="page-hint">加载中...</div>
<div v-else-if="error" class="page-error">{{ error }}</div>
<div v-else>
<div class="stats-grid">
<div class="stats-item">
<div class="stats-label">全站总访问量</div>
<div class="stats-value">{{ overview.totalPv }}</div>
</div>
<div class="stats-item">
<div class="stats-label">有访问记录的页面数</div>
<div class="stats-value">{{ overview.totalPages }}</div>
</div>
</div>
</div>
</div>
<div class="card">
<h3 class="card-title">页面访问明细 PV 排序</h3>
<div v-if="loading" class="page-hint">加载中...</div>
<div v-else-if="error" class="page-error">{{ error }}</div>
<div v-else-if="items.length === 0" class="page-hint">暂无访问数据</div>
<div v-else class="domain-table">
<div class="domain-table-header">
<div class="domain-cell domain-cell-domain">页面标题</div>
<div class="domain-cell">访问量</div>
<div class="domain-cell">最后访问时间</div>
<div class="domain-cell">页面地址</div>
</div>
<div
v-for="item in items"
:key="item.postSlug"
class="domain-table-row"
>
<div class="domain-cell domain-cell-domain">
{{ item.postTitle || item.postSlug }}
</div>
<div class="domain-cell">
{{ item.pv }}
</div>
<div class="domain-cell">
{{ formatTime(item.lastVisitAt) }}
</div>
<div class="domain-cell">
<a
v-if="item.postUrl"
:href="item.postUrl"
target="_blank"
rel="noreferrer"
>
{{ item.postUrl }}
</a>
<span v-else>-</span>
</div>
</div>
</div>
</div>
</div>
</template>
<script setup lang="ts">
import { onMounted, ref } from "vue";
import {
fetchVisitOverview,
fetchVisitPages,
type VisitOverviewResponse,
type VisitPageItem,
} from "../api/admin";
const loading = ref(false);
const error = ref("");
const overview = ref<VisitOverviewResponse>({
totalPv: 0,
totalPages: 0,
});
const items = ref<VisitPageItem[]>([]);
const toastMessage = ref("");
const toastType = ref<"success" | "error">("success");
const toastVisible = ref(false);
function showToast(msg: string, type: "success" | "error" = "success") {
toastMessage.value = msg;
toastType.value = type;
toastVisible.value = true;
window.setTimeout(() => {
toastVisible.value = false;
}, 2000);
}
function formatTime(value: string | null): string {
if (!value) {
return "-";
}
const date = new Date(value);
if (Number.isNaN(date.getTime())) {
return value;
}
const y = date.getFullYear();
const m = String(date.getMonth() + 1).padStart(2, "0");
const d = String(date.getDate()).padStart(2, "0");
const h = String(date.getHours()).padStart(2, "0");
const mm = String(date.getMinutes()).padStart(2, "0");
return `${y}-${m}-${d} ${h}:${mm}`;
}
async function loadData() {
loading.value = true;
error.value = "";
try {
const [overviewRes, pagesRes] = await Promise.all([
fetchVisitOverview(),
fetchVisitPages(),
]);
overview.value = overviewRes;
items.value = pagesRes.items || [];
} catch (e: any) {
const msg = e.message || "加载访问统计数据失败";
error.value = msg;
showToast(msg, "error");
} finally {
loading.value = false;
}
}
onMounted(() => {
loadData();
});
</script>
<style scoped>
.page {
display: flex;
flex-direction: column;
gap: 12px;
}
.page-title {
margin: 0;
font-size: 18px;
color: #24292f;
}
.card {
background-color: #ffffff;
border-radius: 6px;
border: 1px solid #d0d7de;
padding: 16px 18px;
}
.card-title {
margin: 0 0 12px;
font-size: 16px;
}
.page-hint {
font-size: 14px;
color: #57606a;
}
.page-error {
font-size: 14px;
color: #d1242f;
}
.stats-grid {
display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr));
gap: 12px;
}
.stats-item {
padding: 10px 12px;
border-radius: 6px;
background-color: #f6f8fa;
border: 1px solid #d0d7de;
}
.stats-label {
font-size: 14px;
color: #57606a;
margin-bottom: 4px;
}
.stats-value {
font-size: 26px;
font-weight: 600;
color: #24292f;
}
.domain-table {
border: 1px solid #d0d7de;
border-radius: 6px;
overflow: hidden;
}
.domain-table-header {
display: flex;
background-color: #f6f8fa;
}
.domain-table-row {
display: flex;
border-top: 1px solid #eaeae0;
}
.domain-cell {
flex: 1;
padding: 10px 10px;
font-size: 14px;
color: #24292f;
box-sizing: border-box;
}
.domain-cell a {
color: #57606a;
}
.domain-cell-domain {
flex: 2;
font-weight: 500;
}
.toast {
position: fixed;
top: 20px;
left: 50%;
transform: translateX(-50%);
min-width: 220px;
max-width: 320px;
padding: 10px 14px;
border-radius: 6px;
font-size: 13px;
box-shadow: 0 8px 24px rgba(140, 149, 159, 0.2);
z-index: 1000;
}
.toast-success {
background-color: #1a7f37;
color: #ffffff;
}
.toast-error {
background-color: #d1242f;
color: #ffffff;
}
@media (max-width: 768px) {
.stats-grid {
grid-template-columns: repeat(1, minmax(0, 1fr));
}
}
</style>

View File

@@ -75,6 +75,13 @@
>
数据看板
</li>
<li
class="menu-item"
:class="{ active: isRouteActive('analytics') }"
@click="goAnalytics"
>
访问统计
</li>
<li
class="menu-item"
:class="{ active: isRouteActive('settings') }"
@@ -140,6 +147,11 @@ function goStats() {
closeSider();
}
function goAnalytics() {
router.push({ name: "analytics" });
closeSider();
}
function goData() {
router.push({ name: "data" });
closeSider();

View File

@@ -169,6 +169,10 @@ export class CWDComments {
this._render();
this.store.loadComments();
if (this.api && typeof this.api.trackVisit === 'function') {
this.api.trackVisit();
}
})();
this._mounted = true;

View File

@@ -97,9 +97,27 @@
return response.json();
}
async function trackVisit() {
try {
await fetch(`${baseUrl}/api/analytics/visit`, {
method: 'POST',
headers: {
'Content-Type': 'application/json'
},
body: JSON.stringify({
postSlug: config.postSlug,
postTitle: config.postTitle,
postUrl: config.postUrl
})
});
} catch (e) {
}
}
return {
fetchComments,
submitComment,
verifyAdminKey
verifyAdminKey,
trackVisit
};
}