feat(analytics): 添加访问统计功能

实现页面访问统计功能,包括:
1. 前端组件挂载时上报访问数据
2. 后端接口记录访问信息并存储
3. 管理后台新增访问统计视图
4. 相关API文档和设计说明更新
This commit is contained in:
anghunk
2026-01-21 21:56:57 +08:00
parent aabf370f65
commit d5a7a15344
12 changed files with 1241 additions and 201 deletions

View File

@@ -83,6 +83,23 @@ export type CommentStatsResponse = {
}[];
};
export type VisitOverviewResponse = {
totalPv: number;
totalPages: number;
};
export type VisitPageItem = {
postSlug: string;
postTitle: string | null;
postUrl: string | null;
pv: number;
lastVisitAt: string | null;
};
export type VisitPagesResponse = {
items: VisitPageItem[];
};
export async function loginAdmin(name: string, password: string): Promise<string> {
const res = await post<AdminLoginResponse>('/admin/login', { name, password });
const key = res.data.key;
@@ -212,3 +229,11 @@ export function importComments(data: any[]): Promise<{ message: string }> {
export function fetchCommentStats(): Promise<CommentStatsResponse> {
return get<CommentStatsResponse>('/admin/stats/comments');
}
export function fetchVisitOverview(): Promise<VisitOverviewResponse> {
return get<VisitOverviewResponse>('/admin/analytics/overview');
}
export function fetchVisitPages(): Promise<VisitPagesResponse> {
return get<VisitPagesResponse>('/admin/analytics/pages');
}

View File

@@ -5,6 +5,7 @@ import CommentsView from '../views/CommentsView.vue';
import SettingsView from '../views/SettingsView.vue';
import DataView from '../views/DataView.vue';
import StatsView from '../views/StatsView.vue';
import AnalyticsVisitView from '../views/AnalyticsVisitView.vue';
const routes: RouteRecordRaw[] = [
{
@@ -30,6 +31,11 @@ const routes: RouteRecordRaw[] = [
name: 'stats',
component: StatsView
},
{
path: 'analytics',
name: 'analytics',
component: AnalyticsVisitView
},
{
path: 'settings',
name: 'settings',

View File

@@ -0,0 +1,266 @@
<template>
<div class="page">
<h2 class="page-title">访问统计</h2>
<div
v-if="toastVisible"
class="toast"
:class="toastType === 'error' ? 'toast-error' : 'toast-success'"
>
{{ toastMessage }}
</div>
<div class="card">
<h3 class="card-title">整体概览</h3>
<div v-if="loading" class="page-hint">加载中...</div>
<div v-else-if="error" class="page-error">{{ error }}</div>
<div v-else>
<div class="stats-grid">
<div class="stats-item">
<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.totalPages }}</div>
</div>
</div>
</div>
</div>
<div class="card">
<h3 class="card-title">页面访问明细 PV 排序</h3>
<div v-if="loading" class="page-hint">加载中...</div>
<div v-else-if="error" class="page-error">{{ error }}</div>
<div v-else-if="items.length === 0" class="page-hint">暂无访问数据</div>
<div v-else class="domain-table">
<div class="domain-table-header">
<div class="domain-cell domain-cell-domain">页面标题</div>
<div class="domain-cell">访问量</div>
<div class="domain-cell">最后访问时间</div>
<div class="domain-cell">页面地址</div>
</div>
<div
v-for="item in items"
:key="item.postSlug"
class="domain-table-row"
>
<div class="domain-cell domain-cell-domain">
{{ item.postTitle || item.postSlug }}
</div>
<div class="domain-cell">
{{ item.pv }}
</div>
<div class="domain-cell">
{{ formatTime(item.lastVisitAt) }}
</div>
<div class="domain-cell">
<a
v-if="item.postUrl"
:href="item.postUrl"
target="_blank"
rel="noreferrer"
>
{{ item.postUrl }}
</a>
<span v-else>-</span>
</div>
</div>
</div>
</div>
</div>
</template>
<script setup lang="ts">
import { onMounted, ref } from "vue";
import {
fetchVisitOverview,
fetchVisitPages,
type VisitOverviewResponse,
type VisitPageItem,
} from "../api/admin";
const loading = ref(false);
const error = ref("");
const overview = ref<VisitOverviewResponse>({
totalPv: 0,
totalPages: 0,
});
const items = ref<VisitPageItem[]>([]);
const toastMessage = ref("");
const toastType = ref<"success" | "error">("success");
const toastVisible = ref(false);
function showToast(msg: string, type: "success" | "error" = "success") {
toastMessage.value = msg;
toastType.value = type;
toastVisible.value = true;
window.setTimeout(() => {
toastVisible.value = false;
}, 2000);
}
function formatTime(value: string | null): string {
if (!value) {
return "-";
}
const date = new Date(value);
if (Number.isNaN(date.getTime())) {
return value;
}
const y = date.getFullYear();
const m = String(date.getMonth() + 1).padStart(2, "0");
const d = String(date.getDate()).padStart(2, "0");
const h = String(date.getHours()).padStart(2, "0");
const mm = String(date.getMinutes()).padStart(2, "0");
return `${y}-${m}-${d} ${h}:${mm}`;
}
async function loadData() {
loading.value = true;
error.value = "";
try {
const [overviewRes, pagesRes] = await Promise.all([
fetchVisitOverview(),
fetchVisitPages(),
]);
overview.value = overviewRes;
items.value = pagesRes.items || [];
} catch (e: any) {
const msg = e.message || "加载访问统计数据失败";
error.value = msg;
showToast(msg, "error");
} finally {
loading.value = false;
}
}
onMounted(() => {
loadData();
});
</script>
<style scoped>
.page {
display: flex;
flex-direction: column;
gap: 12px;
}
.page-title {
margin: 0;
font-size: 18px;
color: #24292f;
}
.card {
background-color: #ffffff;
border-radius: 6px;
border: 1px solid #d0d7de;
padding: 16px 18px;
}
.card-title {
margin: 0 0 12px;
font-size: 16px;
}
.page-hint {
font-size: 14px;
color: #57606a;
}
.page-error {
font-size: 14px;
color: #d1242f;
}
.stats-grid {
display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr));
gap: 12px;
}
.stats-item {
padding: 10px 12px;
border-radius: 6px;
background-color: #f6f8fa;
border: 1px solid #d0d7de;
}
.stats-label {
font-size: 14px;
color: #57606a;
margin-bottom: 4px;
}
.stats-value {
font-size: 26px;
font-weight: 600;
color: #24292f;
}
.domain-table {
border: 1px solid #d0d7de;
border-radius: 6px;
overflow: hidden;
}
.domain-table-header {
display: flex;
background-color: #f6f8fa;
}
.domain-table-row {
display: flex;
border-top: 1px solid #eaeae0;
}
.domain-cell {
flex: 1;
padding: 10px 10px;
font-size: 14px;
color: #24292f;
box-sizing: border-box;
}
.domain-cell a {
color: #57606a;
}
.domain-cell-domain {
flex: 2;
font-weight: 500;
}
.toast {
position: fixed;
top: 20px;
left: 50%;
transform: translateX(-50%);
min-width: 220px;
max-width: 320px;
padding: 10px 14px;
border-radius: 6px;
font-size: 13px;
box-shadow: 0 8px 24px rgba(140, 149, 159, 0.2);
z-index: 1000;
}
.toast-success {
background-color: #1a7f37;
color: #ffffff;
}
.toast-error {
background-color: #d1242f;
color: #ffffff;
}
@media (max-width: 768px) {
.stats-grid {
grid-template-columns: repeat(1, minmax(0, 1fr));
}
}
</style>

View File

@@ -75,6 +75,13 @@
>
数据看板
</li>
<li
class="menu-item"
:class="{ active: isRouteActive('analytics') }"
@click="goAnalytics"
>
访问统计
</li>
<li
class="menu-item"
:class="{ active: isRouteActive('settings') }"
@@ -140,6 +147,11 @@ function goStats() {
closeSider();
}
function goAnalytics() {
router.push({ name: "analytics" });
closeSider();
}
function goData() {
router.push({ name: "data" });
closeSider();

View File

@@ -169,6 +169,10 @@ export class CWDComments {
this._render();
this.store.loadComments();
if (this.api && typeof this.api.trackVisit === 'function') {
this.api.trackVisit();
}
})();
this._mounted = true;

View File

@@ -97,9 +97,27 @@
return response.json();
}
async function trackVisit() {
try {
await fetch(`${baseUrl}/api/analytics/visit`, {
method: 'POST',
headers: {
'Content-Type': 'application/json'
},
body: JSON.stringify({
postSlug: config.postSlug,
postTitle: config.postTitle,
postUrl: config.postUrl
})
});
} catch (e) {
}
}
return {
fetchComments,
submitComment,
verifyAdminKey
verifyAdminKey,
trackVisit
};
}

