feat(analytics): 扩展页面访问统计接口并新增站点隔离文档
- 在 `/admin/analytics/pages` 接口响应中添加 `itemsByPv` 和 `itemsByLatest` 字段,分别返回按访问量和最新访问排序的前20条数据,优化前端数据展示逻辑 - 前端管理后台适配新的接口响应结构,提升页面访问统计页面的性能和用户体验 - 新增站点隔离配置文档 (`/docs/config/site-isolation.md`),说明如何通过 `siteId` 配置实现数据隔离 - 在前端配置指南中补充 `siteId` 配置示例 - 新增常见问题文档 (`/docs/common-problems.md`),解答设置 `siteId` 后评论数据不显示的问题及数据迁移方案 - 在导航栏和侧边栏添加“常见问题”和“配置”相关入口,完善文档结构 - 修复前端组件 API 调用中 `postSlug` 参数可能为 `postUrl` 的兼容性问题
This commit is contained in:
@@ -111,6 +111,8 @@ export type VisitPageItem = {
|
|||||||
|
|
||||||
export type VisitPagesResponse = {
|
export type VisitPagesResponse = {
|
||||||
items: VisitPageItem[];
|
items: VisitPageItem[];
|
||||||
|
itemsByPv?: VisitPageItem[];
|
||||||
|
itemsByLatest?: VisitPageItem[];
|
||||||
};
|
};
|
||||||
|
|
||||||
export type DomainListResponse = {
|
export type DomainListResponse = {
|
||||||
|
|||||||
@@ -150,12 +150,12 @@
|
|||||||
</div>
|
</div>
|
||||||
<div class="domain-cell domain-cell-url">
|
<div class="domain-cell domain-cell-url">
|
||||||
<a
|
<a
|
||||||
v-if="item.postSlug"
|
v-if="item.postUrl"
|
||||||
:href="item.postSlug"
|
:href="item.postUrl"
|
||||||
target="_blank"
|
target="_blank"
|
||||||
rel="noreferrer"
|
rel="noreferrer"
|
||||||
>
|
>
|
||||||
{{ item.postSlug }}
|
{{ item.postUrl }}
|
||||||
</a>
|
</a>
|
||||||
<span v-else>-</span>
|
<span v-else>-</span>
|
||||||
</div>
|
</div>
|
||||||
@@ -259,24 +259,13 @@ const monthPercentageChange = computed(() => {
|
|||||||
return calculateChange(overview.value.monthPv, overview.value.lastMonthPv || 0);
|
return calculateChange(overview.value.monthPv, overview.value.lastMonthPv || 0);
|
||||||
});
|
});
|
||||||
|
|
||||||
const rawItems = ref<VisitPageItem[]>([]);
|
const itemsByPv = ref<VisitPageItem[]>([]);
|
||||||
|
const itemsByLatest = ref<VisitPageItem[]>([]);
|
||||||
const items = computed<VisitPageItem[]>(() => {
|
const items = computed<VisitPageItem[]>(() => {
|
||||||
const list = rawItems.value.slice();
|
if (visitTab.value === "latest") {
|
||||||
list.sort((a, b) => {
|
return itemsByLatest.value;
|
||||||
const aLast = getLastVisitAtTs(a.lastVisitAt);
|
}
|
||||||
const bLast = getLastVisitAtTs(b.lastVisitAt);
|
return itemsByPv.value;
|
||||||
if (visitTab.value === "latest") {
|
|
||||||
if (bLast !== aLast) {
|
|
||||||
return bLast - aLast;
|
|
||||||
}
|
|
||||||
return b.pv - a.pv;
|
|
||||||
}
|
|
||||||
if (b.pv !== a.pv) {
|
|
||||||
return b.pv - a.pv;
|
|
||||||
}
|
|
||||||
return bLast - aLast;
|
|
||||||
});
|
|
||||||
return list;
|
|
||||||
});
|
});
|
||||||
const visitTab = ref<"pv" | "latest">("pv");
|
const visitTab = ref<"pv" | "latest">("pv");
|
||||||
const visitTabStorageKey = "cwd-analytics-visit-tab";
|
const visitTabStorageKey = "cwd-analytics-visit-tab";
|
||||||
@@ -353,11 +342,11 @@ function extractDomain(source: string | null | undefined): string | null {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function getVisitOrderParam(): "pv" | "latest" | undefined {
|
function getVisitOrderParam(): "pv" | "latest" {
|
||||||
if (visitTab.value === "latest") {
|
if (visitTab.value === "latest") {
|
||||||
return "latest";
|
return "latest";
|
||||||
}
|
}
|
||||||
return undefined;
|
return "pv";
|
||||||
}
|
}
|
||||||
|
|
||||||
function filterLikeStatsByDomain(list: LikeStatsItem[], domain: string | undefined): LikeStatsItem[] {
|
function filterLikeStatsByDomain(list: LikeStatsItem[], domain: string | undefined): LikeStatsItem[] {
|
||||||
@@ -457,8 +446,36 @@ async function loadData() {
|
|||||||
};
|
};
|
||||||
const likeItemsRaw = Array.isArray(likeStatsRes.items) ? likeStatsRes.items : [];
|
const likeItemsRaw = Array.isArray(likeStatsRes.items) ? likeStatsRes.items : [];
|
||||||
likeStatsItems.value = filterLikeStatsByDomain(likeItemsRaw, domain);
|
likeStatsItems.value = filterLikeStatsByDomain(likeItemsRaw, domain);
|
||||||
const pageItems = Array.isArray(pagesRes.items) ? pagesRes.items : [];
|
const pageItemsByPv = Array.isArray(pagesRes.itemsByPv)
|
||||||
rawItems.value = pageItems;
|
? pagesRes.itemsByPv
|
||||||
|
: [];
|
||||||
|
const pageItemsByLatest = Array.isArray(pagesRes.itemsByLatest)
|
||||||
|
? pagesRes.itemsByLatest
|
||||||
|
: [];
|
||||||
|
if (pageItemsByPv.length === 0 && pageItemsByLatest.length === 0) {
|
||||||
|
const baseItems = Array.isArray(pagesRes.items) ? pagesRes.items : [];
|
||||||
|
const sortedByPv = baseItems.slice().sort((a, b) => {
|
||||||
|
if (b.pv !== a.pv) {
|
||||||
|
return b.pv - a.pv;
|
||||||
|
}
|
||||||
|
const aLast = getLastVisitAtTs(a.lastVisitAt);
|
||||||
|
const bLast = getLastVisitAtTs(b.lastVisitAt);
|
||||||
|
return bLast - aLast;
|
||||||
|
});
|
||||||
|
const sortedByLatest = baseItems.slice().sort((a, b) => {
|
||||||
|
const aLast = getLastVisitAtTs(a.lastVisitAt);
|
||||||
|
const bLast = getLastVisitAtTs(b.lastVisitAt);
|
||||||
|
if (bLast !== aLast) {
|
||||||
|
return bLast - aLast;
|
||||||
|
}
|
||||||
|
return b.pv - a.pv;
|
||||||
|
});
|
||||||
|
itemsByPv.value = sortedByPv;
|
||||||
|
itemsByLatest.value = sortedByLatest;
|
||||||
|
} else {
|
||||||
|
itemsByPv.value = pageItemsByPv;
|
||||||
|
itemsByLatest.value = pageItemsByLatest;
|
||||||
|
}
|
||||||
last30Days.value = Array.isArray(overviewRes.last30Days)
|
last30Days.value = Array.isArray(overviewRes.last30Days)
|
||||||
? overviewRes.last30Days
|
? overviewRes.last30Days
|
||||||
: [];
|
: [];
|
||||||
|
|||||||
@@ -263,22 +263,15 @@ export const getVisitPages = async (c: Context<{ Bindings: Bindings }>) => {
|
|||||||
const rawDomain = c.req.query('domain') || '';
|
const rawDomain = c.req.query('domain') || '';
|
||||||
const domainFilter = rawDomain.trim().toLowerCase();
|
const domainFilter = rawDomain.trim().toLowerCase();
|
||||||
const rawOrder = c.req.query('order') || '';
|
const rawOrder = c.req.query('order') || '';
|
||||||
const order = rawOrder.trim().toLowerCase();
|
const order = rawOrder.trim().toLowerCase() === 'latest' ? 'latest' : 'pv';
|
||||||
const isLatest = order === 'latest';
|
|
||||||
|
|
||||||
await c.env.CWD_DB.prepare(
|
await c.env.CWD_DB.prepare(
|
||||||
'CREATE TABLE IF NOT EXISTS page_stats (id INTEGER PRIMARY KEY AUTOINCREMENT, post_slug TEXT UNIQUE NOT NULL, post_title TEXT, post_url TEXT, pv INTEGER NOT NULL DEFAULT 0, last_visit_at INTEGER, created_at INTEGER NOT NULL, updated_at INTEGER NOT NULL)'
|
'CREATE TABLE IF NOT EXISTS page_stats (id INTEGER PRIMARY KEY AUTOINCREMENT, post_slug TEXT UNIQUE NOT NULL, post_title TEXT, post_url TEXT, pv INTEGER NOT NULL DEFAULT 0, last_visit_at INTEGER, created_at INTEGER NOT NULL, updated_at INTEGER NOT NULL)'
|
||||||
).run();
|
).run();
|
||||||
|
|
||||||
let sql =
|
const { results } = await c.env.CWD_DB.prepare(
|
||||||
'SELECT post_slug, post_title, post_url, pv, last_visit_at FROM page_stats ORDER BY pv DESC, last_visit_at DESC';
|
'SELECT post_slug, post_title, post_url, pv, last_visit_at FROM page_stats'
|
||||||
|
).all<{
|
||||||
if (isLatest) {
|
|
||||||
sql =
|
|
||||||
'SELECT post_slug, post_title, post_url, pv, last_visit_at FROM page_stats ORDER BY last_visit_at DESC, pv DESC';
|
|
||||||
}
|
|
||||||
|
|
||||||
const { results } = await c.env.CWD_DB.prepare(sql).all<{
|
|
||||||
post_slug: string;
|
post_slug: string;
|
||||||
post_title: string | null;
|
post_title: string | null;
|
||||||
post_url: string | null;
|
post_url: string | null;
|
||||||
@@ -307,9 +300,44 @@ export const getVisitPages = async (c: Context<{ Bindings: Bindings }>) => {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
items = items.slice(0, 20);
|
const itemsByPv = items
|
||||||
|
.slice()
|
||||||
|
.sort((a, b) => {
|
||||||
|
if (b.pv !== a.pv) {
|
||||||
|
return b.pv - a.pv;
|
||||||
|
}
|
||||||
|
const aLast = a.lastVisitAt ?? 0;
|
||||||
|
const bLast = b.lastVisitAt ?? 0;
|
||||||
|
return bLast - aLast;
|
||||||
|
})
|
||||||
|
.slice(0, 20);
|
||||||
|
|
||||||
return c.json({ items });
|
const itemsByLatest = items
|
||||||
|
.slice()
|
||||||
|
.sort((a, b) => {
|
||||||
|
const aLast = a.lastVisitAt ?? 0;
|
||||||
|
const bLast = b.lastVisitAt ?? 0;
|
||||||
|
if (bLast !== aLast) {
|
||||||
|
return bLast - aLast;
|
||||||
|
}
|
||||||
|
return b.pv - a.pv;
|
||||||
|
})
|
||||||
|
.slice(0, 20);
|
||||||
|
|
||||||
|
const response =
|
||||||
|
order === 'latest'
|
||||||
|
? {
|
||||||
|
items: itemsByLatest,
|
||||||
|
itemsByPv,
|
||||||
|
itemsByLatest
|
||||||
|
}
|
||||||
|
: {
|
||||||
|
items: itemsByPv,
|
||||||
|
itemsByPv,
|
||||||
|
itemsByLatest
|
||||||
|
};
|
||||||
|
|
||||||
|
return c.json(response);
|
||||||
} catch (e: any) {
|
} catch (e: any) {
|
||||||
return c.json(
|
return c.json(
|
||||||
{ message: e.message || '获取页面访问统计失败' },
|
{ message: e.message || '获取页面访问统计失败' },
|
||||||
|
|||||||
@@ -2,5 +2,6 @@ export default [
|
|||||||
{ text: '首页', link: '/' },
|
{ text: '首页', link: '/' },
|
||||||
{ text: '快速开始', link: '/guide/getting-started' },
|
{ text: '快速开始', link: '/guide/getting-started' },
|
||||||
{ text: 'API 文档', link: '/api/overview' },
|
{ text: 'API 文档', link: '/api/overview' },
|
||||||
|
{ text: '常见问题', link: '/common-problems' },
|
||||||
{ text: '管理后台', link: 'https://cwd.zishu.me' },
|
{ text: '管理后台', link: 'https://cwd.zishu.me' },
|
||||||
];
|
];
|
||||||
|
|||||||
@@ -20,7 +20,14 @@ export const rootSidebar = [
|
|||||||
{ text: '数据管理', link: '/function/data-migration' },
|
{ text: '数据管理', link: '/function/data-migration' },
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
text: '配置',
|
||||||
|
items: [
|
||||||
|
{ text: '站点隔离', link: '/config/site-isolation' },
|
||||||
|
],
|
||||||
|
},
|
||||||
{ text: '反馈', link: '/guide/feedback' },
|
{ text: '反馈', link: '/guide/feedback' },
|
||||||
|
{ text: '常见问题', link: '/common-problems' },
|
||||||
];
|
];
|
||||||
|
|
||||||
export const apiSidebar = [
|
export const apiSidebar = [
|
||||||
|
|||||||
@@ -124,13 +124,24 @@ GET /admin/analytics/pages
|
|||||||
"postUrl": "https://example.com/blog/hello-world",
|
"postUrl": "https://example.com/blog/hello-world",
|
||||||
"pv": 100,
|
"pv": 100,
|
||||||
"lastVisitAt": 1737593600000
|
"lastVisitAt": 1737593600000
|
||||||
},
|
}
|
||||||
|
],
|
||||||
|
"itemsByPv": [
|
||||||
{
|
{
|
||||||
"postSlug": "https://example.com/about",
|
"postSlug": "https://example.com/blog/hello-world",
|
||||||
"postTitle": "关于我",
|
"postTitle": "Hello World",
|
||||||
"postUrl": "https://example.com/about",
|
"postUrl": "https://example.com/blog/hello-world",
|
||||||
"pv": 50,
|
"pv": 100,
|
||||||
"lastVisitAt": 1737593500000
|
"lastVisitAt": 1737593600000
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"itemsByLatest": [
|
||||||
|
{
|
||||||
|
"postSlug": "https://example.com/blog/hello-world",
|
||||||
|
"postTitle": "Hello World",
|
||||||
|
"postUrl": "https://example.com/blog/hello-world",
|
||||||
|
"pv": 100,
|
||||||
|
"lastVisitAt": 1737593600000
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
@@ -138,13 +149,16 @@ GET /admin/analytics/pages
|
|||||||
|
|
||||||
字段说明:
|
字段说明:
|
||||||
|
|
||||||
| 字段名 | 类型 | 说明 |
|
| 字段名 | 类型 | 说明 |
|
||||||
| ------------- | ------ | -------------------------- |
|
| ------------------- | ------ | ----------------------------------------- |
|
||||||
| `postSlug` | string | 文章唯一标识符 |
|
| `items` | Array | 根据 `order` 参数返回的主列表 |
|
||||||
| `postTitle` | string \| null | 文章标题 |
|
| `itemsByPv` | Array | 按 `order=pv` 规则排序后的前 20 条数据 |
|
||||||
| `postUrl` | string \| null | 文章 URL |
|
| `itemsByLatest` | Array | 按 `order=latest` 规则排序后的前 20 条数据 |
|
||||||
| `pv` | number | 访问量(PV) |
|
| `postSlug` | string | 文章唯一标识符 |
|
||||||
| `lastVisitAt` | number \| null | 最后访问时间戳(毫秒) |
|
| `postTitle` | string \| null | 文章标题 |
|
||||||
|
| `postUrl` | string \| null | 文章 URL |
|
||||||
|
| `pv` | number | 访问量(PV) |
|
||||||
|
| `lastVisitAt` | number \| null | 最后访问时间戳(毫秒) |
|
||||||
|
|
||||||
**错误响应**
|
**错误响应**
|
||||||
|
|
||||||
|
|||||||
16
docs/common-problems.md
Normal file
16
docs/common-problems.md
Normal file
@@ -0,0 +1,16 @@
|
|||||||
|
# 常见问题
|
||||||
|
|
||||||
|
## 1. 为什么设置完 siteId 后,评论区没有显示评论数据?
|
||||||
|
|
||||||
|
因为设置了 siteId 后,接口会根据 siteId 来查询数据库带有你设置的 siteId 的评论数据。所以如果设置了 siteId,旧数据就有可能不显示,需要手动去 Cloudflare D1 控制台 Query 运行 SQL 语句来更新数据。
|
||||||
|
|
||||||
|
- `abc`: 你要设置的 siteId
|
||||||
|
- `example.com`: 查找包含指定域名的评论数据
|
||||||
|
|
||||||
|
```sql
|
||||||
|
UPDATE Comment
|
||||||
|
SET site_id = 'abc'
|
||||||
|
WHERE post_url LIKE '%example.com%';
|
||||||
|
```
|
||||||
|
|
||||||
|
运行以上 SQL 语句后,评论区就会显示带有 `abc` siteId 的评论数据了。
|
||||||
9
docs/config/site-isolation.md
Normal file
9
docs/config/site-isolation.md
Normal file
@@ -0,0 +1,9 @@
|
|||||||
|
# 站点隔离
|
||||||
|
|
||||||
|
配置支持站点数据隔离。
|
||||||
|
|
||||||
|
通过前端实例调用时进行配置:
|
||||||
|
|
||||||
|
```
|
||||||
|
|
||||||
|
```
|
||||||
@@ -28,6 +28,7 @@ CWD 评论组件采用 **Shadow DOM** 技术构建,基于独立根节点渲染
|
|||||||
const comments = new CWDComments({
|
const comments = new CWDComments({
|
||||||
el: '#comments',
|
el: '#comments',
|
||||||
apiBaseUrl: 'https://your-api.example.com', // 换成你的 API 地址
|
apiBaseUrl: 'https://your-api.example.com', // 换成你的 API 地址
|
||||||
|
siteId: 'your-site-id', // 换成你的站点 ID(关键词),比如 blog
|
||||||
});
|
});
|
||||||
comments.mount();
|
comments.mount();
|
||||||
</script>
|
</script>
|
||||||
|
|||||||
@@ -14,6 +14,9 @@ hero:
|
|||||||
- theme: alt
|
- theme: alt
|
||||||
text: Github
|
text: Github
|
||||||
link: https://github.com/anghunk/cwd
|
link: https://github.com/anghunk/cwd
|
||||||
|
- theme: alt
|
||||||
|
text: 常见问题
|
||||||
|
link: /common-problems
|
||||||
|
|
||||||
features:
|
features:
|
||||||
- icon: ⚡️
|
- icon: ⚡️
|
||||||
|
|||||||
@@ -128,7 +128,7 @@ export function createApiClient(config) {
|
|||||||
'Content-Type': 'application/json'
|
'Content-Type': 'application/json'
|
||||||
},
|
},
|
||||||
body: JSON.stringify({
|
body: JSON.stringify({
|
||||||
postSlug: config.postSlug,
|
postSlug: config.postUrl || config.postSlug,
|
||||||
postTitle: config.postTitle,
|
postTitle: config.postTitle,
|
||||||
postUrl: config.postUrl
|
postUrl: config.postUrl
|
||||||
})
|
})
|
||||||
|
|||||||
Reference in New Issue
Block a user