feat(admin): 实现评论数据导入功能并优化界面
- 添加导入评论的API接口和前端实现 - 在数据管理页面增加导入功能,支持选择来源和上传JSON文件 - 优化导航菜单和页面标题的命名 - 移除调试用的console.log语句 - 更新相关API文档
This commit is contained in:
@@ -129,3 +129,7 @@ export function exportComments(): Promise<any[]> {
|
||||
return get<any[]>('/admin/comments/export');
|
||||
}
|
||||
|
||||
export function importComments(data: any[]): Promise<{ message: string }> {
|
||||
return post<{ message: string }>('/admin/comments/import', data);
|
||||
}
|
||||
|
||||
|
||||
@@ -12,7 +12,7 @@
|
||||
<div class="card">
|
||||
<h3 class="card-title">数据导出</h3>
|
||||
<p class="card-desc">
|
||||
将所有评论数据导出为 JSON 格式,字段与数据库结构一致。
|
||||
将所有评论数据导出为 JSON 格式。
|
||||
</p>
|
||||
<div class="card-actions">
|
||||
<button class="card-button" :disabled="exporting" @click="handleExport">
|
||||
@@ -21,17 +21,58 @@
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="card">
|
||||
<h3 class="card-title">数据导入</h3>
|
||||
<p class="card-desc">
|
||||
从其他评论系统导入数据。请选择来源并上传 JSON 文件。
|
||||
</p>
|
||||
|
||||
<div class="form-group">
|
||||
<label class="form-label">来源系统</label>
|
||||
<select v-model="importSource" class="form-select">
|
||||
<option value="twikoo">Twikoo</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div class="card-actions">
|
||||
<input
|
||||
type="file"
|
||||
ref="fileInput"
|
||||
accept=".json"
|
||||
style="display: none"
|
||||
@change="handleFileChange"
|
||||
>
|
||||
<button class="card-button" :disabled="importing" @click="triggerFileInput">
|
||||
<span v-if="importing">导入中...</span>
|
||||
<span v-else>选择文件并导入</span>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div v-if="importLogs.length > 0" class="log-container">
|
||||
<div class="log-title">导入日志</div>
|
||||
<div class="log-list">
|
||||
<div v-for="(log, index) in importLogs" :key="index" class="log-item">
|
||||
{{ log }}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref } from "vue";
|
||||
import { exportComments } from "../api/admin";
|
||||
import { exportComments, importComments } from "../api/admin";
|
||||
|
||||
const exporting = ref(false);
|
||||
const importing = ref(false);
|
||||
const importSource = ref("twikoo");
|
||||
const fileInput = ref<HTMLInputElement | null>(null);
|
||||
const toastMessage = ref("");
|
||||
const toastType = ref<"success" | "error">("success");
|
||||
const toastVisible = ref(false);
|
||||
const importLogs = ref<string[]>([]);
|
||||
|
||||
function showToast(msg: string, type: "success" | "error" = "success") {
|
||||
toastMessage.value = msg;
|
||||
@@ -42,6 +83,18 @@ function showToast(msg: string, type: "success" | "error" = "success") {
|
||||
}, 2000);
|
||||
}
|
||||
|
||||
function addLog(msg: string) {
|
||||
const now = new Date();
|
||||
const y = now.getFullYear();
|
||||
const m = String(now.getMonth() + 1).padStart(2, '0');
|
||||
const d = String(now.getDate()).padStart(2, '0');
|
||||
const h = String(now.getHours()).padStart(2, '0');
|
||||
const min = String(now.getMinutes()).padStart(2, '0');
|
||||
const s = String(now.getSeconds()).padStart(2, '0');
|
||||
const timeStr = `${y}.${m}.${d} ${h}:${min}:${s}`;
|
||||
importLogs.value.push(`${timeStr} ${msg}`);
|
||||
}
|
||||
|
||||
async function handleExport() {
|
||||
exporting.value = true;
|
||||
try {
|
||||
@@ -62,6 +115,67 @@ async function handleExport() {
|
||||
exporting.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
function triggerFileInput() {
|
||||
fileInput.value?.click();
|
||||
}
|
||||
|
||||
async function handleFileChange(event: Event) {
|
||||
const target = event.target as HTMLInputElement;
|
||||
const file = target.files?.[0];
|
||||
if (!file) return;
|
||||
|
||||
// 重置 input,允许重复选择同一文件
|
||||
target.value = '';
|
||||
|
||||
// 清空之前的日志
|
||||
importLogs.value = [];
|
||||
|
||||
importing.value = true;
|
||||
addLog(`开始导入文件 ${file.name}`);
|
||||
addLog("正在读取文件...");
|
||||
|
||||
const reader = new FileReader();
|
||||
|
||||
reader.onload = async (e) => {
|
||||
try {
|
||||
const content = e.target?.result as string;
|
||||
addLog("文件读取完成,正在解析数据...");
|
||||
|
||||
let json;
|
||||
try {
|
||||
json = JSON.parse(content);
|
||||
} catch (parseError) {
|
||||
throw new Error("JSON 解析失败,请检查文件格式");
|
||||
}
|
||||
|
||||
const count = Array.isArray(json) ? json.length : 1;
|
||||
addLog(`解析成功,共 ${count} 条数据`);
|
||||
addLog("正在上传并导入数据库...");
|
||||
|
||||
// 这里可以根据 importSource 做一些预处理,目前只有 twikoo/通用
|
||||
// 假设 json 已经是我们要的格式
|
||||
|
||||
const res = await importComments(json);
|
||||
addLog(`导入完成: ${res.message || '成功'}`);
|
||||
showToast(res.message || "导入成功", "success");
|
||||
} catch (err: any) {
|
||||
console.error(err);
|
||||
addLog(`导入失败: ${err.message || '未知错误'}`);
|
||||
showToast(err.message || "导入失败,文件格式错误", "error");
|
||||
} finally {
|
||||
importing.value = false;
|
||||
}
|
||||
};
|
||||
|
||||
reader.onerror = () => {
|
||||
addLog("读取文件失败");
|
||||
showToast("读取文件失败", "error");
|
||||
importing.value = false;
|
||||
};
|
||||
|
||||
reader.readAsText(file);
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
@@ -117,6 +231,34 @@ async function handleExport() {
|
||||
cursor: default;
|
||||
}
|
||||
|
||||
.form-group {
|
||||
margin-bottom: 16px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.form-label {
|
||||
font-size: 14px;
|
||||
color: #24292f;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.form-select {
|
||||
padding: 6px 8px;
|
||||
border: 1px solid #d0d7de;
|
||||
border-radius: 4px;
|
||||
font-size: 14px;
|
||||
color: #24292f;
|
||||
background-color: #f6f8fa;
|
||||
outline: none;
|
||||
}
|
||||
|
||||
.form-select:focus {
|
||||
border-color: #0969da;
|
||||
box-shadow: 0 0 0 2px rgba(9, 105, 218, 0.3);
|
||||
}
|
||||
|
||||
.toast {
|
||||
position: fixed;
|
||||
top: 20px;
|
||||
@@ -140,4 +282,34 @@ async function handleExport() {
|
||||
background-color: #d1242f;
|
||||
color: #ffffff;
|
||||
}
|
||||
|
||||
.log-container {
|
||||
margin-top: 16px;
|
||||
padding: 12px;
|
||||
background-color: #f6f8fa;
|
||||
border-radius: 6px;
|
||||
border: 1px solid #d0d7de;
|
||||
}
|
||||
|
||||
.log-title {
|
||||
font-size: 13px;
|
||||
font-weight: 600;
|
||||
color: #24292f;
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
|
||||
.log-list {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 4px;
|
||||
max-height: 200px;
|
||||
overflow-y: auto;
|
||||
}
|
||||
|
||||
.log-item {
|
||||
font-size: 12px;
|
||||
font-family: ui-monospace, SFMono-Regular, "SF Mono", Menlo, Consolas, "Liberation Mono", monospace;
|
||||
color: #57606a;
|
||||
line-height: 1.5;
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -30,6 +30,13 @@
|
||||
>
|
||||
评论管理
|
||||
</li>
|
||||
<li
|
||||
class="menu-item"
|
||||
:class="{ active: isRouteActive('settings') }"
|
||||
@click="goSettings"
|
||||
>
|
||||
网站设置
|
||||
</li>
|
||||
<li
|
||||
class="menu-item"
|
||||
:class="{ active: isRouteActive('data') }"
|
||||
@@ -37,13 +44,6 @@
|
||||
>
|
||||
数据管理
|
||||
</li>
|
||||
<li
|
||||
class="menu-item"
|
||||
:class="{ active: isRouteActive('settings') }"
|
||||
@click="goSettings"
|
||||
>
|
||||
系统设置
|
||||
</li>
|
||||
</ul>
|
||||
</nav>
|
||||
<main class="layout-content">
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
<template>
|
||||
<div class="page">
|
||||
<h2 class="page-title">系统设置</h2>
|
||||
<h2 class="page-title">网站设置</h2>
|
||||
<div
|
||||
v-if="toastVisible"
|
||||
class="toast"
|
||||
|
||||
87
cwd-comments-api/src/api/admin/importComments.ts
Normal file
87
cwd-comments-api/src/api/admin/importComments.ts
Normal file
@@ -0,0 +1,87 @@
|
||||
import { Context } from 'hono';
|
||||
import { Bindings } from '../../bindings';
|
||||
|
||||
export const importComments = async (c: Context<{ Bindings: Bindings }>) => {
|
||||
try {
|
||||
const body = await c.req.json();
|
||||
const comments = Array.isArray(body) ? body : [body];
|
||||
|
||||
if (comments.length === 0) {
|
||||
return c.json({ message: '导入数据为空' }, 400);
|
||||
}
|
||||
|
||||
// 按 ID 升序排序,防止因外键约束导致插入失败(子评论先于父评论插入)
|
||||
comments.sort((a: any, b: any) => {
|
||||
const idA = a.id || 0;
|
||||
const idB = b.id || 0;
|
||||
return idA - idB;
|
||||
});
|
||||
|
||||
// 简单的验证,确保至少包含必要字段
|
||||
// 实际上 Twikoo 的导出格式可能不同,但用户提供的 JSON 结构与 cwd-comments 几乎一致
|
||||
// 我们假设用户已经转换或者这就是他们想要的格式
|
||||
// 如果字段名不匹配,可能需要做映射。既然用户给出了明确的 JSON 结构,我们直接按照这个结构处理。
|
||||
|
||||
const stmts = comments.map((comment: any) => {
|
||||
const {
|
||||
id,
|
||||
pub_date,
|
||||
post_slug,
|
||||
author,
|
||||
email,
|
||||
url,
|
||||
ip_address,
|
||||
device,
|
||||
os,
|
||||
browser,
|
||||
user_agent,
|
||||
content_text,
|
||||
content_html,
|
||||
parent_id,
|
||||
status
|
||||
} = comment;
|
||||
|
||||
// 如果 id 存在,我们尝试保留它。如果冲突,可能需要处理(这里暂且假设是空库导入或者不冲突)
|
||||
// 如果是导入 Twikoo,通常 Twikoo 没有 id (是 _id ObjectId),或者是其他格式。
|
||||
// 但按照用户给的 JSON,是有 id 的。
|
||||
|
||||
// 构建 SQL。
|
||||
// 能够处理 id 存在或不存在的情况
|
||||
const fields = [
|
||||
'pub_date', 'post_slug', 'author', 'email', 'url',
|
||||
'ip_address', 'device', 'os', 'browser', 'user_agent',
|
||||
'content_text', 'content_html', 'parent_id', 'status'
|
||||
];
|
||||
const values = [
|
||||
pub_date, post_slug, author, email, url,
|
||||
ip_address, device, os, browser, user_agent,
|
||||
content_text, content_html, parent_id, status
|
||||
].map(v => v === undefined ? null : v);
|
||||
|
||||
if (id) {
|
||||
fields.unshift('id');
|
||||
values.unshift(id);
|
||||
}
|
||||
|
||||
const placeholders = fields.map(() => '?').join(', ');
|
||||
const sql = `INSERT OR REPLACE INTO Comment (${fields.join(', ')}) VALUES (${placeholders})`;
|
||||
|
||||
return c.env.CWD_DB.prepare(sql).bind(...values);
|
||||
});
|
||||
|
||||
// D1 的 batch 限制一次最多 100 条? 或者是 body size 限制。
|
||||
// 如果数量很大,需要分批。这里先假设数量不多,直接 batch。
|
||||
// 为了安全起见,可以分批处理,比如每 50 条。
|
||||
|
||||
const BATCH_SIZE = 50;
|
||||
for (let i = 0; i < stmts.length; i += BATCH_SIZE) {
|
||||
const batch = stmts.slice(i, i + BATCH_SIZE);
|
||||
await c.env.CWD_DB.batch(batch);
|
||||
}
|
||||
|
||||
return c.json({ message: `成功导入 ${comments.length} 条评论` });
|
||||
} catch (e: any) {
|
||||
console.error(e);
|
||||
return c.json({ message: e.message || '导入失败' }, 500);
|
||||
}
|
||||
};
|
||||
@@ -14,6 +14,7 @@ import { adminLogin } from './api/admin/login';
|
||||
import { deleteComment } from './api/admin/deleteComment';
|
||||
import { listComments } from './api/admin/listComments';
|
||||
import { exportComments } from './api/admin/exportComments';
|
||||
import { importComments } from './api/admin/importComments';
|
||||
import { updateStatus } from './api/admin/updateStatus';
|
||||
import { getAdminEmail } from './api/admin/getAdminEmail';
|
||||
import { setAdminEmail } from './api/admin/setAdminEmail';
|
||||
@@ -151,6 +152,7 @@ app.use('/admin/*', adminAuth);
|
||||
app.delete('/admin/comments/delete', deleteComment);
|
||||
app.get('/admin/comments/list', listComments);
|
||||
app.get('/admin/comments/export', exportComments);
|
||||
app.post('/admin/comments/import', importComments);
|
||||
app.put('/admin/comments/status', updateStatus);
|
||||
app.get('/admin/settings/email', getAdminEmail);
|
||||
app.put('/admin/settings/email', setAdminEmail);
|
||||
|
||||
@@ -295,6 +295,72 @@ Token 通过登录接口获取,有效期为 24 小时。
|
||||
}
|
||||
```
|
||||
|
||||
## POST /admin/comments/import
|
||||
|
||||
导入评论数据,支持 JSON 格式,可以是单个对象或数组。
|
||||
|
||||
- 方法:`POST`
|
||||
- 路径:`/admin/comments/import`
|
||||
- 鉴权:需要(Bearer Token)
|
||||
|
||||
### 请求体
|
||||
|
||||
```json
|
||||
[
|
||||
{
|
||||
"id": 5,
|
||||
"pub_date": "2026-01-20T04:36:57.636Z",
|
||||
"post_slug": "",
|
||||
"author": "1024605422",
|
||||
"email": "1024605422@qq.com",
|
||||
"url": null,
|
||||
"ip_address": "15.235.156.27",
|
||||
"device": "Desktop",
|
||||
"os": "Windows 10",
|
||||
"browser": "Chrome 143.0.0.0",
|
||||
"user_agent": "Mozilla/5.0 ...",
|
||||
"content_text": "试一下",
|
||||
"content_html": "试一下",
|
||||
"parent_id": null,
|
||||
"status": "approved"
|
||||
}
|
||||
]
|
||||
```
|
||||
|
||||
字段说明:与导出格式一致。如果不提供 `id`,则会自动生成。
|
||||
|
||||
### 成功响应
|
||||
|
||||
- 状态码:`200`
|
||||
|
||||
```json
|
||||
{
|
||||
"message": "成功导入 1 条评论"
|
||||
}
|
||||
```
|
||||
|
||||
### 错误响应
|
||||
|
||||
- 导入数据为空:
|
||||
|
||||
- 状态码:`400`
|
||||
|
||||
```json
|
||||
{
|
||||
"message": "导入数据为空"
|
||||
}
|
||||
```
|
||||
|
||||
- 导入失败:
|
||||
|
||||
- 状态码:`500`
|
||||
|
||||
```json
|
||||
{
|
||||
"message": "导入失败"
|
||||
}
|
||||
```
|
||||
|
||||
## GET /admin/settings/email
|
||||
|
||||
获取当前通知邮箱配置。
|
||||
|
||||
@@ -251,11 +251,8 @@ export class CommentItem extends Component {
|
||||
}
|
||||
|
||||
handleReply() {
|
||||
console.log('[CommentItem] handleReply called, comment.id:', this.props.comment.id);
|
||||
if (this.props.onReply) {
|
||||
this.props.onReply(this.props.comment.id);
|
||||
} else {
|
||||
console.warn('[CommentItem] onReply callback is missing!');
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -41,11 +41,6 @@ export class CommentList extends Component {
|
||||
|
||||
render() {
|
||||
const { comments, loading, error, currentPage, totalPages } = this.props;
|
||||
|
||||
console.log('[CommentList] render called, comments:', comments);
|
||||
console.log('[CommentList] comments.length:', comments?.length);
|
||||
console.log('[CommentList] loading:', loading);
|
||||
|
||||
// 清空容器
|
||||
this.empty(this.container);
|
||||
|
||||
@@ -93,7 +88,6 @@ export class CommentList extends Component {
|
||||
this.commentItems.clear();
|
||||
|
||||
comments.forEach((comment, index) => {
|
||||
console.log(`[CommentList] Rendering comment ${index}:`, comment);
|
||||
const commentItem = new CommentItem(commentsContainer, {
|
||||
comment,
|
||||
replyingTo: this.props.replyingTo,
|
||||
@@ -113,8 +107,6 @@ export class CommentList extends Component {
|
||||
this.commentItems.set(comment.id, commentItem);
|
||||
});
|
||||
|
||||
console.log('[CommentList] Final commentsContainer children count:', commentsContainer.children.length);
|
||||
console.log('[CommentList] Final commentsContainer innerHTML:', commentsContainer.innerHTML);
|
||||
root.appendChild(commentsContainer);
|
||||
} else {
|
||||
// 空状态
|
||||
|
||||
Reference in New Issue
Block a user