View File

@@ -0,0 +1,78 @@
import type { Context } from 'hono';
import type { Bindings } from '../../bindings';
type VisitOverview = {
totalPv: number;
totalPages: number;
};
type VisitPageItem = {
postSlug: string;
postTitle: string | null;
postUrl: string | null;
pv: number;
lastVisitAt: string | null;
};
export const getVisitOverview = async (
c: Context<{ Bindings: Bindings }>
) => {
try {
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 TEXT, created_at TEXT NOT NULL, updated_at TEXT NOT NULL)'
).run();
const row = await c.env.CWD_DB.prepare(
'SELECT COUNT(*) as totalPages, COALESCE(SUM(pv), 0) as totalPv FROM page_stats'
).first<{
totalPages: number | null;
totalPv: number | null;
}>();
const data: VisitOverview = {
totalPv: row?.totalPv || 0,
totalPages: row?.totalPages || 0
};
return c.json(data);
} catch (e: any) {
return c.json(
{ message: e.message || '获取访问统计概览失败' },
500
);
}
};
export const getVisitPages = async (c: Context<{ Bindings: Bindings }>) => {
try {
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 TEXT, created_at TEXT NOT NULL, updated_at TEXT NOT NULL)'
).run();
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'
).all<{
post_slug: string;
post_title: string | null;
post_url: string | null;
pv: number;
last_visit_at: string | null;
}>();
const items: VisitPageItem[] = results.map((row) => ({
postSlug: row.post_slug,
postTitle: row.post_title,
postUrl: row.post_url,
pv: row.pv || 0,
lastVisitAt: row.last_visit_at
}));
return c.json({ items });
} catch (e: any) {
return c.json(
{ message: e.message || '获取页面访问统计失败' },
500
);
}
};

