feat(admin): 添加评论数据看板功能

- 新增数据看板页面,展示评论统计信息
- 添加 echarts 依赖用于数据可视化
- 实现后端统计接口,包括总数、按域名和最近7天数据
- 完善前端统计页面样式和交互
- 更新文档说明和版本更新指南
This commit is contained in:
anghunk
2026-01-21 13:30:59 +08:00
parent ff3fa138da
commit 943225cc7f
10 changed files with 592 additions and 6 deletions

View File

@@ -12,7 +12,8 @@
},
"dependencies": {
"vue": "^3.5.13",
"vue-router": "^4.4.5"
"vue-router": "^4.4.5",
"echarts": "^5.5.0"
},
"devDependencies": {
"@vitejs/plugin-vue": "^5.1.4",

View File

@@ -62,6 +62,26 @@ export type EmailNotifySettingsResponse = {
};
};
export type CommentStatsResponse = {
summary: {
total: number;
approved: number;
pending: number;
rejected: number;
};
domains: {
domain: string;
total: number;
approved: number;
pending: number;
rejected: number;
}[];
last7Days: {
date: string;
total: number;
}[];
};
export async function loginAdmin(name: string, password: string): Promise<string> {
const res = await post<AdminLoginResponse>('/admin/login', { name, password });
const key = res.data.key;
@@ -160,3 +180,7 @@ export function exportComments(): Promise<any[]> {
export function importComments(data: any[]): Promise<{ message: string }> {
return post<{ message: string }>('/admin/comments/import', data);
}
export function fetchCommentStats(): Promise<CommentStatsResponse> {
return get<CommentStatsResponse>('/admin/stats/comments');
}

View File

@@ -4,6 +4,7 @@ import LayoutView from '../views/LayoutView.vue';
import CommentsView from '../views/CommentsView.vue';
import SettingsView from '../views/SettingsView.vue';
import DataView from '../views/DataView.vue';
import StatsView from '../views/StatsView.vue';
const routes: RouteRecordRaw[] = [
{
@@ -24,6 +25,11 @@ const routes: RouteRecordRaw[] = [
name: 'comments',
component: CommentsView
},
{
path: 'stats',
name: 'stats',
component: StatsView
},
{
path: 'settings',
name: 'settings',
@@ -55,4 +61,3 @@ router.beforeEach((to, from, next) => {
}
next();
});

View File

@@ -425,6 +425,72 @@ async function executeImport(comments: any[]) {
font-size: 15px;
}
.stats-grid {
display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr));
gap: 12px;
}
.stats-item {
padding: 10px 12px;
border-radius: 6px;
background-color: #f6f8fa;
border: 1px solid #d0d7de;
}
.stats-label {
font-size: 12px;
color: #57606a;
margin-bottom: 4px;
}
.stats-value {
font-size: 18px;
font-weight: 600;
color: #24292f;
}
.stats-value-approved {
color: #1a7f37;
}
.stats-value-pending {
color: #9a6700;
}
.stats-value-rejected {
color: #d1242f;
}
.domain-table {
border: 1px solid #d0d7de;
border-radius: 6px;
overflow: hidden;
}
.domain-table-header {
display: flex;
background-color: #f6f8fa;
}
.domain-table-row {
display: flex;
border-top: 1px solid #eaeae0;
}
.domain-cell {
flex: 1;
padding: 8px 10px;
font-size: 13px;
color: #24292f;
box-sizing: border-box;
}
.domain-cell-domain {
flex: 2;
font-weight: 500;
}
.card-desc {
font-size: 14px;
color: #57606a;

View File

@@ -68,6 +68,13 @@
>
评论管理
</li>
<li
class="menu-item"
:class="{ active: isRouteActive('stats') }"
@click="goStats"
>
数据看板
</li>
<li
class="menu-item"
:class="{ active: isRouteActive('settings') }"
@@ -128,6 +135,11 @@ function goComments() {
closeSider();
}
function goStats() {
router.push({ name: "stats" });
closeSider();
}
function goData() {
router.push({ name: "data" });
closeSider();

View File

@@ -0,0 +1,339 @@
<template>
<div class="page">
<h2 class="page-title">数据看板</h2>
<div
v-if="toastVisible"
class="toast"
:class="toastType === 'error' ? 'toast-error' : 'toast-success'"
>
{{ toastMessage }}
</div>
<div class="card">
<h3 class="card-title">最近 7 天评论数趋势</h3>
<div v-if="statsLoading" class="page-hint">加载中...</div>
<div v-else-if="statsError" class="page-error">{{ statsError }}</div>
<div v-else class="chart-wrapper">
<div ref="chartEl" class="chart"></div>
</div>
</div>
<div class="card">
<h3 class="card-title">整体概览</h3>
<div v-if="statsLoading" class="page-hint">加载中...</div>
<div v-else-if="statsError" class="page-error">{{ statsError }}</div>
<div v-else>
<div class="stats-grid">
<div class="stats-item">
<div class="stats-label">总评论数</div>
<div class="stats-value">{{ statsSummary.total }}</div>
</div>
<div class="stats-item">
<div class="stats-label">已通过</div>
<div class="stats-value stats-value-approved">{{ statsSummary.approved }}</div>
</div>
<div class="stats-item">
<div class="stats-label">待审核</div>
<div class="stats-value stats-value-pending">{{ statsSummary.pending }}</div>
</div>
<div class="stats-item">
<div class="stats-label">已拒绝</div>
<div class="stats-value stats-value-rejected">{{ statsSummary.rejected }}</div>
</div>
</div>
</div>
</div>
<div class="card">
<h3 class="card-title">按域名统计</h3>
<div v-if="statsLoading" class="page-hint">加载中...</div>
<div v-else-if="statsError" class="page-error">{{ statsError }}</div>
<div v-else-if="domainStats.length === 0" class="page-hint">暂无评论数据</div>
<div v-else class="domain-table">
<div class="domain-table-header">
<div class="domain-cell domain-cell-domain">域名</div>
<div class="domain-cell">总数</div>
<div class="domain-cell">已通过</div>
<div class="domain-cell">待审核</div>
<div class="domain-cell">已拒绝</div>
</div>
<div v-for="item in domainStats" :key="item.domain" class="domain-table-row">
<div class="domain-cell domain-cell-domain">{{ item.domain }}</div>
<div class="domain-cell">{{ item.total }}</div>
<div class="domain-cell">{{ item.approved }}</div>
<div class="domain-cell">{{ item.pending }}</div>
<div class="domain-cell">{{ item.rejected }}</div>
</div>
</div>
</div>
</div>
</template>
<script setup lang="ts">
import { onMounted, onBeforeUnmount, ref, nextTick } from "vue";
import * as echarts from "echarts";
import { fetchCommentStats } from "../api/admin";
type DomainStat = {
domain: string;
total: number;
approved: number;
pending: number;
rejected: number;
};
const statsLoading = ref(false);
const statsError = ref("");
const statsSummary = ref({
total: 0,
approved: 0,
pending: 0,
rejected: 0,
});
const domainStats = ref<DomainStat[]>([]);
const last7Days = ref<{ date: string; total: number }[]>([]);
const toastMessage = ref("");
const toastType = ref<"success" | "error">("success");
const toastVisible = ref(false);
const chartEl = ref<HTMLDivElement | null>(null);
let chartInstance: echarts.ECharts | null = null;
function showToast(msg: string, type: "success" | "error" = "success") {
toastMessage.value = msg;
toastType.value = type;
toastVisible.value = true;
window.setTimeout(() => {
toastVisible.value = false;
}, 2000);
}
async function loadStats() {
statsLoading.value = true;
statsError.value = "";
try {
const res = await fetchCommentStats();
statsSummary.value = {
total: res.summary.total,
approved: res.summary.approved,
pending: res.summary.pending,
rejected: res.summary.rejected,
};
domainStats.value = res.domains;
last7Days.value = Array.isArray(res.last7Days) ? res.last7Days : [];
} catch (e: any) {
const msg = e.message || "加载统计数据失败";
statsError.value = msg;
showToast(msg, "error");
} finally {
statsLoading.value = false;
await nextTick();
if (!statsError.value && last7Days.value.length > 0) {
renderChart();
} else if (chartInstance) {
chartInstance.clear();
}
}
}
function renderChart() {
const el = chartEl.value;
if (!el) {
return;
}
if (!chartInstance) {
chartInstance = echarts.init(el);
}
const dates = last7Days.value.map((item) => item.date.slice(5));
const values = last7Days.value.map((item) => item.total);
const option: echarts.EChartsOption = {
tooltip: {
trigger: "axis",
},
grid: {
left: 40,
right: 16,
top: 24,
bottom: 32,
},
xAxis: {
type: "category",
data: dates,
boundaryGap: false,
axisTick: {
alignWithLabel: true,
},
},
yAxis: {
type: "value",
minInterval: 1,
},
series: [
{
type: "line",
smooth: true,
data: values,
areaStyle: {},
symbol: "circle",
symbolSize: 6,
},
],
};
chartInstance.setOption(option);
}
function handleResize() {
if (chartInstance) {
chartInstance.resize();
}
}
onMounted(() => {
loadStats();
window.addEventListener("resize", handleResize);
});
onBeforeUnmount(() => {
window.removeEventListener("resize", handleResize);
if (chartInstance) {
chartInstance.dispose();
chartInstance = null;
}
});
</script>
<style scoped>
.page {
display: flex;
flex-direction: column;
gap: 12px;
}
.page-title {
margin: 0;
font-size: 18px;
color: #24292f;
}
.card {
background-color: #ffffff;
border-radius: 6px;
border: 1px solid #d0d7de;
padding: 16px 18px;
}
.card-title {
margin: 0 0 12px;
font-size: 16px;
}
.page-hint {
font-size: 14px;
color: #57606a;
}
.page-error {
font-size: 14px;
color: #d1242f;
}
.stats-grid {
display: grid;
grid-template-columns: repeat(4, minmax(0, 1fr));
gap: 12px;
}
.stats-item {
padding: 10px 12px;
border-radius: 6px;
background-color: #f6f8fa;
border: 1px solid #d0d7de;
}
.stats-label {
font-size: 14px;
color: #57606a;
margin-bottom: 4px;
}
.stats-value {
font-size: 30px;
font-weight: 600;
color: #24292f;
}
.stats-value-approved {
color: #1a7f37;
}
.stats-value-pending {
color: #9a6700;
}
.stats-value-rejected {
color: #d1242f;
}
.domain-table {
border: 1px solid #d0d7de;
border-radius: 6px;
overflow: hidden;
}
.domain-table-header {
display: flex;
background-color: #f6f8fa;
}
.domain-table-row {
display: flex;
border-top: 1px solid #eaeae0;
}
.domain-cell {
flex: 1;
padding: 10px 10px;
font-size: 14px;
color: #24292f;
box-sizing: border-box;
}
.domain-cell-domain {
flex: 2;
font-weight: 500;
}
.chart-wrapper {
margin-top: 4px;
}
.chart {
width: 100%;
height: 260px;
}
.toast {
position: fixed;
top: 20px;
left: 50%;
transform: translateX(-50%);
min-width: 220px;
max-width: 320px;
padding: 10px 14px;
border-radius: 6px;
font-size: 13px;
box-shadow: 0 8px 24px rgba(140, 149, 159, 0.2);
z-index: 1000;
}
.toast-success {
background-color: #1a7f37;
color: #ffffff;
}
.toast-error {
background-color: #d1242f;
color: #ffffff;
}
</style>

View File

@@ -0,0 +1,133 @@
import { Context } from 'hono';
import { Bindings } from '../../bindings';
type StatusCounts = {
total: number;
approved: number;
pending: number;
rejected: number;
};
type DomainCounts = StatusCounts & {
domain: string;
};
function extractDomain(source: string | null | undefined): string | null {
if (!source) {
return null;
}
const value = source.trim();
if (!value) {
return null;
}
if (!/^https?:\/\//i.test(value)) {
return null;
}
try {
const url = new URL(value);
return url.hostname.toLowerCase();
} catch {
return null;
}
}
export const getStats = async (c: Context<{ Bindings: Bindings }>) => {
try {
const summaryRow = await c.env.CWD_DB.prepare(
"SELECT COUNT(*) as total, SUM(CASE WHEN status = 'approved' THEN 1 ELSE 0 END) as approved, SUM(CASE WHEN status = 'pending' THEN 1 ELSE 0 END) as pending, SUM(CASE WHEN status = 'rejected' THEN 1 ELSE 0 END) as rejected FROM Comment"
).first<{
total: number | null;
approved: number | null;
pending: number | null;
rejected: number | null;
}>();
const summary: StatusCounts = {
total: summaryRow?.total || 0,
approved: summaryRow?.approved || 0,
pending: summaryRow?.pending || 0,
rejected: summaryRow?.rejected || 0
};
const { results } = await c.env.CWD_DB.prepare(
'SELECT post_slug, url, status FROM Comment'
).all<{
post_slug: string;
url: string | null;
status: string;
}>();
const domainMap = new Map<string, StatusCounts>();
for (const row of results) {
const domain =
extractDomain(row.post_slug) || extractDomain(row.url) || 'unknown';
let counts = domainMap.get(domain);
if (!counts) {
counts = {
total: 0,
approved: 0,
pending: 0,
rejected: 0
};
domainMap.set(domain, counts);
}
counts.total += 1;
if (row.status === 'approved') {
counts.approved += 1;
} else if (row.status === 'pending') {
counts.pending += 1;
} else if (row.status === 'rejected') {
counts.rejected += 1;
}
}
const domains: DomainCounts[] = Array.from(domainMap.entries())
.map(([domain, counts]) => ({
domain,
total: counts.total,
approved: counts.approved,
pending: counts.pending,
rejected: counts.rejected
}))
.sort((a, b) => b.total - a.total);
const now = Date.now();
const sevenDaysAgo = now - 6 * 24 * 60 * 60 * 1000;
const { results: dailyRows } = await c.env.CWD_DB.prepare(
"SELECT date(created / 1000, 'unixepoch') as day, COUNT(*) as total FROM Comment WHERE created >= ? GROUP BY day ORDER BY day ASC"
)
.bind(sevenDaysAgo)
.all<{ day: string; total: number }>();
const dailyMap = new Map<string, number>();
for (const row of dailyRows) {
if (row && row.day) {
dailyMap.set(row.day, row.total || 0);
}
}
const last7Days: { date: string; total: number }[] = [];
for (let i = 6; i >= 0; i--) {
const d = new Date(now - i * 24 * 60 * 60 * 1000);
const year = d.getUTCFullYear();
const month = String(d.getUTCMonth() + 1).padStart(2, '0');
const day = String(d.getUTCDate()).padStart(2, '0');
const key = `${year}-${month}-${day}`;
last7Days.push({
date: key,
total: dailyMap.get(key) || 0
});
}
return c.json({
summary,
domains,
last7Days
});
} catch (e: any) {
return c.json({ message: e.message || '获取统计数据失败' }, 500);
}
};

View File

@@ -21,6 +21,7 @@ import { updateStatus } from './api/admin/updateStatus';
import { getAdminEmail } from './api/admin/getAdminEmail';
import { setAdminEmail } from './api/admin/setAdminEmail';
import { testEmail } from './api/admin/testEmail';
import { getStats } from './api/admin/getStats';
const app = new Hono<{ Bindings: Bindings }>();
const VERSION = `v${packageJson.version}`;
@@ -236,6 +237,7 @@ 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/stats/comments', getStats);
app.get('/admin/settings/email', getAdminEmail);
app.put('/admin/settings/email', setAdminEmail);
app.get('/admin/settings/email-notify', async (c) => {

View File

@@ -2,7 +2,9 @@
重新拉取 GitHub 项目代码
## 接口更新
## 后端更新
接口逻辑相关的更新,需要重新部署到 Cloudflare Workers.
```
cd cwd-api
@@ -12,7 +14,9 @@ npm run deploy
重新部署到 Cloudflare Workers.
## 后台更新
## 前端更新
### 管理后台
如果使用官方后台不需要更新。
@@ -24,7 +28,7 @@ npm run build
将打包后的代码更新到你托管的地方(例如 Cloudflare Pages、GitHub Pages、Netlify 等)。
## 评论端更新
### 评论端
如果使用官方评论端 js `https://cwd.zishu.me/cwd.js`,不需要更新。

View File

@@ -37,5 +37,5 @@ features:
---
<style>
#comments{max-width:1200px;margin:2em auto 0;padding:0 24px;}
#comments{max-width:1150px;margin:2em auto 0;}
</style>