feat(analytics): 添加今日/本周/本月访问量统计功能

- 在访问统计概览中新增今日、本周、本月访问量数据展示
- 更新API文档和前端界面以支持新的统计维度
- 优化图表显示样式,减小数据点大小
- 调整统计网格布局适配移动端
This commit is contained in:
anghunk
2026-01-22 09:52:44 +08:00
parent 4550b3c972
commit 5164f2c490
6 changed files with 302 additions and 9 deletions

View File

@@ -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;

View File

@@ -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>

View File

@@ -224,7 +224,7 @@ function renderChart() {
color: "#0ea5e9",
},
symbol: "circle",
symbolSize: 6,
symbolSize: 3,
},
],
};

View File

@@ -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
};

View File

@@ -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": "获取页面访问统计失败"
}
```

View File

@@ -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": "记录访问数据失败"
}
```