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