349 lines
7.7 KiB
Vue
349 lines
7.7 KiB
Vue
<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: {
|
|
color: new echarts.graphic.LinearGradient(0, 0, 0, 1, [
|
|
{ offset: 0, color: "rgba(56, 189, 248, 0.80)" },
|
|
{ offset: 1, color: "rgba(56, 189, 248, 0.2)" },
|
|
]),
|
|
},
|
|
lineStyle: {
|
|
width: 2,
|
|
color: "#0ea5e9",
|
|
},
|
|
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>
|