feat(admin): 添加评论数据看板功能
- 新增数据看板页面,展示评论统计信息 - 添加 echarts 依赖用于数据可视化 - 实现后端统计接口,包括总数、按域名和最近7天数据 - 完善前端统计页面样式和交互 - 更新文档说明和版本更新指南
This commit is contained in:
@@ -12,7 +12,8 @@
|
|||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"vue": "^3.5.13",
|
"vue": "^3.5.13",
|
||||||
"vue-router": "^4.4.5"
|
"vue-router": "^4.4.5",
|
||||||
|
"echarts": "^5.5.0"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@vitejs/plugin-vue": "^5.1.4",
|
"@vitejs/plugin-vue": "^5.1.4",
|
||||||
|
|||||||
@@ -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> {
|
export async function loginAdmin(name: string, password: string): Promise<string> {
|
||||||
const res = await post<AdminLoginResponse>('/admin/login', { name, password });
|
const res = await post<AdminLoginResponse>('/admin/login', { name, password });
|
||||||
const key = res.data.key;
|
const key = res.data.key;
|
||||||
@@ -160,3 +180,7 @@ export function exportComments(): Promise<any[]> {
|
|||||||
export function importComments(data: any[]): Promise<{ message: string }> {
|
export function importComments(data: any[]): Promise<{ message: string }> {
|
||||||
return post<{ message: string }>('/admin/comments/import', data);
|
return post<{ message: string }>('/admin/comments/import', data);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function fetchCommentStats(): Promise<CommentStatsResponse> {
|
||||||
|
return get<CommentStatsResponse>('/admin/stats/comments');
|
||||||
|
}
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ import LayoutView from '../views/LayoutView.vue';
|
|||||||
import CommentsView from '../views/CommentsView.vue';
|
import CommentsView from '../views/CommentsView.vue';
|
||||||
import SettingsView from '../views/SettingsView.vue';
|
import SettingsView from '../views/SettingsView.vue';
|
||||||
import DataView from '../views/DataView.vue';
|
import DataView from '../views/DataView.vue';
|
||||||
|
import StatsView from '../views/StatsView.vue';
|
||||||
|
|
||||||
const routes: RouteRecordRaw[] = [
|
const routes: RouteRecordRaw[] = [
|
||||||
{
|
{
|
||||||
@@ -24,6 +25,11 @@ const routes: RouteRecordRaw[] = [
|
|||||||
name: 'comments',
|
name: 'comments',
|
||||||
component: CommentsView
|
component: CommentsView
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
path: 'stats',
|
||||||
|
name: 'stats',
|
||||||
|
component: StatsView
|
||||||
|
},
|
||||||
{
|
{
|
||||||
path: 'settings',
|
path: 'settings',
|
||||||
name: 'settings',
|
name: 'settings',
|
||||||
@@ -55,4 +61,3 @@ router.beforeEach((to, from, next) => {
|
|||||||
}
|
}
|
||||||
next();
|
next();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -425,6 +425,72 @@ async function executeImport(comments: any[]) {
|
|||||||
font-size: 15px;
|
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 {
|
.card-desc {
|
||||||
font-size: 14px;
|
font-size: 14px;
|
||||||
color: #57606a;
|
color: #57606a;
|
||||||
|
|||||||
@@ -68,6 +68,13 @@
|
|||||||
>
|
>
|
||||||
评论管理
|
评论管理
|
||||||
</li>
|
</li>
|
||||||
|
<li
|
||||||
|
class="menu-item"
|
||||||
|
:class="{ active: isRouteActive('stats') }"
|
||||||
|
@click="goStats"
|
||||||
|
>
|
||||||
|
数据看板
|
||||||
|
</li>
|
||||||
<li
|
<li
|
||||||
class="menu-item"
|
class="menu-item"
|
||||||
:class="{ active: isRouteActive('settings') }"
|
:class="{ active: isRouteActive('settings') }"
|
||||||
@@ -128,6 +135,11 @@ function goComments() {
|
|||||||
closeSider();
|
closeSider();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function goStats() {
|
||||||
|
router.push({ name: "stats" });
|
||||||
|
closeSider();
|
||||||
|
}
|
||||||
|
|
||||||
function goData() {
|
function goData() {
|
||||||
router.push({ name: "data" });
|
router.push({ name: "data" });
|
||||||
closeSider();
|
closeSider();
|
||||||
|
|||||||
339
cwd-admin/src/views/StatsView.vue
Normal file
339
cwd-admin/src/views/StatsView.vue
Normal 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>
|
||||||
133
cwd-api/src/api/admin/getStats.ts
Normal file
133
cwd-api/src/api/admin/getStats.ts
Normal 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);
|
||||||
|
}
|
||||||
|
};
|
||||||
@@ -21,6 +21,7 @@ import { updateStatus } from './api/admin/updateStatus';
|
|||||||
import { getAdminEmail } from './api/admin/getAdminEmail';
|
import { getAdminEmail } from './api/admin/getAdminEmail';
|
||||||
import { setAdminEmail } from './api/admin/setAdminEmail';
|
import { setAdminEmail } from './api/admin/setAdminEmail';
|
||||||
import { testEmail } from './api/admin/testEmail';
|
import { testEmail } from './api/admin/testEmail';
|
||||||
|
import { getStats } from './api/admin/getStats';
|
||||||
|
|
||||||
const app = new Hono<{ Bindings: Bindings }>();
|
const app = new Hono<{ Bindings: Bindings }>();
|
||||||
const VERSION = `v${packageJson.version}`;
|
const VERSION = `v${packageJson.version}`;
|
||||||
@@ -236,6 +237,7 @@ app.get('/admin/comments/list', listComments);
|
|||||||
app.get('/admin/comments/export', exportComments);
|
app.get('/admin/comments/export', exportComments);
|
||||||
app.post('/admin/comments/import', importComments);
|
app.post('/admin/comments/import', importComments);
|
||||||
app.put('/admin/comments/status', updateStatus);
|
app.put('/admin/comments/status', updateStatus);
|
||||||
|
app.get('/admin/stats/comments', getStats);
|
||||||
app.get('/admin/settings/email', getAdminEmail);
|
app.get('/admin/settings/email', getAdminEmail);
|
||||||
app.put('/admin/settings/email', setAdminEmail);
|
app.put('/admin/settings/email', setAdminEmail);
|
||||||
app.get('/admin/settings/email-notify', async (c) => {
|
app.get('/admin/settings/email-notify', async (c) => {
|
||||||
|
|||||||
@@ -2,7 +2,9 @@
|
|||||||
|
|
||||||
重新拉取 GitHub 项目代码
|
重新拉取 GitHub 项目代码
|
||||||
|
|
||||||
## 接口更新
|
## 后端更新
|
||||||
|
|
||||||
|
接口逻辑相关的更新,需要重新部署到 Cloudflare Workers.
|
||||||
|
|
||||||
```
|
```
|
||||||
cd cwd-api
|
cd cwd-api
|
||||||
@@ -12,7 +14,9 @@ npm run deploy
|
|||||||
|
|
||||||
重新部署到 Cloudflare Workers.
|
重新部署到 Cloudflare Workers.
|
||||||
|
|
||||||
## 后台更新
|
## 前端更新
|
||||||
|
|
||||||
|
### 管理后台
|
||||||
|
|
||||||
如果使用官方后台不需要更新。
|
如果使用官方后台不需要更新。
|
||||||
|
|
||||||
@@ -24,7 +28,7 @@ npm run build
|
|||||||
|
|
||||||
将打包后的代码更新到你托管的地方(例如 Cloudflare Pages、GitHub Pages、Netlify 等)。
|
将打包后的代码更新到你托管的地方(例如 Cloudflare Pages、GitHub Pages、Netlify 等)。
|
||||||
|
|
||||||
## 评论端更新
|
### 评论端
|
||||||
|
|
||||||
如果使用官方评论端 js `https://cwd.zishu.me/cwd.js`,不需要更新。
|
如果使用官方评论端 js `https://cwd.zishu.me/cwd.js`,不需要更新。
|
||||||
|
|
||||||
|
|||||||
@@ -37,5 +37,5 @@ features:
|
|||||||
---
|
---
|
||||||
|
|
||||||
<style>
|
<style>
|
||||||
#comments{max-width:1200px;margin:2em auto 0;padding:0 24px;}
|
#comments{max-width:1150px;margin:2em auto 0;}
|
||||||
</style>
|
</style>
|
||||||
|
|||||||
Reference in New Issue
Block a user