feat(admin): 新增数据备份与恢复功能

添加配置、统计数据和全量备份的导入导出接口
重构数据管理界面,提供分类导出选项
优化导入流程,支持批量操作和错误处理
This commit is contained in:
anghunk
2026-01-29 20:22:40 +08:00
parent 534ea65f41
commit d126e2c4c3
10 changed files with 669 additions and 354 deletions

View File

@@ -254,6 +254,30 @@ export function importComments(data: any[]): Promise<{ message: string }> {
return post<{ message: string }>('/admin/comments/import', data);
}
export function exportConfig(): Promise<any[]> {
return get<any[]>('/admin/export/config');
}
export function importConfig(data: any[]): Promise<{ message: string }> {
return post<{ message: string }>('/admin/import/config', data);
}
export function exportStats(): Promise<any> {
return get<any>('/admin/export/stats');
}
export function importStats(data: any): Promise<{ message: string }> {
return post<{ message: string }>('/admin/import/stats', data);
}
export function exportBackup(): Promise<any> {
return get<any>('/admin/export/backup');
}
export function importBackup(data: any): Promise<{ message: string }> {
return post<{ message: string }>('/admin/import/backup', data);
}
export function fetchCommentStats(domain?: string): Promise<CommentStatsResponse> {
const searchParams = new URLSearchParams();
if (domain) {

View File

@@ -653,6 +653,7 @@ watch(domainFilter, () => {
.domain-cell-pv {
text-align: center;
flex: 0 0 220px;
}
.domain-cell-rank {

View File

@@ -9,57 +9,78 @@
{{ toastMessage }}
</div>
<!-- 1. 评论数据 -->
<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">
<h3 class="card-title">评论数据</h3>
<p class="card-desc">管理评论内容支持从 Twikoo / Artalk 迁移数据</p>
<div class="action-row">
<span class="action-label">导出:</span>
<button class="card-button secondary" :disabled="exporting" @click="handleExportComments">
<span v-if="exporting">导出中...</span>
<span v-else>导出 JSON</span>
</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">
<div class="action-row">
<span class="action-label">导入:</span>
<select v-model="importSource" class="form-select" style="margin-right: 8px; width: 140px;">
<option value="twikoo">Twikoo (.json)</option>
<option value="artalk">Artalk (.json)</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 class="card-button secondary" :disabled="importing" @click="triggerFileInput('comments')">
导入评论
</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>
<!-- 2. 系统配置 -->
<div class="card">
<h3 class="card-title">其他</h3>
<p class="card-desc">访问明细后台配置等数据导入导出正在开发中...</p>
<h3 class="card-title">系统配置</h3>
<p class="card-desc">管理后台设置邮件配置黑名单等</p>
<div class="action-row">
<button class="card-button secondary" :disabled="exporting" @click="handleExportConfig">导出配置</button>
<button class="card-button secondary" :disabled="importing" @click="triggerFileInput('config')">导入配置</button>
</div>
</div>
<!-- 3. 访问统计 -->
<div class="card">
<h3 class="card-title">访问统计</h3>
<p class="card-desc">管理文章访问量点赞数及每日访问趋势</p>
<div class="action-row">
<button class="card-button secondary" :disabled="exporting" @click="handleExportStats">导出统计</button>
<button class="card-button secondary" :disabled="importing" @click="triggerFileInput('stats')">导入统计</button>
</div>
</div>
<!-- 4. 全量备份 -->
<div class="card">
<h3 class="card-title">全量备份</h3>
<p class="card-desc">一键备份或恢复系统所有数据评论 + 配置 + 统计</p>
<div class="action-row">
<button class="card-button secondary" :disabled="exporting" @click="handleExportBackup">全量导出</button>
<button class="card-button secondary" :disabled="importing" @click="triggerFileInput('backup')">全量恢复</button>
</div>
</div>
<!-- 隐藏的文件输入框 -->
<input
type="file"
ref="fileInput"
accept=".json"
style="display: none"
@change="handleFileChange"
/>
<!-- 导入日志 -->
<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>
<!-- 前缀确认弹窗 -->
@@ -93,7 +114,12 @@
<script setup lang="ts">
import { ref } from "vue";
import { exportComments, importComments } from "../api/admin";
import {
exportComments, importComments,
exportConfig, importConfig,
exportStats, importStats,
exportBackup, importBackup
} from "../api/admin";
const exporting = ref(false);
const importing = ref(false);
@@ -104,6 +130,9 @@ const toastType = ref<"success" | "error">("success");
const toastVisible = ref(false);
const importLogs = ref<string[]>([]);
// 当前导入模式: comments | config | stats | backup
const currentImportMode = ref<string>('comments');
// 前缀处理相关状态
const showPrefixModal = ref(false);
const urlPrefix = ref("");
@@ -121,25 +150,20 @@ function showToast(msg: string, type: "success" | "error" = "success") {
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}`);
const timeStr = now.toLocaleTimeString();
importLogs.value.push(`[${timeStr}] ${msg}`);
}
async function handleExport() {
// 通用导出函数
async function executeExport(apiFunc: () => Promise<any>, fileNamePrefix: string) {
exporting.value = true;
try {
const data = await exportComments();
const data = await apiFunc();
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`;
a.download = `${fileNamePrefix}-${new Date().toISOString().split("T")[0]}.json`;
document.body.appendChild(a);
a.click();
window.URL.revokeObjectURL(url);
@@ -152,35 +176,35 @@ async function handleExport() {
}
}
function triggerFileInput() {
fileInput.value?.click();
// 导出处理
const handleExportComments = () => executeExport(exportComments, 'comments-export');
const handleExportConfig = () => executeExport(exportConfig, 'cwd-config');
const handleExportStats = () => executeExport(exportStats, 'cwd-stats');
const handleExportBackup = () => executeExport(exportBackup, 'cwd-full-backup');
// 触发文件选择
function triggerFileInput(mode: string) {
currentImportMode.value = mode;
importLogs.value = []; // 清空日志
if (fileInput.value) {
fileInput.value.value = ''; // 重置 input
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 = [];
showPrefixModal.value = false;
urlPrefix.value = "";
pendingJson.value = [];
importing.value = true;
addLog(`开始导入文件 ${file.name}`);
addLog("正在读取文件...");
addLog(`开始导入: ${file.name} (模式: ${currentImportMode.value})`);
const reader = new FileReader();
reader.onload = async (e) => {
try {
const content = e.target?.result as string;
addLog("文件读取完成,正在解析数据...");
let json;
try {
json = JSON.parse(content);
@@ -188,40 +212,31 @@ async function handleFileChange(event: Event) {
throw new Error("JSON 解析失败,请检查文件格式");
}
const comments = Array.isArray(json) ? json : [json];
const count = comments.length;
addLog(`解析成功,共 ${count} 条数据`);
// 检测前缀逻辑
let missingCount = 0;
for (const item of comments) {
// 优先检查 Twikoo 字段 href其次 Artalk 字段 page_key最后 CWD 字段 post_slug
const url = item.href || item.page_key || item.post_slug;
if (url && typeof url === "string") {
if (!url.startsWith("http://") && !url.startsWith("https://")) {
missingCount++;
}
}
}
if (missingCount > 0) {
addLog(`检测到 ${missingCount} 条评论 URL 缺少前缀,等待确认...`);
missingPrefixCount.value = missingCount;
pendingJson.value = comments;
showPrefixModal.value = true;
// 暂停在这里,等待用户操作 modal
} else {
// 无需处理,直接导入
await executeImport(comments);
addLog("文件解析成功,开始处理...");
switch (currentImportMode.value) {
case 'comments':
await processImportComments(json);
break;
case 'config':
await processImportConfig(json);
break;
case 'stats':
await processImportStats(json);
break;
case 'backup':
await processImportBackup(json);
break;
}
} catch (err: any) {
console.error(err);
addLog(`导入失败:${err.message || "未知错误"}`);
showToast(err.message || "导入失败,文件格式错误", "error");
addLog(`错误: ${err.message}`);
showToast(err.message, "error");
importing.value = false;
}
};
reader.onerror = () => {
addLog("读取文件失败");
showToast("读取文件失败", "error");
@@ -231,185 +246,123 @@ async function handleFileChange(event: Event) {
reader.readAsText(file);
}
// 导入处理逻辑
async function processImportConfig(data: any) {
const res = await importConfig(data);
addLog(res.message);
showToast("配置导入成功");
importing.value = false;
}
async function processImportStats(data: any) {
const res = await importStats(data);
addLog(res.message);
showToast("统计数据导入成功");
importing.value = false;
}
async function processImportBackup(data: any) {
const res = await importBackup(data);
addLog(res.message);
showToast("全量恢复成功");
importing.value = false;
}
async function processImportComments(json: any) {
const comments = Array.isArray(json) ? json : [json];
addLog(`解析到 ${comments.length} 条评论数据`);
// 检查 URL 前缀 (仅针对评论导入)
let missingCount = 0;
for (const item of comments) {
const url = item.href || item.page_key || item.post_slug;
if (url && typeof url === "string") {
if (!url.startsWith("http://") && !url.startsWith("https://")) {
missingCount++;
}
}
}
if (missingCount > 0) {
addLog(`检测到 ${missingCount} 条 URL 缺失前缀,等待用户确认...`);
missingPrefixCount.value = missingCount;
pendingJson.value = comments;
showPrefixModal.value = true;
// 暂停,等待 Modal 操作
} else {
await executeImportComments(comments);
}
}
async function executeImportComments(comments: any[]) {
try {
const res = await importComments(comments);
addLog(`导入完成: ${res.message}`);
showToast("评论导入成功");
} catch (err: any) {
throw err;
} finally {
importing.value = false;
pendingJson.value = [];
}
}
// 前缀确认逻辑
async function confirmPrefix() {
if (!urlPrefix.value) {
showToast("请输入域名前缀", "error");
return;
}
// 处理前缀,确保以 / 结尾或拼接正确
let prefix = urlPrefix.value.trim();
// 简单处理:如果 prefix 不以 / 结尾,且 url 不以 / 开头,可能需要补 /。
// 但通常用户输入的域名可能带也可能不带。
// 这里假设用户输入的 prefix 是 "https://example.com",而 href 是 "/posts/1" 或 "posts/1"
// 为了安全,如果 prefix 结尾没有 /,且 target 不以 / 开头,中间加个 /。
// 但更简单的策略是直接拼,让用户自己负责输入正确的。
// 考虑到用户习惯,我们做个简单的优化:如果 prefix 没 / 且 url 没 /,加一个。
const comments = pendingJson.value.map((item) => {
// 浅拷贝
const newItem = { ...item };
// Twikoo
if (newItem.href && typeof newItem.href === "string") {
if (!newItem.href.startsWith("http://") && !newItem.href.startsWith("https://")) {
newItem.href = joinUrl(prefix, newItem.href);
}
}
// Artalk
if (newItem.page_key && typeof newItem.page_key === "string") {
if (
!newItem.page_key.startsWith("http://") &&
!newItem.page_key.startsWith("https://")
) {
if (!newItem.page_key.startsWith("http://") && !newItem.page_key.startsWith("https://")) {
newItem.page_key = joinUrl(prefix, newItem.page_key);
}
}
// CWD
if (newItem.post_slug && typeof newItem.post_slug === "string") {
if (
!newItem.post_slug.startsWith("http://") &&
!newItem.post_slug.startsWith("https://")
) {
if (!newItem.post_slug.startsWith("http://") && !newItem.post_slug.startsWith("https://")) {
newItem.post_slug = joinUrl(prefix, newItem.post_slug);
}
}
return newItem;
});
showPrefixModal.value = false;
addLog(`${missingPrefixCount.value} 条数据添加前缀`);
await executeImport(comments);
}
function joinUrl(prefix: string, path: string): string {
if (prefix.endsWith("/") && path.startsWith("/")) {
return prefix + path.substring(1);
}
if (!prefix.endsWith("/") && !path.startsWith("/")) {
return prefix + "/" + path;
}
return prefix + path;
addLog(`已添加前缀,继续导入...`);
await executeImportComments(comments);
}
function cancelPrefix() {
showPrefixModal.value = false;
addLog("用户选择跳过添加前缀,直接导入");
executeImport(pendingJson.value);
addLog("用户跳过前缀添加");
executeImportComments(pendingJson.value);
}
async function executeImport(comments: any[]) {
addLog("正在上传并导入数据库...");
try {
const res = await importComments(comments);
addLog(`导入完成:${res.message || "成功"}`);
showToast(res.message || "导入成功", "success");
} catch (err: any) {
console.error(err);
addLog(`导入失败:${err.message || "未知错误"}`);
showToast(err.message || "导入失败", "error");
} finally {
importing.value = false;
pendingJson.value = [];
}
function joinUrl(prefix: string, path: string): string {
if (prefix.endsWith("/") && path.startsWith("/")) return prefix + path.substring(1);
if (!prefix.endsWith("/") && !path.startsWith("/")) return prefix + "/" + path;
return prefix + path;
}
</script>
<style scoped>
/* ... (existing styles) ... */
.form-input {
padding: 8px;
border: 1px solid #d0d7de;
border-radius: 4px;
font-size: 14px;
width: 100%;
box-sizing: border-box;
}
.form-input:focus {
border-color: #0969da;
outline: none;
box-shadow: 0 0 0 2px rgba(9, 105, 218, 0.3);
}
/* Modal Styles */
.modal-overlay {
position: fixed;
top: 0;
left: 0;
right: 0;
bottom: 0;
background-color: rgba(0, 0, 0, 0.5);
display: flex;
align-items: center;
justify-content: center;
z-index: 2000;
}
.modal {
background-color: white;
border-radius: 6px;
box-shadow: 0 8px 24px rgba(0, 0, 0, 0.2);
width: 400px;
max-width: 90%;
padding: 20px;
display: flex;
flex-direction: column;
gap: 16px;
}
.modal-title {
margin: 0;
font-size: 16px;
font-weight: 600;
color: #24292f;
}
.modal-desc {
margin: 0;
font-size: 14px;
color: #57606a;
line-height: 1.5;
}
.modal-actions {
display: flex;
justify-content: flex-end;
gap: 10px;
margin-top: 8px;
}
.modal-btn {
padding: 8px 16px;
border-radius: 4px;
font-size: 14px;
cursor: pointer;
border: 1px solid transparent;
}
.modal-btn.primary {
background-color: #0969da;
color: white;
}
.modal-btn.secondary {
background-color: #f6f8fa;
border-color: #d0d7de;
color: #24292f;
}
.modal-btn:hover {
opacity: 0.9;
}
.page {
display: flex;
flex-direction: column;
gap: 12px;
max-width: 520px;
max-width: 600px; /* Increased width slightly */
}
.page-title {
@@ -423,160 +376,94 @@ async function executeImport(comments: any[]) {
border-radius: 6px;
border: 1px solid var(--border-color);
padding: 16px 18px;
margin-bottom: 1em;
margin-bottom: 0.5em; /* Reduced spacing */
}
.card-title {
margin: 0 0 12px;
margin: 0 0 8px;
font-size: 15px;
color: var(--text-primary);
}
.stats-grid {
display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr));
gap: 12px;
}
.stats-item {
padding: 10px 12px;
border-radius: 6px;
background-color: var(--bg-sider);
border: 1px solid var(--border-color);
}
.stats-label {
font-size: 12px;
color: var(--text-secondary);
margin-bottom: 4px;
}
.stats-value {
font-size: 18px;
font-weight: 600;
color: var(--text-primary);
}
.stats-value-approved {
color: var(--color-success);
}
.stats-value-pending {
color: var(--color-warning);
}
.stats-value-rejected {
color: var(--color-danger);
}
.domain-table {
border: 1px solid var(--border-color);
border-radius: 6px;
overflow: hidden;
}
.domain-table-header {
display: flex;
background-color: var(--bg-sider);
color: var(--text-secondary);
}
.domain-table-row {
display: flex;
border-top: 1px solid var(--border-color);
}
.domain-cell {
flex: 1;
padding: 8px 10px;
font-size: 13px;
color: var(--text-primary);
box-sizing: border-box;
}
.domain-cell-domain {
flex: 2;
font-weight: 500;
}
.card-desc {
font-size: 14px;
font-size: 13px;
color: var(--text-secondary);
margin: 0 0 16px;
}
.card-actions {
.action-row {
display: flex;
justify-content: flex-end;
align-items: center;
gap: 12px;
margin-top: 8px;
}
.action-label {
font-size: 13px;
color: var(--text-primary);
min-width: 40px;
}
.card-button {
padding: 8px 14px;
padding: 6px 14px;
border-radius: 4px;
border: none;
border: 1px solid transparent;
background-color: var(--primary-color);
color: var(--text-inverse);
font-size: 14px;
font-size: 13px;
cursor: pointer;
min-width: 100px;
transition: all 0.2s;
}
.card-button:hover {
opacity: 0.9;
}
.card-button:disabled {
opacity: 0.7;
cursor: default;
opacity: 0.6;
cursor: not-allowed;
}
.form-group {
margin-bottom: 16px;
display: flex;
flex-direction: column;
gap: 8px;
}
.form-label {
font-size: 14px;
.card-button.secondary {
background-color: var(--bg-input);
border-color: var(--border-color);
color: var(--text-primary);
font-weight: 500;
}
.card-button.secondary:hover {
background-color: var(--bg-sider);
}
.card-button.success {
background-color: var(--color-success);
}
.form-select {
padding: 6px 8px;
border: 1px solid var(--border-color);
border-radius: 4px;
font-size: 14px;
font-size: 13px;
color: var(--text-primary);
background-color: var(--bg-input);
outline: none;
}
.form-select:focus {
border-color: var(--primary-color);
box-shadow: 0 0 0 2px rgba(9, 105, 218, 0.3);
}
/* Toast & Log styles same as before */
.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: var(--shadow-card);
z-index: 1000;
}
.toast-success {
background-color: var(--color-success);
color: var(--text-inverse);
}
.toast-error {
background-color: var(--color-danger);
color: var(--text-inverse);
}
.toast-success { background-color: var(--color-success); color: var(--text-inverse); }
.toast-error { background-color: var(--color-danger); color: var(--text-inverse); }
.log-container {
margin-top: 16px;
@@ -585,27 +472,28 @@ async function executeImport(comments: any[]) {
border-radius: 6px;
border: 1px solid var(--border-color);
}
.log-title { font-size: 13px; font-weight: 600; 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: monospace; color: var(--text-secondary); }
.log-title {
font-size: 13px;
font-weight: 600;
color: var(--text-primary);
margin-bottom: 8px;
/* Modal Styles */
.modal-overlay {
position: fixed; top: 0; left: 0; right: 0; bottom: 0;
background-color: rgba(0, 0, 0, 0.5);
display: flex; align-items: center; justify-content: center; z-index: 2000;
}
.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: var(--text-secondary);
line-height: 1.5;
.modal {
background-color: var(--bg-card);
border-radius: 6px;
width: 400px; max-width: 90%;
padding: 20px;
display: flex; flex-direction: column; gap: 16px;
}
.modal-title { margin: 0; font-size: 16px; font-weight: 600; }
.modal-desc { margin: 0; font-size: 14px; color: var(--text-secondary); line-height: 1.5; }
.modal-actions { display: flex; justify-content: flex-end; gap: 10px; }
.modal-btn { padding: 8px 16px; border-radius: 4px; font-size: 14px; cursor: pointer; border: none; }
.modal-btn.primary { background-color: var(--primary-color); color: white; }
.modal-btn.secondary { background-color: var(--bg-sider); color: var(--text-primary); border: 1px solid var(--border-color); }
.form-input { padding: 8px; border: 1px solid var(--border-color); border-radius: 4px; width: 100%; box-sizing: border-box; }
</style>

View File

@@ -0,0 +1,29 @@
import { Context } from 'hono';
import { Bindings } from '../../bindings';
import { getConfigs } from './exportConfig';
import { getStatsData } from './exportStats';
export const exportBackup = async (c: Context<{ Bindings: Bindings }>) => {
try {
const { results: comments } = await c.env.CWD_DB.prepare(
'SELECT * FROM Comment ORDER BY priority DESC, created DESC'
).all();
const configs = await getConfigs(c.env);
const stats = await getStatsData(c.env);
const backupData = {
version: '1.0',
timestamp: Date.now(),
comments: comments,
settings: configs,
page_stats: stats.page_stats,
page_visit_daily: stats.page_visit_daily,
likes: stats.likes
};
return c.json(backupData);
} catch (e: any) {
return c.json({ message: e.message || '全量导出失败' }, 500);
}
};

View File

@@ -0,0 +1,20 @@
import { Context } from 'hono';
import { Bindings } from '../../bindings';
export const getConfigs = async (env: Bindings) => {
await env.CWD_DB.prepare(
'CREATE TABLE IF NOT EXISTS Settings (key TEXT PRIMARY KEY, value TEXT NOT NULL)'
).run();
const { results } = await env.CWD_DB.prepare('SELECT * FROM Settings').all();
return results;
};
export const exportConfig = async (c: Context<{ Bindings: Bindings }>) => {
try {
const results = await getConfigs(c.env);
return c.json(results);
} catch (e: any) {
return c.json({ message: e.message || '导出配置失败' }, 500);
}
};

View File

@@ -0,0 +1,52 @@
import { Context } from 'hono';
import { Bindings } from '../../bindings';
export const getStatsData = async (env: Bindings) => {
// Ensure tables exist
await env.CWD_DB.exec(`
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_visit_daily (
id INTEGER PRIMARY KEY AUTOINCREMENT,
date TEXT NOT NULL,
domain TEXT,
count INTEGER NOT NULL DEFAULT 0,
created_at INTEGER NOT NULL,
updated_at INTEGER NOT NULL
);
CREATE TABLE IF NOT EXISTS Likes (
id INTEGER PRIMARY KEY AUTOINCREMENT,
page_slug TEXT NOT NULL,
user_id TEXT NOT NULL,
created_at INTEGER NOT NULL,
UNIQUE(page_slug, user_id)
);
`);
const { results: pageStats } = await env.CWD_DB.prepare('SELECT * FROM page_stats').all();
const { results: dailyVisits } = await env.CWD_DB.prepare('SELECT * FROM page_visit_daily').all();
const { results: likes } = await env.CWD_DB.prepare('SELECT * FROM Likes').all();
return {
page_stats: pageStats,
page_visit_daily: dailyVisits,
likes: likes
};
};
export const exportStats = async (c: Context<{ Bindings: Bindings }>) => {
try {
const data = await getStatsData(c.env);
return c.json(data);
} catch (e: any) {
return c.json({ message: e.message || '导出统计数据失败' }, 500);
}
};

View File

@@ -0,0 +1,108 @@
import { Context } from 'hono';
import { Bindings } from '../../bindings';
import { saveConfigs } from './importConfig';
import { saveStatsData } from './importStats';
const saveComments = async (env: Bindings, comments: any[]) => {
if (!Array.isArray(comments) || comments.length === 0) {
return;
}
const stmts = comments.map((comment: any) => {
const {
id,
created,
post_slug,
name,
email,
url,
ip_address,
device,
os,
browser,
ua,
content_text,
content_html,
parent_id,
status,
likes
} = comment;
const fields = [
'created', 'post_slug', 'name', 'email', 'url',
'ip_address', 'device', 'os', 'browser', 'ua',
'content_text', 'content_html', 'parent_id', 'status', 'likes'
];
const values = [
created || Date.now(),
post_slug || "",
name || "Anonymous",
email || "",
url || null,
ip_address || null,
device || null,
os || null,
browser || null,
ua || null,
content_text || "",
content_html || "",
parent_id || null,
status || "approved",
typeof likes === 'number' && Number.isFinite(likes) && likes >= 0 ? likes : 0
];
if (id !== undefined && id !== null) {
fields.unshift('id');
values.unshift(id);
}
const placeholders = fields.map(() => '?').join(', ');
const sql = `INSERT OR REPLACE INTO Comment (${fields.join(', ')}) VALUES (${placeholders})`;
return env.CWD_DB.prepare(sql).bind(...values);
});
// Batch execute
const BATCH_SIZE = 50;
for (let i = 0; i < stmts.length; i += BATCH_SIZE) {
const batch = stmts.slice(i, i + BATCH_SIZE);
await env.CWD_DB.batch(batch);
}
};
export const importBackup = async (c: Context<{ Bindings: Bindings }>) => {
try {
const body = await c.req.json();
if (!body || typeof body !== 'object') {
return c.json({ message: '数据格式错误' }, 400);
}
let message = '导入结果:';
// 1. Comments
if (Array.isArray(body.comments) && body.comments.length > 0) {
await saveComments(c.env, body.comments);
message += ` 评论 ${body.comments.length} 条;`;
}
// 2. Settings
if (Array.isArray(body.settings) && body.settings.length > 0) {
await saveConfigs(c.env, body.settings);
message += ` 配置 ${body.settings.length} 条;`;
}
// 3. Stats
// Pass the whole body as it expects { page_stats, ... }
await saveStatsData(c.env, body);
const statsCount = (body.page_stats?.length || 0) + (body.page_visit_daily?.length || 0) + (body.likes?.length || 0);
if (statsCount > 0) {
message += ` 统计数据 ${statsCount} 条;`;
}
return c.json({ message });
} catch (e: any) {
console.error(e);
return c.json({ message: e.message || '全量导入失败' }, 500);
}
};

View File

@@ -0,0 +1,50 @@
import { Context } from 'hono';
import { Bindings } from '../../bindings';
export const saveConfigs = async (env: Bindings, configs: any[]) => {
if (!Array.isArray(configs) || configs.length === 0) {
return;
}
await env.CWD_DB.prepare(
'CREATE TABLE IF NOT EXISTS Settings (key TEXT PRIMARY KEY, value TEXT NOT NULL)'
).run();
const stmts = configs.map((item) => {
return env.CWD_DB.prepare(
'REPLACE INTO Settings (key, value) VALUES (?, ?)'
).bind(item.key, item.value);
});
// Batch execute
const BATCH_SIZE = 50;
for (let i = 0; i < stmts.length; i += BATCH_SIZE) {
const batch = stmts.slice(i, i + BATCH_SIZE);
await env.CWD_DB.batch(batch);
}
};
export const importConfig = async (c: Context<{ Bindings: Bindings }>) => {
try {
const body = await c.req.json();
const configs = Array.isArray(body) ? body : [body];
if (configs.length === 0) {
return c.json({ message: '导入数据为空' }, 400);
}
// Validate basic structure
const validConfigs = configs.filter((item: any) => item && item.key && typeof item.value === 'string');
if (validConfigs.length === 0) {
return c.json({ message: '没有有效的配置数据' }, 400);
}
await saveConfigs(c.env, validConfigs);
return c.json({ message: `成功导入 ${validConfigs.length} 条配置` });
} catch (e: any) {
console.error(e);
return c.json({ message: e.message || '导入配置失败' }, 500);
}
};

View File

@@ -0,0 +1,125 @@
import { Context } from 'hono';
import { Bindings } from '../../bindings';
export const saveStatsData = async (env: Bindings, data: any) => {
// Ensure tables exist
await env.CWD_DB.exec(`
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_visit_daily (
id INTEGER PRIMARY KEY AUTOINCREMENT,
date TEXT NOT NULL,
domain TEXT,
count INTEGER NOT NULL DEFAULT 0,
created_at INTEGER NOT NULL,
updated_at INTEGER NOT NULL
);
CREATE TABLE IF NOT EXISTS Likes (
id INTEGER PRIMARY KEY AUTOINCREMENT,
page_slug TEXT NOT NULL,
user_id TEXT NOT NULL,
created_at INTEGER NOT NULL,
UNIQUE(page_slug, user_id)
);
`);
const stmts: any[] = [];
// 1. page_stats
if (Array.isArray(data.page_stats)) {
for (const item of data.page_stats) {
const fields = ['post_slug', 'post_title', 'post_url', 'pv', 'last_visit_at', 'created_at', 'updated_at'];
const values = [
item.post_slug,
item.post_title,
item.post_url,
item.pv,
item.last_visit_at,
item.created_at,
item.updated_at
];
if (item.id) {
fields.unshift('id');
values.unshift(item.id);
}
const placeholders = fields.map(() => '?').join(', ');
stmts.push(
env.CWD_DB.prepare(`INSERT OR REPLACE INTO page_stats (${fields.join(', ')}) VALUES (${placeholders})`).bind(...values)
);
}
}
// 2. page_visit_daily
if (Array.isArray(data.page_visit_daily)) {
for (const item of data.page_visit_daily) {
const fields = ['date', 'domain', 'count', 'created_at', 'updated_at'];
const values = [
item.date,
item.domain,
item.count,
item.created_at,
item.updated_at
];
if (item.id) {
fields.unshift('id');
values.unshift(item.id);
}
const placeholders = fields.map(() => '?').join(', ');
stmts.push(
env.CWD_DB.prepare(`INSERT OR REPLACE INTO page_visit_daily (${fields.join(', ')}) VALUES (${placeholders})`).bind(...values)
);
}
}
// 3. Likes
if (Array.isArray(data.likes)) {
for (const item of data.likes) {
const fields = ['page_slug', 'user_id', 'created_at'];
const values = [
item.page_slug,
item.user_id,
item.created_at
];
if (item.id) {
fields.unshift('id');
values.unshift(item.id);
}
const placeholders = fields.map(() => '?').join(', ');
stmts.push(
env.CWD_DB.prepare(`INSERT OR REPLACE INTO Likes (${fields.join(', ')}) VALUES (${placeholders})`).bind(...values)
);
}
}
// Batch execute
const BATCH_SIZE = 50;
for (let i = 0; i < stmts.length; i += BATCH_SIZE) {
const batch = stmts.slice(i, i + BATCH_SIZE);
await env.CWD_DB.batch(batch);
}
};
export const importStats = async (c: Context<{ Bindings: Bindings }>) => {
try {
const body = await c.req.json();
if (!body || typeof body !== 'object') {
return c.json({ message: '数据格式错误' }, 400);
}
await saveStatsData(c.env, body);
const statsCount = (body.page_stats?.length || 0) + (body.page_visit_daily?.length || 0) + (body.likes?.length || 0);
return c.json({ message: `成功导入 ${statsCount} 条统计数据` });
} catch (e: any) {
console.error(e);
return c.json({ message: e.message || '导入统计数据失败' }, 500);
}
};

View File

@@ -18,6 +18,18 @@ 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 { exportConfig } from './api/admin/exportConfig';
import { importConfig } from './api/admin/importConfig';
import { exportStats } from './api/admin/exportStats';
import { importStats } from './api/admin/importStats';
import { exportBackup } from './api/admin/exportBackup';
import { importBackup } from './api/admin/importBackup';
import { exportConfig } from './api/admin/exportConfig';
import { importConfig } from './api/admin/importConfig';
import { exportStats } from './api/admin/exportStats';
import { importStats } from './api/admin/importStats';
import { exportBackup } from './api/admin/exportBackup';
import { importBackup } from './api/admin/importBackup';
import { updateStatus } from './api/admin/updateStatus';
import { updateComment } from './api/admin/updateComment';
import { getAdminEmail } from './api/admin/getAdminEmail';
@@ -254,6 +266,12 @@ 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.get('/admin/export/config', exportConfig);
app.post('/admin/import/config', importConfig);
app.get('/admin/export/stats', exportStats);
app.post('/admin/import/stats', importStats);
app.get('/admin/export/backup', exportBackup);
app.post('/admin/import/backup', importBackup);
app.put('/admin/comments/status', updateStatus);
app.put('/admin/comments/update', updateComment);
app.get('/admin/stats/comments', getStats);