feat(admin): 添加评论数据导出功能
- 新增数据管理页面和导出功能 - 实现后端导出接口返回所有评论数据 - 更新文档添加导出接口说明
This commit is contained in:
@@ -124,3 +124,8 @@ export function saveCommentSettings(data: {
|
|||||||
}): Promise<{ message: string }> {
|
}): Promise<{ message: string }> {
|
||||||
return put<{ message: string }>('/admin/settings/comments', data);
|
return put<{ message: string }>('/admin/settings/comments', data);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function exportComments(): Promise<any[]> {
|
||||||
|
return get<any[]>('/admin/comments/export');
|
||||||
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ import LoginView from '../views/LoginView.vue';
|
|||||||
import LayoutView from '../views/LayoutView.vue';
|
import LayoutView from '../views/LayoutView.vue';
|
||||||
import CommentsView from '../views/CommentsView.vue';
|
import CommentsView from '../views/CommentsView.vue';
|
||||||
import SettingsView from '../views/SettingsView.vue';
|
import SettingsView from '../views/SettingsView.vue';
|
||||||
|
import DataView from '../views/DataView.vue';
|
||||||
|
|
||||||
const routes: RouteRecordRaw[] = [
|
const routes: RouteRecordRaw[] = [
|
||||||
{
|
{
|
||||||
@@ -27,6 +28,11 @@ const routes: RouteRecordRaw[] = [
|
|||||||
path: 'settings',
|
path: 'settings',
|
||||||
name: 'settings',
|
name: 'settings',
|
||||||
component: SettingsView
|
component: SettingsView
|
||||||
|
},
|
||||||
|
{
|
||||||
|
path: 'data',
|
||||||
|
name: 'data',
|
||||||
|
component: DataView
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
|
|||||||
143
cwd-comments-admin/src/views/DataView.vue
Normal file
143
cwd-comments-admin/src/views/DataView.vue
Normal file
@@ -0,0 +1,143 @@
|
|||||||
|
<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>
|
||||||
|
<p class="card-desc">
|
||||||
|
将所有评论数据导出为 JSON 格式,字段与数据库结构一致。
|
||||||
|
</p>
|
||||||
|
<div class="card-actions">
|
||||||
|
<button class="card-button" :disabled="exporting" @click="handleExport">
|
||||||
|
<span v-if="exporting">导出中...</span>
|
||||||
|
<span v-else>导出 JSON</span>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup lang="ts">
|
||||||
|
import { ref } from "vue";
|
||||||
|
import { exportComments } from "../api/admin";
|
||||||
|
|
||||||
|
const exporting = ref(false);
|
||||||
|
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);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function handleExport() {
|
||||||
|
exporting.value = true;
|
||||||
|
try {
|
||||||
|
const data = await exportComments();
|
||||||
|
const blob = new Blob([JSON.stringify(data, null, 2)], { type: 'application/json' });
|
||||||
|
const url = window.URL.createObjectURL(blob);
|
||||||
|
const a = document.createElement('a');
|
||||||
|
a.href = url;
|
||||||
|
a.download = `comments-export-${new Date().toISOString().split('T')[0]}.json`;
|
||||||
|
document.body.appendChild(a);
|
||||||
|
a.click();
|
||||||
|
window.URL.revokeObjectURL(url);
|
||||||
|
document.body.removeChild(a);
|
||||||
|
showToast("导出成功", "success");
|
||||||
|
} catch (e: any) {
|
||||||
|
showToast(e.message || "导出失败", "error");
|
||||||
|
} finally {
|
||||||
|
exporting.value = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.page {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 12px;
|
||||||
|
max-width: 520px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.page-title {
|
||||||
|
margin: 0;
|
||||||
|
font-size: 18px;
|
||||||
|
color: #24292f;
|
||||||
|
}
|
||||||
|
|
||||||
|
.card {
|
||||||
|
background-color: #ffffff;
|
||||||
|
border-radius: 6px;
|
||||||
|
border: 1px solid #d0d7de;
|
||||||
|
padding: 16px 18px;
|
||||||
|
margin-bottom: 1em;
|
||||||
|
}
|
||||||
|
|
||||||
|
.card-title {
|
||||||
|
margin: 0 0 12px;
|
||||||
|
font-size: 15px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.card-desc {
|
||||||
|
font-size: 14px;
|
||||||
|
color: #57606a;
|
||||||
|
margin: 0 0 16px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.card-actions {
|
||||||
|
display: flex;
|
||||||
|
justify-content: flex-end;
|
||||||
|
}
|
||||||
|
|
||||||
|
.card-button {
|
||||||
|
padding: 8px 14px;
|
||||||
|
border-radius: 4px;
|
||||||
|
border: none;
|
||||||
|
background-color: #0969da;
|
||||||
|
color: #ffffff;
|
||||||
|
font-size: 14px;
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
|
||||||
|
.card-button:disabled {
|
||||||
|
opacity: 0.7;
|
||||||
|
cursor: default;
|
||||||
|
}
|
||||||
|
|
||||||
|
.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;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@@ -30,6 +30,13 @@
|
|||||||
>
|
>
|
||||||
评论管理
|
评论管理
|
||||||
</li>
|
</li>
|
||||||
|
<li
|
||||||
|
class="menu-item"
|
||||||
|
:class="{ active: isRouteActive('data') }"
|
||||||
|
@click="goData"
|
||||||
|
>
|
||||||
|
数据管理
|
||||||
|
</li>
|
||||||
<li
|
<li
|
||||||
class="menu-item"
|
class="menu-item"
|
||||||
:class="{ active: isRouteActive('settings') }"
|
:class="{ active: isRouteActive('settings') }"
|
||||||
@@ -61,6 +68,10 @@ function goComments() {
|
|||||||
router.push({ name: "comments" });
|
router.push({ name: "comments" });
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function goData() {
|
||||||
|
router.push({ name: "data" });
|
||||||
|
}
|
||||||
|
|
||||||
function goSettings() {
|
function goSettings() {
|
||||||
router.push({ name: "settings" });
|
router.push({ name: "settings" });
|
||||||
}
|
}
|
||||||
|
|||||||
14
cwd-comments-api/src/api/admin/exportComments.ts
Normal file
14
cwd-comments-api/src/api/admin/exportComments.ts
Normal file
@@ -0,0 +1,14 @@
|
|||||||
|
import { Context } from 'hono';
|
||||||
|
import { Bindings } from '../../bindings';
|
||||||
|
|
||||||
|
export const exportComments = async (c: Context<{ Bindings: Bindings }>) => {
|
||||||
|
try {
|
||||||
|
const { results } = await c.env.CWD_DB.prepare(
|
||||||
|
'SELECT * FROM Comment ORDER BY pub_date DESC'
|
||||||
|
).all();
|
||||||
|
|
||||||
|
return c.json(results);
|
||||||
|
} catch (e: any) {
|
||||||
|
return c.json({ message: e.message || '导出失败' }, 500);
|
||||||
|
}
|
||||||
|
};
|
||||||
@@ -13,6 +13,7 @@ import { postComment } from './api/public/postComment';
|
|||||||
import { adminLogin } from './api/admin/login';
|
import { adminLogin } from './api/admin/login';
|
||||||
import { deleteComment } from './api/admin/deleteComment';
|
import { deleteComment } from './api/admin/deleteComment';
|
||||||
import { listComments } from './api/admin/listComments';
|
import { listComments } from './api/admin/listComments';
|
||||||
|
import { exportComments } from './api/admin/exportComments';
|
||||||
import { updateStatus } from './api/admin/updateStatus';
|
import { updateStatus } from './api/admin/updateStatus';
|
||||||
import { getAdminEmail } from './api/admin/getAdminEmail';
|
import { getAdminEmail } from './api/admin/getAdminEmail';
|
||||||
import { setAdminEmail } from './api/admin/setAdminEmail';
|
import { setAdminEmail } from './api/admin/setAdminEmail';
|
||||||
@@ -149,6 +150,7 @@ app.post('/admin/login', adminLogin);
|
|||||||
app.use('/admin/*', adminAuth);
|
app.use('/admin/*', adminAuth);
|
||||||
app.delete('/admin/comments/delete', deleteComment);
|
app.delete('/admin/comments/delete', deleteComment);
|
||||||
app.get('/admin/comments/list', listComments);
|
app.get('/admin/comments/list', listComments);
|
||||||
|
app.get('/admin/comments/export', exportComments);
|
||||||
app.put('/admin/comments/status', updateStatus);
|
app.put('/admin/comments/status', updateStatus);
|
||||||
app.get('/admin/settings/email', getAdminEmail);
|
app.get('/admin/settings/email', getAdminEmail);
|
||||||
app.put('/admin/settings/email', setAdminEmail);
|
app.put('/admin/settings/email', setAdminEmail);
|
||||||
|
|||||||
@@ -10,8 +10,6 @@ Authorization: Bearer <token>
|
|||||||
|
|
||||||
Token 通过登录接口获取,有效期为 24 小时。
|
Token 通过登录接口获取,有效期为 24 小时。
|
||||||
|
|
||||||
本节按 OpenAPI 风格描述每个接口的请求 / 响应结构,并补充鉴权和错误码说明。
|
|
||||||
|
|
||||||
## POST /admin/login
|
## POST /admin/login
|
||||||
|
|
||||||
管理员登录,获取后续调用其他管理员接口所需的临时 Token。
|
管理员登录,获取后续调用其他管理员接口所需的临时 Token。
|
||||||
@@ -251,6 +249,52 @@ Token 通过登录接口获取,有效期为 24 小时。
|
|||||||
}
|
}
|
||||||
```
|
```
|
||||||
|
|
||||||
|
## GET /admin/comments/export
|
||||||
|
|
||||||
|
导出所有评论数据,返回格式为 JSON,字段与数据库结构一致。
|
||||||
|
|
||||||
|
- 方法:`GET`
|
||||||
|
- 路径:`/admin/comments/export`
|
||||||
|
- 鉴权:需要(Bearer Token)
|
||||||
|
|
||||||
|
### 成功响应
|
||||||
|
|
||||||
|
- 状态码:`200`
|
||||||
|
|
||||||
|
```json
|
||||||
|
[
|
||||||
|
{
|
||||||
|
"id": 1,
|
||||||
|
"pub_date": "2026-01-13 10:00:00",
|
||||||
|
"post_slug": "/blog/hello-world",
|
||||||
|
"author": "张三",
|
||||||
|
"email": "zhangsan@example.com",
|
||||||
|
"url": "https://zhangsan.me",
|
||||||
|
"ip_address": "127.0.0.1",
|
||||||
|
"device": "Desktop",
|
||||||
|
"os": "Windows 10",
|
||||||
|
"browser": "Chrome 90",
|
||||||
|
"user_agent": "Mozilla/5.0 ...",
|
||||||
|
"content_text": "很棒的文章!",
|
||||||
|
"content_html": "很棒的文章!",
|
||||||
|
"parent_id": null,
|
||||||
|
"status": "approved"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
```
|
||||||
|
|
||||||
|
### 错误响应
|
||||||
|
|
||||||
|
- 导出失败:
|
||||||
|
|
||||||
|
- 状态码:`500`
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"message": "导出失败"
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
## GET /admin/settings/email
|
## GET /admin/settings/email
|
||||||
|
|
||||||
获取当前通知邮箱配置。
|
获取当前通知邮箱配置。
|
||||||
|
|||||||
@@ -2,33 +2,19 @@
|
|||||||
|
|
||||||
## 基础信息
|
## 基础信息
|
||||||
|
|
||||||
- **Base URL**:`https://your-worker.workers.dev` 或你的自定义域名
|
- ** **:`https://your-worker-api.workers.dev` 或你的自定义域名
|
||||||
- **数据格式**:JSON
|
- **数据格式**:JSON
|
||||||
- **字符编码**:UTF-8
|
- **字符编码**:UTF-8
|
||||||
|
|
||||||
所有 API 均为 RESTful 风格,无会话 Cookie,认证全部通过 `Authorization` 请求头完成。
|
所有 API 均为 RESTful 风格,无会话 Cookie,认证全部通过 `Authorization` 请求头完成。
|
||||||
|
|
||||||
## 版本信息
|
## 版本信息
|
||||||
|
部署成功后,直接访问 Base URL,成功时返回 HTML,其中包含类似文案:
|
||||||
当前后端版本号在根路径返回:
|
|
||||||
|
|
||||||
```http
|
|
||||||
GET /
|
|
||||||
```
|
|
||||||
|
|
||||||
成功时返回 HTML,其中包含类似文案:
|
|
||||||
|
|
||||||
```text
|
```text
|
||||||
CWD 评论部署成功,当前版本 v0.0.1
|
CWD 评论部署成功,当前版本 v0.0.1
|
||||||
```
|
```
|
||||||
|
|
||||||
说明:
|
|
||||||
|
|
||||||
- 当前 API 路径未在 URL 中显式区分版本(如 `/v1`),版本号仅通过根路径展示。
|
|
||||||
- 后续若引入不兼容变更,建议通过自定义域名路径前缀或 Worker 路由实现接口版本化,例如:
|
|
||||||
- `https://comments-api.example.com/v1`
|
|
||||||
- `https://api.example.com/comments/v1`
|
|
||||||
|
|
||||||
## 鉴权方式
|
## 鉴权方式
|
||||||
|
|
||||||
公开接口与管理员接口的鉴权要求不同:
|
公开接口与管理员接口的鉴权要求不同:
|
||||||
@@ -49,53 +35,19 @@ Authorization: Bearer <token>
|
|||||||
|
|
||||||
Token 通过登录接口获取,有效期为 24 小时,服务端会在 KV 中存储会话信息并在每次请求时进行校验。
|
Token 通过登录接口获取,有效期为 24 小时,服务端会在 KV 中存储会话信息并在每次请求时进行校验。
|
||||||
|
|
||||||
## 统一字段与约定
|
|
||||||
|
|
||||||
虽然当前实现没有使用统一的 `success` 包装字段,但存在一些通用约定:
|
|
||||||
|
|
||||||
- 错误响应:
|
|
||||||
- 始终包含 `message` 字段,描述错误原因。
|
|
||||||
- HTTP 状态码用于表达错误类型(例如 400/401/403/429/500)。
|
|
||||||
- 列表类响应:
|
|
||||||
- 使用 `data` + `pagination` 结构:
|
|
||||||
|
|
||||||
```json
|
|
||||||
{
|
|
||||||
"data": [ /* 列表数据 */ ],
|
|
||||||
"pagination": {
|
|
||||||
"page": 1,
|
|
||||||
"limit": 20,
|
|
||||||
"total": 5,
|
|
||||||
"totalCount": 100
|
|
||||||
}
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
- 单项结果或配置类响应:
|
|
||||||
- 直接返回对象,例如:
|
|
||||||
|
|
||||||
```json
|
|
||||||
{
|
|
||||||
"email": "admin@example.com"
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
- 操作类响应(创建、更新、删除):
|
|
||||||
- 一般返回 `{ "message": "说明文本" }`。
|
|
||||||
|
|
||||||
## HTTP 状态码
|
## HTTP 状态码
|
||||||
|
|
||||||
常见状态码及含义如下:
|
常见状态码及含义如下:
|
||||||
|
|
||||||
| 状态码 | 说明 | 典型场景 |
|
| 状态码 | 说明 | 典型场景 |
|
||||||
| ------ | -------------------------- | ---------------------------------------------------- |
|
| ------ | ---------------------------- | --------------------------------------------------- |
|
||||||
| 200 | 请求成功 | 正常查询、操作成功 |
|
| 200 | 请求成功 | 正常查询、操作成功 |
|
||||||
| 400 | 请求参数错误 | 缺少必填字段、格式不正确等 |
|
| 400 | 请求参数错误 | 缺少必填字段、格式不正确等 |
|
||||||
| 401 | 未授权 | 未携带 Token 或 Token 失效 |
|
| 401 | 未授权 | 未携带 Token 或 Token 失效 |
|
||||||
| 403 | 禁止访问 | 登录失败次数过多导致 IP 被暂时封禁 |
|
| 403 | 禁止访问 | 登录失败次数过多导致 IP 被暂时封禁 |
|
||||||
| 404 | 资源不存在(当前未显式使用)| 预留给未来可能的资源不存在场景 |
|
| 404 | 资源不存在(当前未显式使用) | 预留给未来可能的资源不存在场景 |
|
||||||
| 429 | 请求过于频繁 | 评论频率超过限制(默认同一 IP 10 秒内只能评论一次) |
|
| 429 | 请求过于频繁 | 评论频率超过限制(默认同一 IP 10 秒内只能评论一次) |
|
||||||
| 500 | 服务器内部错误 | 未捕获异常、数据库错误等 |
|
| 500 | 服务器内部错误 | 未捕获异常、数据库错误等 |
|
||||||
|
|
||||||
具体到每个接口的详细请求 / 响应体和错误码,请参考:
|
具体到每个接口的详细请求 / 响应体和错误码,请参考:
|
||||||
|
|
||||||
|
|||||||
@@ -1,8 +1,8 @@
|
|||||||
# 公开 API
|
# 公开 API
|
||||||
|
|
||||||
本节描述无需认证即可访问的公开接口,包括评论获取、评论提交以及评论设置获取。
|
无需认证即可访问的公开接口,包括评论获取、评论提交以及评论设置获取。
|
||||||
|
|
||||||
遵循 OpenAPI 风格进行组织,包含路径、方法、参数、请求体和响应示例。
|
包含路径、方法、参数、请求体和响应示例。
|
||||||
|
|
||||||
## GET /api/comments
|
## GET /api/comments
|
||||||
|
|
||||||
@@ -109,7 +109,7 @@
|
|||||||
|
|
||||||
```json
|
```json
|
||||||
{
|
{
|
||||||
"post_slug": "/blog/hello-world",
|
"post_slug": "https://example.com/blog/hello-world",
|
||||||
"post_title": "博客标题,可选",
|
"post_title": "博客标题,可选",
|
||||||
"post_url": "https://example.com/blog/hello-world",
|
"post_url": "https://example.com/blog/hello-world",
|
||||||
"author": "张三",
|
"author": "张三",
|
||||||
@@ -122,16 +122,16 @@
|
|||||||
|
|
||||||
#### 字段说明
|
#### 字段说明
|
||||||
|
|
||||||
| 字段名 | 类型 | 必填 | 说明 |
|
| 字段名 | 类型 | 必填 | 说明 |
|
||||||
| ------------ | ------ | ---- | ------------------------------------------------------------ |
|
| ------------ | ------ | ---- | ------------------------------------------------------------------------------------------------------------- |
|
||||||
| `post_slug` | string | 是 | 文章唯一标识符,应与前端组件初始化时的 `postSlug` 值一致 |
|
| `post_slug` | string | 是 | 文章唯一标识符,应与前端组件初始化时的 `postSlug` 值一致,`window.location.origin + window.location.pathname` |
|
||||||
| `post_title` | string | 否 | 文章标题,用于邮件通知内容 |
|
| `post_title` | string | 否 | 文章标题,用于邮件通知内容 |
|
||||||
| `post_url` | string | 否 | 文章 URL,用于邮件通知中的跳转链接 |
|
| `post_url` | string | 否 | 文章 URL,用于邮件通知中的跳转链接 |
|
||||||
| `author` | string | 是 | 评论者昵称 |
|
| `author` | string | 是 | 评论者昵称 |
|
||||||
| `email` | string | 是 | 评论者邮箱,需为合法邮箱格式 |
|
| `email` | string | 是 | 评论者邮箱,需为合法邮箱格式 |
|
||||||
| `url` | string | 否 | 评论者个人主页或站点地址 |
|
| `url` | string | 否 | 评论者个人主页或站点地址 |
|
||||||
| `content` | string | 是 | 评论内容,内部会过滤 `<script>...</script>` 片段 |
|
| `content` | string | 是 | 评论内容,内部会过滤 `<script>...</script>` 片段 |
|
||||||
| `parent_id` | number | 否 | 父评论 ID,用于回复功能;缺省或 `null` 表示根评论 |
|
| `parent_id` | number | 否 | 父评论 ID,用于回复功能;缺省或 `null` 表示根评论 |
|
||||||
|
|
||||||
### 成功响应
|
### 成功响应
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user