diff --git a/cwd-admin/src/api/admin.ts b/cwd-admin/src/api/admin.ts
index 72f5042..708a689 100644
--- a/cwd-admin/src/api/admin.ts
+++ b/cwd-admin/src/api/admin.ts
@@ -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;
diff --git a/cwd-admin/src/views/AnalyticsVisitView.vue b/cwd-admin/src/views/AnalyticsVisitView.vue
index b21c29b..7a59410 100644
--- a/cwd-admin/src/views/AnalyticsVisitView.vue
+++ b/cwd-admin/src/views/AnalyticsVisitView.vue
@@ -31,6 +31,18 @@
全站总访问量
{{ overview.totalPv }}
+
+
今日访问量
+
{{ overview.todayPv }}
+
+
+
本周访问量
+
{{ overview.weekPv }}
+
+
+
本月访问量
+
{{ overview.monthPv }}
+
有访问记录的页面数
{{ overview.totalPages }}
@@ -127,6 +139,9 @@ const error = ref("");
const overview = ref
({
totalPv: 0,
totalPages: 0,
+ todayPv: 0,
+ weekPv: 0,
+ monthPv: 0,
last30Days: [],
});
const items = ref([]);
@@ -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));
+ }
+}
diff --git a/cwd-admin/src/views/StatsView.vue b/cwd-admin/src/views/StatsView.vue
index 41cbe5a..2b79a15 100644
--- a/cwd-admin/src/views/StatsView.vue
+++ b/cwd-admin/src/views/StatsView.vue
@@ -224,7 +224,7 @@ function renderChart() {
color: "#0ea5e9",
},
symbol: "circle",
- symbolSize: 6,
+ symbolSize: 3,
},
],
};
diff --git a/cwd-api/src/api/admin/visitAnalytics.ts b/cwd-api/src/api/admin/visitAnalytics.ts
index 41dc9d3..8290f73 100644
--- a/cwd-api/src/api/admin/visitAnalytics.ts
+++ b/cwd-api/src/api/admin/visitAnalytics.ts
@@ -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
};
diff --git a/docs/api/admin.md b/docs/api/admin.md
index 1735718..68f6807 100644
--- a/docs/api/admin.md
+++ b/docs/api/admin.md
@@ -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\ | 最近 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": "获取页面访问统计失败"
+}
+```
diff --git a/docs/api/public.md b/docs/api/public.md
index 34405a4..5835419 100644
--- a/docs/api/public.md
+++ b/docs/api/public.md
@@ -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": "记录访问数据失败"
+ }
+ ```