View File

@@ -0,0 +1,69 @@
import type { Context } from 'hono';
import type { Bindings } from '../../bindings';
type TrackVisitBody = {
postSlug?: string;
postTitle?: string;
postUrl?: string;
};
export const trackVisit = async (c: Context<{ Bindings: Bindings }>) => {
try {
const body = (await c.req.json().catch(() => ({}))) as TrackVisitBody;
const rawPostSlug = typeof body.postSlug === 'string' ? body.postSlug.trim() : '';
const rawPostTitle = typeof body.postTitle === 'string' ? body.postTitle.trim() : '';
const rawPostUrl = typeof body.postUrl === 'string' ? body.postUrl.trim() : '';
if (!rawPostSlug) {
return c.json({ message: 'postSlug is required' }, 400);
}
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 TEXT, created_at TEXT NOT NULL, updated_at TEXT NOT NULL)'
).run();
const now = new Date().toISOString();
const existing = await c.env.CWD_DB.prepare(
'SELECT id, pv FROM page_stats WHERE post_slug = ?'
)
.bind(rawPostSlug)
.first<{ id: number; pv: number }>();
if (!existing) {
await c.env.CWD_DB.prepare(
'INSERT INTO page_stats (post_slug, post_title, post_url, pv, last_visit_at, created_at, updated_at) VALUES (?, ?, ?, ?, ?, ?, ?)'
)
.bind(
rawPostSlug,
rawPostTitle || null,
rawPostUrl || null,
1,
now,
now,
now
)
.run();
} else {
const newPv = (existing.pv || 0) + 1;
await c.env.CWD_DB.prepare(
'UPDATE page_stats SET post_title = ?, post_url = ?, pv = ?, last_visit_at = ?, updated_at = ? WHERE id = ?'
)
.bind(
rawPostTitle || null,
rawPostUrl || null,
newPv,
now,
now,
existing.id
)
.run();
}
return c.json({ success: true });
} catch (e: any) {
return c.json({ message: e.message || '记录访问数据失败' }, 500);
}
};

View File

