diff --git a/cwd-admin/src/api/admin.ts b/cwd-admin/src/api/admin.ts index d037d74..26d869c 100644 --- a/cwd-admin/src/api/admin.ts +++ b/cwd-admin/src/api/admin.ts @@ -254,6 +254,30 @@ export function importComments(data: any[]): Promise<{ message: string }> { return post<{ message: string }>('/admin/comments/import', data); } +export function exportConfig(): Promise { + return get('/admin/export/config'); +} + +export function importConfig(data: any[]): Promise<{ message: string }> { + return post<{ message: string }>('/admin/import/config', data); +} + +export function exportStats(): Promise { + return get('/admin/export/stats'); +} + +export function importStats(data: any): Promise<{ message: string }> { + return post<{ message: string }>('/admin/import/stats', data); +} + +export function exportBackup(): Promise { + return get('/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 { const searchParams = new URLSearchParams(); if (domain) { diff --git a/cwd-admin/src/views/AnalyticsVisitView.vue b/cwd-admin/src/views/AnalyticsVisitView.vue index f29db46..12805c5 100644 --- a/cwd-admin/src/views/AnalyticsVisitView.vue +++ b/cwd-admin/src/views/AnalyticsVisitView.vue @@ -653,6 +653,7 @@ watch(domainFilter, () => { .domain-cell-pv { text-align: center; + flex: 0 0 220px; } .domain-cell-rank { diff --git a/cwd-admin/src/views/DataView.vue b/cwd-admin/src/views/DataView.vue index b606dda..7c1c250 100644 --- a/cwd-admin/src/views/DataView.vue +++ b/cwd-admin/src/views/DataView.vue @@ -9,57 +9,78 @@ {{ toastMessage }} +
-

评论数据导出

-

将所有评论数据导出为 JSON 格式。

-
-
-
-
-

评论数据导入

-

从其他评论系统导入数据,请选择来源并上传 JSON 文件。

- -
- - -
- -
- -
- -
-
导入日志
-
-
- {{ log }} -
-
-
+
-

其他

-

访问明细、后台配置等数据导入导出正在开发中...

- +

系统配置

+

管理后台设置、邮件配置、黑名单等。

+
+ + +
+
+ + +
+

访问统计

+

管理文章访问量、点赞数及每日访问趋势。

+
+ + +
+
+ + +
+

全量备份

+

一键备份或恢复系统所有数据(评论 + 配置 + 统计)。

+
+ + +
+
+ + + + + +
+
操作日志
+
+
+ {{ log }} +
+
@@ -93,7 +114,12 @@ diff --git a/cwd-api/src/api/admin/exportBackup.ts b/cwd-api/src/api/admin/exportBackup.ts new file mode 100644 index 0000000..41208ae --- /dev/null +++ b/cwd-api/src/api/admin/exportBackup.ts @@ -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); + } +}; diff --git a/cwd-api/src/api/admin/exportConfig.ts b/cwd-api/src/api/admin/exportConfig.ts new file mode 100644 index 0000000..0499325 --- /dev/null +++ b/cwd-api/src/api/admin/exportConfig.ts @@ -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); + } +}; diff --git a/cwd-api/src/api/admin/exportStats.ts b/cwd-api/src/api/admin/exportStats.ts new file mode 100644 index 0000000..f8b96ef --- /dev/null +++ b/cwd-api/src/api/admin/exportStats.ts @@ -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); + } +}; diff --git a/cwd-api/src/api/admin/importBackup.ts b/cwd-api/src/api/admin/importBackup.ts new file mode 100644 index 0000000..a167120 --- /dev/null +++ b/cwd-api/src/api/admin/importBackup.ts @@ -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); + } +}; diff --git a/cwd-api/src/api/admin/importConfig.ts b/cwd-api/src/api/admin/importConfig.ts new file mode 100644 index 0000000..eaf93bb --- /dev/null +++ b/cwd-api/src/api/admin/importConfig.ts @@ -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); + } +}; diff --git a/cwd-api/src/api/admin/importStats.ts b/cwd-api/src/api/admin/importStats.ts new file mode 100644 index 0000000..70ee1e9 --- /dev/null +++ b/cwd-api/src/api/admin/importStats.ts @@ -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); + } +}; diff --git a/cwd-api/src/index.ts b/cwd-api/src/index.ts index 119662a..f030d64 100644 --- a/cwd-api/src/index.ts +++ b/cwd-api/src/index.ts @@ -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);