feat(admin): 新增数据备份与恢复功能
添加配置、统计数据和全量备份的导入导出接口 重构数据管理界面,提供分类导出选项 优化导入流程,支持批量操作和错误处理
This commit is contained in:
@@ -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) {
|
||||
|
||||
@@ -653,6 +653,7 @@ watch(domainFilter, () => {
|
||||
|
||||
.domain-cell-pv {
|
||||
text-align: center;
|
||||
flex: 0 0 220px;
|
||||
}
|
||||
|
||||
.domain-cell-rank {
|
||||
|
||||
@@ -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>
|
||||
|
||||
29
cwd-api/src/api/admin/exportBackup.ts
Normal file
29
cwd-api/src/api/admin/exportBackup.ts
Normal 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);
|
||||
}
|
||||
};
|
||||
20
cwd-api/src/api/admin/exportConfig.ts
Normal file
20
cwd-api/src/api/admin/exportConfig.ts
Normal 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);
|
||||
}
|
||||
};
|
||||
52
cwd-api/src/api/admin/exportStats.ts
Normal file
52
cwd-api/src/api/admin/exportStats.ts
Normal 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);
|
||||
}
|
||||
};
|
||||
108
cwd-api/src/api/admin/importBackup.ts
Normal file
108
cwd-api/src/api/admin/importBackup.ts
Normal 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);
|
||||
}
|
||||
};
|
||||
50
cwd-api/src/api/admin/importConfig.ts
Normal file
50
cwd-api/src/api/admin/importConfig.ts
Normal 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);
|
||||
}
|
||||
};
|
||||
125
cwd-api/src/api/admin/importStats.ts
Normal file
125
cwd-api/src/api/admin/importStats.ts
Normal 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);
|
||||
}
|
||||
};
|
||||
@@ -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);
|
||||
|
||||
Reference in New Issue
Block a user