@@ -23,6 +23,8 @@ import { getAdminEmail } from './api/admin/getAdminEmail';
import { setAdminEmail } from './api/admin/setAdminEmail';
import { testEmail } from './api/admin/testEmail';
import { getStats } from './api/admin/getStats';
import { trackVisit } from './api/public/trackVisit';
import { getVisitOverview, getVisitPages } from './api/admin/visitAnalytics';
const app = new Hono<{ Bindings: Bindings }>();
const VERSION = `v${packageJson.version}`;
@@ -214,6 +216,7 @@ app.get('/', (c) => {
app.get('/api/comments', getComments);
app.post('/api/comments', postComment);
app.post('/api/verify-admin', verifyAdminKey);
app.post('/api/analytics/visit', trackVisit);
app.get('/api/config/comments', async (c) => {
try {
const settings = await loadCommentSettings(c.env);
@@ -240,6 +243,8 @@ app.post('/admin/comments/import', importComments);
app.put('/admin/comments/status', updateStatus);
app.put('/admin/comments/update', updateComment);
app.get('/admin/stats/comments', getStats);
app.get('/admin/analytics/overview', getVisitOverview);
app.get('/admin/analytics/pages', getVisitPages);
app.get('/admin/settings/email', getAdminEmail);
app.put('/admin/settings/email', setAdminEmail);
app.get('/admin/settings/email-notify', async (c) => {

View File

@@ -10,7 +10,9 @@ Authorization: Bearer <token>
Token 通过登录接口获取,有效期为 24 小时。
## 管理员登录
## 1. 身份认证相关
### 1.1 管理员登录
```
POST /admin/login
@@ -93,7 +95,9 @@ POST /admin/login
}
```
## 获取评论列表
## 2. 评论管理相关
### 2.1 获取评论列表
```
GET /admin/comments/list
@@ -135,6 +139,7 @@ GET /admin/comments/list
"contentText": "很棒的文章!",
"contentHtml": "很棒的文章!",
"status": "approved",
"priority": 2,
"ua": "Mozilla/5.0 ...",
"avatar": "https://gravatar.com/avatar/..."
}
@@ -158,7 +163,7 @@ GET /admin/comments/list
}
```
## 更新评论状态
### 2.2 更新评论状态
```
PUT /admin/comments/status
@@ -166,6 +171,8 @@ PUT /admin/comments/status
更新评论状态(例如通过 / 拒绝)。
**注意**:此接口仅用于更新评论状态,如需修改评论内容、置顶权重等,请使用 `/admin/comments/update` 接口。
- 方法:`PUT`
- 路径:`/admin/comments/status`
- 鉴权需要Bearer Token
@@ -212,7 +219,106 @@ PUT /admin/comments/status
}
```
## 删除指定评论
### 2.3 更新评论内容
```
PUT /admin/comments/update
```
更新评论的详细信息,包括昵称、邮箱、网址、评论地址、内容、状态和置顶权重等。
- 方法:`PUT`
- 路径:`/admin/comments/update`
- 鉴权需要Bearer Token
**请求头**
| 名称 | 必填 | 示例 |
| -------------- | ---- | ------------------ |
| `Content-Type` | 是 | `application/json` |
**请求体**
```json
{
"id": 1,
"name": "张三",
"email": "zhangsan@example.com",
"url": "https://zhangsan.me",
"postSlug": "https://example.com/blog/hello-world",
"contentText": "更新后的评论内容",
"status": "approved",
"priority": 2
}
```
字段说明:
| 字段名 | 类型 | 必填 | 说明 |
| ----------- | ------ | ---- | -------------------------------------- |
| `id` | number | 是 | 评论 ID |
| `name` | string | 是 | 评论者昵称 |
| `email` | string | 是 | 评论者邮箱 |
| `url` | string | 否 | 评论者个人主页或站点地址 |
| `postSlug` | string | 否 | 评论地址(文章 URL不传则保持原值 |
| `contentText` | string | 是 | 评论内容(纯文本) |
| `status` | string | 否 | 评论状态,可选 `approved`、`pending`、`rejected` |
| `priority` | number | 否 | 置顶权重,默认为 1数值越大越靠前 |
**置顶权重说明**
- `1`:不置顶(默认值)
- `2` 或更高:数值越大,该评论在列表中排序越靠前
- 公开 API 获取评论时,会按照 `priority DESC` 进行排序
**成功响应**
- 状态码:`200`
```json
{
"message": "Comment updated, id: 1."
}
```
**错误响应**
- ID 无效:
- 状态码:`400` 或 `404`
```json
{
"message": "Missing or invalid id"
}
```
```json
{
"message": "Comment not found"
}
```
- 必填字段为空:
- 状态码:`400`
```json
{
"message": "昵称不能为空"
}
```
- 更新失败:
- 状态码:`500`
```json
{
"message": "Update failed"
}
```
### 2.4 删除指定评论
```
DELETE /admin/comments/delete
@@ -260,7 +366,142 @@ DELETE /admin/comments/delete
}
```
## 导出所有评论数据
### 2.5 将指定 IP 加入评论黑名单
```
POST /admin/comments/block-ip
```
通过接口将指定 IP 地址加入评论黑名单,后续该 IP 提交评论将被拒绝。
- 方法:`POST`
- 路径:`/admin/comments/block-ip`
- 鉴权需要Bearer Token
**请求头**
| 名称 | 必填 | 示例 |
| -------------- | ---- | ------------------ |
| `Content-Type` | 是 | `application/json` |
**请求体**
```json
{
"ip": "1.1.1.1"
}
```
字段说明:
| 字段名 | 类型 | 必填 | 说明 |
| ------ | ------ | ---- | ------------------------------------------------ |
| `ip` | string | 是 | 要加入黑名单的 IP 地址,多个 IP 用逗号或换行分隔 |
**成功响应**
- 状态码:`200`
```json
{
"message": "已加入 IP 黑名单"
}
```
**错误响应**
- IP 为空:
- 状态码:`400`
```json
{
"message": "IP 地址不能为空"
}
```
- 内部错误:
- 状态码:`500`
```json
{
"message": "操作失败"
}
```
### 2.6 将指定邮箱加入评论黑名单
```
POST /admin/comments/block-email
```
通过接口将指定邮箱地址加入评论黑名单,后续该邮箱提交评论将被拒绝。
- 方法:`POST`
- 路径:`/admin/comments/block-email`
- 鉴权需要Bearer Token
**请求头**
| 名称 | 必填 | 示例 |
| -------------- | ---- | ------------------ |
| `Content-Type` | 是 | `application/json` |
**请求体**
```json
{
"email": "spam@example.com"
}
```
字段说明:
| 字段名 | 类型 | 必填 | 说明 |
| ------- | ------ | ---- | ------------------------------------------------ |
| `email` | string | 是 | 要加入黑名单的邮箱地址,多个邮箱用逗号或换行分隔 |
**成功响应**
- 状态码:`200`
```json
{
"message": "已加入邮箱黑名单"
}
```
**错误响应**
- 邮箱为空:
- 状态码:`400`
```json
{
"message": "邮箱不能为空"
}
```
- 邮箱格式不正确:
- 状态码:`400`
```json
{
"message": "邮箱格式不正确"
}
```
- 内部错误:
- 状态码:`500`
```json
{
"message": "操作失败"
}
```
## 3. 评论数据导入导出
### 3.1 导出所有评论数据
```
GET /admin/comments/export
@@ -309,7 +550,7 @@ GET /admin/comments/export
}
```
## 导入评论数据
### 3.2 导入评论数据
```
POST /admin/comments/import
@@ -377,95 +618,9 @@ POST /admin/comments/import
}
```
## 获取当前通知邮箱配置
## 4. 评论设置相关
```
GET /admin/settings/email
```
获取当前通知邮箱配置。
- 方法:`GET`
- 路径:`/admin/settings/email`
- 鉴权需要Bearer Token
**成功响应**
- 状态码:`200`
```json
{
"email": "admin@example.com"
}
```
**错误响应**
- 状态码:`500`
```json
{
"message": "错误信息"
}
```
## 设置通知邮箱
```
PUT /admin/settings/email
```
设置通知邮箱,用于接收新评论提醒。
- 方法:`PUT`
- 路径:`/admin/settings/email`
- 鉴权需要Bearer Token
**请求头**
| 名称 | 必填 | 示例 |
| -------------- | ---- | ------------------ |
| `Content-Type` | 是 | `application/json` |
**请求体**
```json
{
"email": "admin@example.com"
}
```
**成功响应**
- 状态码:`200`
```json
{
"message": "保存成功"
}
```
**错误响应**
- 邮箱格式不正确:
- 状态码:`400`
```json
{
"message": "邮箱格式不正确"
}
```
- 服务器错误:
- 状态码:`500`
```json
{
"message": "错误信息"
}
```
## 获取评论配置
### 4.1 获取评论配置
```
GET /admin/settings/comments
@@ -500,13 +655,13 @@ GET /admin/settings/comments
| 字段名 | 类型 | 说明 |
| ---------------- | --------------- | --------------------------------------------------------- |
| `adminEmail` | string | 博主邮箱地址,用于显示博主标识以及管理员身份验证 |
| `adminEmail` | string | 博主邮箱地址,用于显示"博主"标识以及管理员身份验证 |
| `adminBadge` | string | 博主标识文字,例如 `"博主"` |
| `avatarPrefix` | string | 头像地址前缀,如 Gravatar 或 Cravatar 镜像地址 |
| `adminEnabled` | boolean | 是否启用博主标识相关展示 |
| `allowedDomains` | Array\<string\> | 允许调用组件的域名列表,留空则不限制 |
| `adminKey` | string\|null | 管理员评论密钥(明文),仅通过管理后台接口返回 |
| `adminKeySet` | boolean | 是否已经设置过管理员评论密钥 |
| `adminKeySet` | boolean | 是否已经设置过管理员管理员评论密钥 |
| `requireReview` | boolean | 是否开启新评论先审核再显示true 表示新评论默认为待审核) |
| `blockedIps` | Array\<string\> | IP 黑名单列表,匹配到的 IP 提交评论将被拒绝 |
| `blockedEmails` | Array\<string\> | 邮箱黑名单列表,匹配到的邮箱提交评论将被拒绝 |
@@ -521,7 +676,7 @@ GET /admin/settings/comments
}
```
## 更新评论配置
### 4.2 更新评论配置
```
PUT /admin/settings/comments
@@ -599,16 +754,51 @@ PUT /admin/settings/comments
}
```
## 将指定 IP 加入评论黑名单
### 4.3 获取通知邮箱配置(已废弃)
```
POST /admin/comments/block-ip
GET /admin/settings/email
```
通过接口将指定 IP 地址加入评论黑名单,后续该 IP 提交评论将被拒绝
获取当前通知邮箱配置
- 方法:`POST`
- 路径:`/admin/comments/block-ip`
> [!NOTE]
> 此接口已被 `/admin/settings/email-notify` 替代,建议使用新接口获取完整的邮件通知配置。
- 方法:`GET`
- 路径:`/admin/settings/email`
- 鉴权需要Bearer Token
**成功响应**
- 状态码:`200`
```json
{
"email": "admin@example.com"
}
```
**错误响应**
- 状态码:`500`
```json
{
"message": "错误信息"
}
```
### 4.4 设置通知邮箱(已废弃)
```
PUT /admin/settings/email
```
设置通知邮箱,用于接收新评论提醒。
- 方法:`PUT`
- 路径:`/admin/settings/email`
- 鉴权需要Bearer Token
**请求头**
@@ -621,99 +811,22 @@ POST /admin/comments/block-ip
```json
{
"ip": "1.1.1.1"
"email": "admin@example.com"
}
```
字段说明:
| 字段名 | 类型 | 必填 | 说明 |
| ------ | ------ | ---- | ------------------------------------------------ |
| `ip` | string | 是 | 要加入黑名单的 IP 地址,多个 IP 用逗号或换行分隔 |
**成功响应**
- 状态码:`200`
```json
{
"message": "已加入 IP 黑名单"
"message": "保存成功"
}
```
**错误响应**
- IP 为空:
- 状态码:`400`
```json
{
"message": "IP 地址不能为空"
}
```
- 内部错误:
- 状态码:`500`
```json
{
"message": "操作失败"
}
```
## 将指定邮箱加入评论黑名单
```
POST /admin/comments/block-email
```
通过接口将指定邮箱地址加入评论黑名单,后续该邮箱提交评论将被拒绝。
- 方法:`POST`
- 路径:`/admin/comments/block-email`
- 鉴权需要Bearer Token
**请求头**
| 名称 | 必填 | 示例 |
| -------------- | ---- | ------------------ |
| `Content-Type` | 是 | `application/json` |
**请求体**
```json
{
"email": "spam@example.com"
}
```
字段说明:
| 字段名 | 类型 | 必填 | 说明 |
| ------- | ------ | ---- | ------------------------------------------------ |
| `email` | string | 是 | 要加入黑名单的邮箱地址,多个邮箱用逗号或换行分隔 |
**成功响应**
- 状态码:`200`
```json
{
"message": "已加入邮箱黑名单"
}
```
**错误响应**
- 邮箱为空:
- 状态码:`400`
```json
{
"message": "邮箱不能为空"
}
```
- 邮箱格式不正确:
- 状态码:`400`
@@ -723,16 +836,250 @@ POST /admin/comments/block-email
}
```
- 内部错误:
- 服务器错误:
- 状态码:`500`
```json
{
"message": "操作失败"
"message": "错误信息"
}
```
## 获取评论统计数据(数据看板)
## 5. 邮件通知配置相关
### 5.1 获取邮件通知配置
```
GET /admin/settings/email-notify
```
获取邮件通知配置,包括 SMTP 配置和邮件模板。
- 方法:`GET`
- 路径:`/admin/settings/email-notify`
- 鉴权需要Bearer Token
**成功响应**
- 状态码:`200`
```json
{
"globalEnabled": true,
"smtp": {
"host": "smtp.qq.com",
"port": 465,
"user": "noreply@qq.com",
"pass": "",
"secure": true
},
"templates": {
"reply": "回复评论的邮件模板 HTML",
"admin": "新评论通知的邮件模板 HTML"
}
}
```
字段说明:
| 字段名 | 类型 | 说明 |
| -------------- | ------- | ------------------------------------------------------------ |
| `globalEnabled` | boolean | 是否全局启用邮件通知 |
| `smtp.host` | string | SMTP 服务器地址,默认 `smtp.qq.com` |
| `smtp.port` | number | SMTP 服务器端口,默认 `465` |
| `smtp.user` | string | SMTP 用户名(发件邮箱) |
| `smtp.pass` | string | SMTP 密码(脱敏显示,不返回完整密码) |
| `smtp.secure` | boolean | 是否使用 SSL/TLS默认 `true` |
| `templates.reply` | string | 回复评论时的邮件模板,支持变量占位符 |
| `templates.admin` | string | 新评论通知的邮件模板,支持变量占位符 |
**邮件模板变量**
回复评论模板(`templates.reply`)支持的变量:
| 变量名 | 说明 |
| ----------------- | ------------ |
| `${toEmail}` | 收件人邮箱 |
| `${toName}` | 收件人昵称 |
| `${postTitle}` | 文章标题 |
| `${parentComment}` | 被回复的评论内容 |
| `${replyAuthor}` | 回复者昵称 |
| `${replyContent}` | 回复内容 |
| `${postUrl}` | 文章链接 |
新评论通知模板(`templates.admin`)支持的变量:
| 变量名 | 说明 |
| ----------------- | ------------ |
| `${postTitle}` | 文章标题 |
| `${postUrl}` | 文章链接 |
| `${commentAuthor}` | 评论者昵称 |
| `${commentContent}` | 评论内容 |
**错误响应**
- 状态码:`500`
```json
{
"message": "错误信息"
}
```
### 5.2 更新邮件通知配置
```
PUT /admin/settings/email-notify
```
更新邮件通知配置,包括 SMTP 配置和邮件模板。
- 方法:`PUT`
- 路径:`/admin/settings/email-notify`
- 鉴权需要Bearer Token
**请求头**
| 名称 | 必填 | 示例 |
| -------------- | ---- | ------------------ |
| `Content-Type` | 是 | `application/json` |
**请求体**
```json
{
"globalEnabled": true,
"smtp": {
"host": "smtp.qq.com",
"port": 465,
"user": "noreply@qq.com",
"pass": "your_password",
"secure": true
},
"templates": {
"reply": "<div>...</div>",
"admin": "<div>...</div>"
}
}
```
字段说明:
| 字段名 | 类型 | 必填 | 说明 |
| -------------- | ------- | ---- | ------------------------------------------------------------ |
| `globalEnabled` | boolean | 否 | 是否全局启用邮件通知 |
| `smtp` | object | 否 | SMTP 配置对象 |
| `smtp.host` | string | 否 | SMTP 服务器地址 |
| `smtp.port` | number | 否 | SMTP 服务器端口 |
| `smtp.user` | string | 否 | SMTP 用户名(发件邮箱) |
| `smtp.pass` | string | 否 | SMTP 密码 |
| `smtp.secure` | boolean | 否 | 是否使用 SSL/TLS |
| `templates` | object | 否 | 邮件模板对象 |
| `templates.reply` | string | 否 | 回复评论的邮件模板 HTML |
| `templates.admin` | string | 否 | 新评论通知的邮件模板 HTML |
**成功响应**
- 状态码:`200`
```json
{
"message": "保存成功"
}
```
**错误响应**
- 状态码:`500`
```json
{
"message": "保存失败"
}
```
### 5.3 测试邮件发送
```
POST /admin/settings/email-test
```
测试邮件通知配置是否正确,发送一封测试邮件到指定邮箱。
- 方法:`POST`
- 路径:`/admin/settings/email-test`
- 鉴权需要Bearer Token
**请求头**
| 名称 | 必填 | 示例 |
| -------------- | ---- | ------------------ |
| `Content-Type` | 是 | `application/json` |
**请求体**
```json
{
"toEmail": "test@example.com",
"smtp": {
"host": "smtp.qq.com",
"port": 465,
"user": "noreply@qq.com",
"pass": "your_password",
"secure": true
}
}
```
字段说明:
| 字段名 | 类型 | 必填 | 说明 |
| ----------- | ------ | ---- | ------------------------------------ |
| `toEmail` | string | 是 | 接收测试邮件的邮箱地址 |
| `smtp` | object | 是 | SMTP 配置对象(与更新配置接口相同) |
**成功响应**
- 状态码:`200`
```json
{
"message": "邮件发送成功"
}
```
**错误响应**
- 接收邮箱无效:
- 状态码:`400`
```json
{
"message": "请输入有效的接收邮箱"
}
```
- SMTP 配置不完整:
- 状态码:`400`
```json
{
"message": "SMTP 配置不完整"
}
```
- 邮件发送失败:
- 状态码:`500`
```json
{
"message": "邮件发送失败: 具体错误信息"
}
```
## 6. 统计数据相关
### 6.1 获取评论统计数据(数据看板)
```
GET /admin/stats/comments

View File

@@ -1,16 +1,18 @@
# 公开 API
无需认证即可访问的公开接口,包括评论获取、评论提交以及评论设置获取
无需认证即可访问的公开接口,包括评论获取、评论提交、配置获取和身份验证
包含路径、方法、参数、请求体和响应示例。
## 获取指定文章的评论列表
## 1. 评论相关
### 1.1 获取指定文章的评论列表
```
GET /api/comments
```
获取指定文章的评论列表。
获取指定文章的评论列表,支持分页和嵌套结构
- 方法:`GET`
- 路径:`/api/comments`
@@ -42,6 +44,7 @@ GET /api/comments
"pubDate": "2026-01-13T10:00:00Z",
"postSlug": "/blog/hello-world",
"avatar": "https://gravatar.com/avatar/...",
"priority": 2,
"replies": [
{
"id": 2,
@@ -54,7 +57,8 @@ GET /api/comments
"postSlug": "/blog/hello-world",
"avatar": "https://gravatar.com/avatar/...",
"parentId": 1,
"replyToAuthor": "张三"
"replyToAuthor": "张三",
"priority": 1
}
]
}
@@ -70,8 +74,9 @@ GET /api/comments
说明:
-`nested=true`(默认)时,接口返回的是根评论列表,每条根评论包含其 `replies`
-`nested=true`(默认)时,接口返回的是"根评论列表",每条根评论包含其 `replies`
-`nested=false` 时,接口返回扁平列表,所有评论都在 `data` 中,`replies` 为空。
- `priority` 字段:评论的置顶权重,数值越大排序越靠前。
**错误响应**
@@ -95,7 +100,7 @@ GET /api/comments
}
```
## 提交新评论或回复
### 1.2 提交新评论或回复
```
POST /api/comments
@@ -124,11 +129,12 @@ POST /api/comments
"email": "zhangsan@example.com",
"url": "https://zhangsan.me",
"content": "很棒的文章!",
"parent_id": 1
"parent_id": 1,
"adminToken": "your-admin-key"
}
```
**字段说明**
字段说明
| 字段名 | 类型 | 必填 | 说明 |
| ------------ | ------ | ---- | ------------------------------------------------------------------------------------------------------------- |
@@ -140,20 +146,31 @@ POST /api/comments
| `url` | string | 否 | 评论者个人主页或站点地址 |
| `content` | string | 是 | 评论内容,内部会过滤 `<script>...</script>` 片段 |
| `parent_id` | number | 否 | 父评论 ID用于回复功能缺省或 `null` 表示根评论 |
| `adminToken` | string | 否 | 管理员评论密钥,博主发布评论时需要先通过 `/api/verify-admin` 验证密钥后将密钥传入此字段,评论将直接通过且不受审核设置影响 |
**成功响应**
- 状态码:`200`
评论直接通过时:
```json
{
"message": "Comment submitted. Awaiting moderation."
"message": "评论已提交",
"status": "approved"
}
```
当前实现中评论会直接以 `approved` 状态写入数据库,后续如引入人工审核可在实现中调整为“待审核状态
评论进入待审核状态时(开启"先审核再显示"且非管理员评论):
**错误响应**与限流
```json
{
"message": "已提交评论,待管理员审核后显示",
"status": "pending"
}
```
**错误响应**
- 请求体缺失或字段类型错误:
@@ -165,7 +182,7 @@ POST /api/comments
}
```
- 缺少必填字段示例
- 缺少必填字段:
- `post_slug` 为空:
@@ -207,6 +224,56 @@ POST /api/comments
}
```
- IP 或邮箱被限制:
- IP 被限制:
- 状态码:`403`
```json
{
"message": "当前 IP 已被限制评论,请联系站长进行处理"
}
```
- 邮箱被限制:
- 状态码:`403`
```json
{
"message": "当前邮箱已被限制评论,请联系站长进行处理"
}
```
- 管理员评论验证失败:
- 未输入密钥:
- 状态码:`401`
```json
{
"message": "请输入管理员密钥",
"requireAuth": true
}
```
- 密钥错误:
- 状态码:`401`
```json
{
"message": "密钥错误"
}
```
- 验证失败次数过多:
- 状态码:`403`
```json
{
"message": "验证失败次数过多请30分钟后再试"
}
```
- 评论频率限制:
- 状态码:`429`
@@ -214,7 +281,7 @@ POST /api/comments
```json
{
"message": "评论频繁,等 10s 后再试"
"message": "评论频繁等10s后再试"
}
```
@@ -228,7 +295,9 @@ POST /api/comments
}
```
## 获取评论相关的公开配置
## 2. 配置相关
### 2.1 获取评论相关的公开配置
```
GET /api/config/comments
@@ -248,7 +317,7 @@ GET /api/config/comments
{
"adminEmail": "admin@example.com",
"adminBadge": "博主",
"avatarPrefix": "https://gravatar.com/avatar",
"avatar": "https://gravatar.com/avatar",
"adminEnabled": true,
"allowedDomains": [],
"requireReview": false
@@ -259,7 +328,7 @@ GET /api/config/comments
| 字段名 | 类型 | 说明 |
| -------------- | ------- | -------------------------------------------------------------------- |
| `adminEmail` | string | 博主邮箱地址,用于在前端展示博主标识,并触发管理员身份验证流程 |
| `adminEmail` | string | 博主邮箱地址,用于在前端展示"博主"标识,并触发管理员身份验证流程 |
| `adminBadge` | string | 博主标识文字,例如 `"博主"` |
| `avatarPrefix` | string | 头像地址前缀,如 Gravatar 或 Cravatar 镜像地址 |
| `adminEnabled` | boolean | 是否启用博主标识相关展示(关闭时不显示徽标,但仍可作为管理员邮箱) |
@@ -275,3 +344,80 @@ GET /api/config/comments
"message": "加载评论配置失败"
}
```
## 3. 身份验证相关
### 3.1 验证管理员密钥
```
POST /api/verify-admin
```
验证前台管理员评论所需的密钥,用于博主发布评论时的身份验证。
- 方法:`POST`
- 路径:`/api/verify-admin`
- 鉴权:不需要
**请求头**
| 名称 | 必填 | 示例 |
| -------------- | ---- | ------------------ |
| `Content-Type` | 是 | `application/json` |
**请求体**
```json
{
"adminToken": "your-admin-key"
}
```
字段说明:
| 字段名 | 类型 | 必填 | 说明 |
| ----------- | ------ | ---- | -------------- |
| `adminToken` | string | 是 | 管理员评论密钥 |
**风控说明**
- 同一 IP 连续验证失败 3 次后,该 IP 将被锁定 30 分钟
- 失败次数记录有效期为 1 小时
**成功响应**
- 状态码:`200`
```json
{
"message": "验证通过"
}
```
或未设置密钥时:
```json
{
"message": "未设置管理员密钥"
}
```
**错误响应**
- 寽钥错误:
- 状态码:`401`
```json
{
"message": "密钥错误"
}
```
- 验证失败次数过多:
- 状态码:`403`
```json
{
"message": "验证失败次数过多请30分钟后再试"
}
```

View File

@@ -18,6 +18,70 @@
开启是否显示博主标签,配置博主邮箱和标签文字,即可在评论中显示博主标签;关闭为不显示。
### 管理员评论密钥
设置管理员评论密钥后,博主在前台发布评论时需要输入此密钥进行身份验证,评论将被直接通过,不受"先审核再显示"设置的影响。
**注意**
- 同一 IP 连续验证失败 3 次后,该 IP 将被锁定 30 分钟
- 失败次数记录有效期为 1 小时
### 评论置顶
在评论管理页面,点击"编辑"按钮可修改评论的置顶权重:
- `1`:不置顶(默认值)
- `2` 或更高:数值越大,该评论在列表中排序越靠前
置顶后的评论会在前台评论列表中优先显示。
### 邮件通知配置
在设置页面可以配置邮件通知功能,包括:
#### 全局开关
控制是否启用邮件通知功能。
#### SMTP 配置
| 字段 | 说明 | 示例 |
| ------- | -------------------------------- | ----------------- |
| 主机 | SMTP 服务器地址 | `smtp.qq.com` |
| 端口 | SMTP 服务器端口 | `465` |
| 用户名 | 发件邮箱 | `noreply@qq.com` |
| 密码 | 邮箱密码或授权码 | `your_password` |
| 安全连接 | 是否使用 SSL/TLS | `true` |
#### 邮件模板
支持自定义 HTML 邮件模板,包含以下变量:
**回复评论模板变量**
| 变量名 | 说明 |
| ----------------- | ------------ |
| `${toEmail}` | 收件人邮箱 |
| `${toName}` | 收件人昵称 |
| `${postTitle}` | 文章标题 |
| `${parentComment}` | 被回复的评论内容 |
| `${replyAuthor}` | 回复者昵称 |
| `${replyContent}` | 回复内容 |
| `${postUrl}` | 文章链接 |
**新评论通知模板变量**
| 变量名 | 说明 |
| ----------------- | ------------ |
| `${postTitle}` | 文章标题 |
| `${postUrl}` | 文章链接 |
| `${commentAuthor}` | 评论者昵称 |
| `${commentContent}` | 评论内容 |
#### 测试邮件
配置完成后,可以使用"测试邮件"功能发送一封测试邮件到指定邮箱,验证配置是否正确。
## 使用官方管理后台
使用官方提供的管理后台最新版本https://cwd.zishu.me