feat(analytics): 添加今日/本周/本月访问量统计功能
- 在访问统计概览中新增今日、本周、本月访问量数据展示 - 更新API文档和前端界面以支持新的统计维度 - 优化图表显示样式,减小数据点大小 - 调整统计网格布局适配移动端
This commit is contained in:
@@ -86,6 +86,9 @@ export type CommentStatsResponse = {
|
||||
export type VisitOverviewResponse = {
|
||||
totalPv: number;
|
||||
totalPages: number;
|
||||
todayPv: number;
|
||||
weekPv: number;
|
||||
monthPv: number;
|
||||
last30Days?: {
|
||||
date: string;
|
||||
total: number;
|
||||
|
||||
@@ -31,6 +31,18 @@
|
||||
<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.todayPv }}</div>
|
||||
</div>
|
||||
<div class="stats-item">
|
||||
<div class="stats-label">本周访问量</div>
|
||||
<div class="stats-value">{{ overview.weekPv }}</div>
|
||||
</div>
|
||||
<div class="stats-item">
|
||||
<div class="stats-label">本月访问量</div>
|
||||
<div class="stats-value">{{ overview.monthPv }}</div>
|
||||
</div>
|
||||
<div class="stats-item">
|
||||
<div class="stats-label">有访问记录的页面数</div>
|
||||
<div class="stats-value">{{ overview.totalPages }}</div>
|
||||
@@ -127,6 +139,9 @@ const error = ref("");
|
||||
const overview = ref<VisitOverviewResponse>({
|
||||
totalPv: 0,
|
||||
totalPages: 0,
|
||||
todayPv: 0,
|
||||
weekPv: 0,
|
||||
monthPv: 0,
|
||||
last30Days: [],
|
||||
});
|
||||
const items = ref<VisitPageItem[]>([]);
|
||||
@@ -226,6 +241,9 @@ async function loadData() {
|
||||
overview.value = {
|
||||
totalPv: overviewRes.totalPv,
|
||||
totalPages: overviewRes.totalPages,
|
||||
todayPv: overviewRes.todayPv ?? 0,
|
||||
weekPv: overviewRes.weekPv ?? 0,
|
||||
monthPv: overviewRes.monthPv ?? 0,
|
||||
last30Days: Array.isArray(overviewRes.last30Days)
|
||||
? overviewRes.last30Days
|
||||
: [],
|
||||
@@ -337,7 +355,7 @@ function renderChart() {
|
||||
color: "#0ea5e9",
|
||||
},
|
||||
symbol: "circle",
|
||||
symbolSize: 6,
|
||||
symbolSize: 3,
|
||||
},
|
||||
],
|
||||
};
|
||||
@@ -441,7 +459,7 @@ watch(
|
||||
|
||||
.stats-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||
grid-template-columns: repeat(5, minmax(0, 1fr));
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
@@ -594,4 +612,10 @@ watch(
|
||||
background-color: #0969da;
|
||||
color: #ffffff;
|
||||
}
|
||||
|
||||
@media (max-width: 768px) {
|
||||
.stats-grid {
|
||||
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||
}
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -224,7 +224,7 @@ function renderChart() {
|
||||
color: "#0ea5e9",
|
||||
},
|
||||
symbol: "circle",
|
||||
symbolSize: 6,
|
||||
symbolSize: 3,
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
@@ -4,6 +4,9 @@ import type { Bindings } from '../../bindings';
|
||||
type VisitOverview = {
|
||||
totalPv: number;
|
||||
totalPages: number;
|
||||
todayPv: number;
|
||||
weekPv: number;
|
||||
monthPv: number;
|
||||
last30Days: {
|
||||
date: string;
|
||||
total: number;
|
||||
@@ -81,11 +84,31 @@ export const getVisitOverview = async (
|
||||
|
||||
const now = new Date();
|
||||
const thirtyDaysAgo = new Date(now.getTime() - 29 * 24 * 60 * 60 * 1000);
|
||||
const startDate = thirtyDaysAgo.toISOString().slice(0, 10);
|
||||
|
||||
const year = now.getUTCFullYear();
|
||||
const month = now.getUTCMonth();
|
||||
const day = now.getUTCDate();
|
||||
|
||||
const toKey = (d: Date) => {
|
||||
const y = d.getUTCFullYear();
|
||||
const m = String(d.getUTCMonth() + 1).padStart(2, '0');
|
||||
const dd = String(d.getUTCDate()).padStart(2, '0');
|
||||
return `${y}-${m}-${dd}`;
|
||||
};
|
||||
|
||||
const startDate30 = toKey(thirtyDaysAgo);
|
||||
|
||||
const monthStartDate = new Date(Date.UTC(year, month, 1));
|
||||
const monthStartKey = toKey(monthStartDate);
|
||||
|
||||
let earliestDate = startDate30;
|
||||
if (monthStartKey < earliestDate) {
|
||||
earliestDate = monthStartKey;
|
||||
}
|
||||
|
||||
let dailySql =
|
||||
'SELECT date, domain, count FROM page_visit_daily WHERE date >= ?';
|
||||
const params: string[] = [startDate];
|
||||
const params: string[] = [earliestDate];
|
||||
|
||||
if (domainFilter) {
|
||||
dailySql += ' AND domain = ?';
|
||||
@@ -116,13 +139,52 @@ export const getVisitOverview = async (
|
||||
dailyMap.set(fallbackDate, totalPv);
|
||||
}
|
||||
|
||||
const todayKey = toKey(now);
|
||||
|
||||
const weekStartDate = (() => {
|
||||
const d = new Date(Date.UTC(year, month, day));
|
||||
const weekday = d.getUTCDay();
|
||||
const offset = (weekday + 6) % 7;
|
||||
return new Date(d.getTime() - offset * 24 * 60 * 60 * 1000);
|
||||
})();
|
||||
const weekStartKey = toKey(weekStartDate);
|
||||
|
||||
let todayPv = dailyMap.get(todayKey) || 0;
|
||||
let weekPv = 0;
|
||||
let monthPv = 0;
|
||||
|
||||
{
|
||||
let cursor = new Date(weekStartDate.getTime());
|
||||
while (cursor.getTime() <= now.getTime()) {
|
||||
const key = toKey(cursor);
|
||||
weekPv += dailyMap.get(key) || 0;
|
||||
cursor = new Date(cursor.getTime() - 0 + 24 * 60 * 60 * 1000);
|
||||
}
|
||||
}
|
||||
|
||||
{
|
||||
let cursor = new Date(monthStartDate.getTime());
|
||||
while (cursor.getTime() <= now.getTime()) {
|
||||
const key = toKey(cursor);
|
||||
monthPv += dailyMap.get(key) || 0;
|
||||
cursor = new Date(cursor.getTime() + 24 * 60 * 60 * 1000);
|
||||
}
|
||||
}
|
||||
|
||||
if (todayPv > totalPv) {
|
||||
todayPv = totalPv;
|
||||
}
|
||||
if (weekPv > totalPv) {
|
||||
weekPv = totalPv;
|
||||
}
|
||||
if (monthPv > totalPv) {
|
||||
monthPv = totalPv;
|
||||
}
|
||||
|
||||
const last30Days: { date: string; total: number }[] = [];
|
||||
for (let i = 29; i >= 0; i--) {
|
||||
const d = new Date(now.getTime() - i * 24 * 60 * 60 * 1000);
|
||||
const year = d.getUTCFullYear();
|
||||
const month = String(d.getUTCMonth() + 1).padStart(2, '0');
|
||||
const day = String(d.getUTCDate()).padStart(2, '0');
|
||||
const key = `${year}-${month}-${day}`;
|
||||
const key = toKey(d);
|
||||
last30Days.push({
|
||||
date: key,
|
||||
total: dailyMap.get(key) || 0
|
||||
@@ -132,6 +194,9 @@ export const getVisitOverview = async (
|
||||
const data: VisitOverview = {
|
||||
totalPv,
|
||||
totalPages,
|
||||
todayPv,
|
||||
weekPv,
|
||||
monthPv,
|
||||
last30Days
|
||||
};
|
||||
|
||||
|
||||
@@ -1160,3 +1160,134 @@ GET /admin/stats/comments
|
||||
"message": "获取统计数据失败"
|
||||
}
|
||||
```
|
||||
|
||||
## 7. 访问统计相关
|
||||
|
||||
### 7.1 获取访问统计概览
|
||||
|
||||
```
|
||||
GET /admin/analytics/overview
|
||||
```
|
||||
|
||||
用于管理后台「访问统计」页面展示整体访问数据,包括总 PV、总页面数以及最近 30 天的访问趋势。
|
||||
|
||||
- 方法:`GET`
|
||||
- 路径:`/admin/analytics/overview`
|
||||
- 鉴权:需要(Bearer Token)
|
||||
|
||||
**查询参数**
|
||||
|
||||
| 名称 | 位置 | 类型 | 必填 | 说明 |
|
||||
| -------- | ----- | ------ | ---- | ------------------------------------------ |
|
||||
| `domain` | query | string | 否 | 按域名筛选访问数据,传入域名,如 `example.com` |
|
||||
|
||||
**成功响应**
|
||||
|
||||
- 状态码:`200`
|
||||
|
||||
```json
|
||||
{
|
||||
"totalPv": 1000,
|
||||
"totalPages": 50,
|
||||
"last30Days": [
|
||||
{
|
||||
"date": "2026-01-15",
|
||||
"total": 20
|
||||
},
|
||||
{
|
||||
"date": "2026-01-16",
|
||||
"total": 25
|
||||
}
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
字段说明:
|
||||
|
||||
| 字段名 | 类型 | 说明 |
|
||||
| -------------- | ------------------- | ------------------------------------- |
|
||||
| `totalPv` | number | 总访问量(PV) |
|
||||
| `totalPages` | number | 总页面数 |
|
||||
| `last30Days` | Array\<DailyStat\> | 最近 30 天的每日访问数(按自然日聚合) |
|
||||
| `last30Days[].date` | string (YYYY-MM-DD) | 日期,UTC 时间格式化后的自然日 |
|
||||
| `last30Days[].total` | number | 当日访问总数 |
|
||||
|
||||
**错误响应**
|
||||
|
||||
- 状态码:`500`
|
||||
|
||||
```json
|
||||
{
|
||||
"message": "获取访问统计概览失败"
|
||||
}
|
||||
```
|
||||
|
||||
### 7.2 获取页面访问统计
|
||||
|
||||
```
|
||||
GET /admin/analytics/pages
|
||||
```
|
||||
|
||||
用于管理后台「访问统计」页面展示各个页面的访问明细,支持按 PV 排序或最新访问排序。
|
||||
|
||||
- 方法:`GET`
|
||||
- 路径:`/admin/analytics/pages`
|
||||
- 鉴权:需要(Bearer Token)
|
||||
|
||||
**查询参数**
|
||||
|
||||
| 名称 | 位置 | 类型 | 必填 | 说明 |
|
||||
| -------- | ----- | ------ | ---- | ------------------------------------------ |
|
||||
| `domain` | query | string | 否 | 按域名筛选访问数据,传入域名,如 `example.com` |
|
||||
| `order` | query | string | 否 | 排序方式,`pv`(按访问量排序,默认)或 `latest`(最新访问) |
|
||||
|
||||
说明:
|
||||
|
||||
- 当前实现中固定返回前 20 条数据
|
||||
- `order=pv`:按访问量降序排序,访问量相同时按最后访问时间降序排序
|
||||
- `order=latest`:按最后访问时间降序排序,访问时间相同时按访问量降序排序
|
||||
|
||||
**成功响应**
|
||||
|
||||
- 状态码:`200`
|
||||
|
||||
```json
|
||||
{
|
||||
"items": [
|
||||
{
|
||||
"postSlug": "https://example.com/blog/hello-world",
|
||||
"postTitle": "Hello World",
|
||||
"postUrl": "https://example.com/blog/hello-world",
|
||||
"pv": 100,
|
||||
"lastVisitAt": 1737593600000
|
||||
},
|
||||
{
|
||||
"postSlug": "https://example.com/about",
|
||||
"postTitle": "关于我",
|
||||
"postUrl": "https://example.com/about",
|
||||
"pv": 50,
|
||||
"lastVisitAt": 1737593500000
|
||||
}
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
字段说明:
|
||||
|
||||
| 字段名 | 类型 | 说明 |
|
||||
| ------------- | ------ | -------------------------- |
|
||||
| `postSlug` | string | 文章唯一标识符 |
|
||||
| `postTitle` | string \| null | 文章标题 |
|
||||
| `postUrl` | string \| null | 文章 URL |
|
||||
| `pv` | number | 访问量(PV) |
|
||||
| `lastVisitAt` | number \| null | 最后访问时间戳(毫秒) |
|
||||
|
||||
**错误响应**
|
||||
|
||||
- 状态码:`500`
|
||||
|
||||
```json
|
||||
{
|
||||
"message": "获取页面访问统计失败"
|
||||
}
|
||||
```
|
||||
|
||||
@@ -421,3 +421,73 @@ POST /api/verify-admin
|
||||
"message": "验证失败次数过多,请30分钟后再试"
|
||||
}
|
||||
```
|
||||
|
||||
## 4. 访问统计相关
|
||||
|
||||
### 4.1 记录页面访问
|
||||
|
||||
```
|
||||
POST /api/analytics/visit
|
||||
```
|
||||
|
||||
前端组件在加载时调用此接口,记录页面访问数据,用于后台访问统计分析。
|
||||
|
||||
- 方法:`POST`
|
||||
- 路径:`/api/analytics/visit`
|
||||
- 鉴权:不需要
|
||||
|
||||
**请求头**
|
||||
|
||||
| 名称 | 必填 | 示例 |
|
||||
| -------------- | ---- | ------------------ |
|
||||
| `Content-Type` | 是 | `application/json` |
|
||||
|
||||
**请求体**
|
||||
|
||||
```json
|
||||
{
|
||||
"postSlug": "https://example.com/blog/hello-world",
|
||||
"postTitle": "博客标题",
|
||||
"postUrl": "https://example.com/blog/hello-world"
|
||||
}
|
||||
```
|
||||
|
||||
字段说明:
|
||||
|
||||
| 字段名 | 类型 | 必填 | 说明 |
|
||||
| ----------- | ------ | ---- | -------------------------------------------------------------------- |
|
||||
| `postSlug` | string | 是 | 文章唯一标识符,`window.location.origin + window.location.pathname` |
|
||||
| `postTitle` | string | 否 | 文章标题,用于后台展示页面名称 |
|
||||
| `postUrl` | string | 否 | 文章 URL,用于后台展示页面链接和域名统计 |
|
||||
|
||||
**成功响应**
|
||||
|
||||
- 状态码:`200`
|
||||
|
||||
```json
|
||||
{
|
||||
"success": true
|
||||
}
|
||||
```
|
||||
|
||||
**错误响应**
|
||||
|
||||
- 缺少 `postSlug`:
|
||||
|
||||
- 状态码:`400`
|
||||
|
||||
```json
|
||||
{
|
||||
"message": "postSlug is required"
|
||||
}
|
||||
```
|
||||
|
||||
- 服务器内部错误:
|
||||
|
||||
- 状态码:`500`
|
||||
|
||||
```json
|
||||
{
|
||||
"message": "记录访问数据失败"
|
||||
}
|
||||
```
|
||||
|
||||
Reference in New Issue
Block a user