feat(数据分析): 添加域名过滤功能并持久化存储
- 在评论、统计和访问分析页面添加域名过滤功能 - 使用localStorage持久化存储用户选择的域名过滤条件 - 新增30天访问趋势图表展示 - 优化API接口支持按域名过滤数据 - 添加每日访问统计记录功能
This commit is contained in:
@@ -86,6 +86,10 @@ export type CommentStatsResponse = {
|
||||
export type VisitOverviewResponse = {
|
||||
totalPv: number;
|
||||
totalPages: number;
|
||||
last30Days?: {
|
||||
date: string;
|
||||
total: number;
|
||||
}[];
|
||||
};
|
||||
|
||||
export type VisitPageItem = {
|
||||
@@ -226,14 +230,32 @@ 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');
|
||||
export function fetchCommentStats(domain?: string): Promise<CommentStatsResponse> {
|
||||
const searchParams = new URLSearchParams();
|
||||
if (domain) {
|
||||
searchParams.set('domain', domain);
|
||||
}
|
||||
const query = searchParams.toString();
|
||||
const url = query ? `/admin/stats/comments?${query}` : '/admin/stats/comments';
|
||||
return get<CommentStatsResponse>(url);
|
||||
}
|
||||
|
||||
export function fetchVisitOverview(): Promise<VisitOverviewResponse> {
|
||||
return get<VisitOverviewResponse>('/admin/analytics/overview');
|
||||
export function fetchVisitOverview(domain?: string): Promise<VisitOverviewResponse> {
|
||||
const searchParams = new URLSearchParams();
|
||||
if (domain) {
|
||||
searchParams.set('domain', domain);
|
||||
}
|
||||
const query = searchParams.toString();
|
||||
const url = query ? `/admin/analytics/overview?${query}` : '/admin/analytics/overview';
|
||||
return get<VisitOverviewResponse>(url);
|
||||
}
|
||||
|
||||
export function fetchVisitPages(): Promise<VisitPagesResponse> {
|
||||
return get<VisitPagesResponse>('/admin/analytics/pages');
|
||||
export function fetchVisitPages(domain?: string): Promise<VisitPagesResponse> {
|
||||
const searchParams = new URLSearchParams();
|
||||
if (domain) {
|
||||
searchParams.set('domain', domain);
|
||||
}
|
||||
const query = searchParams.toString();
|
||||
const url = query ? `/admin/analytics/pages?${query}` : '/admin/analytics/pages';
|
||||
return get<VisitPagesResponse>(url);
|
||||
}
|
||||
|
||||
@@ -1,6 +1,18 @@
|
||||
<template>
|
||||
<div class="page">
|
||||
<h2 class="page-title">访问统计</h2>
|
||||
<div style="display: flex; align-items: center; gap: 20px">
|
||||
<h2 class="page-title">访问统计</h2>
|
||||
<div class="toolbar">
|
||||
<div class="toolbar-left">
|
||||
<select v-model="domainFilter" class="toolbar-select">
|
||||
<option value="">全部域名</option>
|
||||
<option v-for="item in domainOptions" :key="item" :value="item">
|
||||
{{ item }}
|
||||
</option>
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div
|
||||
v-if="toastVisible"
|
||||
class="toast"
|
||||
@@ -27,6 +39,15 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="card">
|
||||
<h3 class="card-title">最近 30 天访问趋势</h3>
|
||||
<div v-if="loading" class="page-hint">加载中...</div>
|
||||
<div v-else-if="error" class="page-error">{{ error }}</div>
|
||||
<div v-else class="chart-wrapper">
|
||||
<div ref="chartEl" class="chart"></div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="card">
|
||||
<h3 class="card-title">页面访问明细(按 PV 排序)</h3>
|
||||
<div v-if="loading" class="page-hint">加载中...</div>
|
||||
@@ -39,11 +60,7 @@
|
||||
<div class="domain-cell">最后访问时间</div>
|
||||
<div class="domain-cell">页面地址</div>
|
||||
</div>
|
||||
<div
|
||||
v-for="item in items"
|
||||
:key="item.postSlug"
|
||||
class="domain-table-row"
|
||||
>
|
||||
<div v-for="item in items" :key="item.postSlug" class="domain-table-row">
|
||||
<div class="domain-cell domain-cell-domain">
|
||||
{{ item.postTitle || item.postSlug }}
|
||||
</div>
|
||||
@@ -54,12 +71,7 @@
|
||||
{{ formatTime(item.lastVisitAt) }}
|
||||
</div>
|
||||
<div class="domain-cell">
|
||||
<a
|
||||
v-if="item.postUrl"
|
||||
:href="item.postUrl"
|
||||
target="_blank"
|
||||
rel="noreferrer"
|
||||
>
|
||||
<a v-if="item.postUrl" :href="item.postUrl" target="_blank" rel="noreferrer">
|
||||
{{ item.postUrl }}
|
||||
</a>
|
||||
<span v-else>-</span>
|
||||
@@ -71,7 +83,8 @@
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { onMounted, ref } from "vue";
|
||||
import { onMounted, onBeforeUnmount, ref, nextTick, watch } from "vue";
|
||||
import * as echarts from "echarts";
|
||||
import {
|
||||
fetchVisitOverview,
|
||||
fetchVisitPages,
|
||||
@@ -79,18 +92,32 @@ import {
|
||||
type VisitPageItem,
|
||||
} from "../api/admin";
|
||||
|
||||
const DOMAIN_STORAGE_KEY = "cwd_admin_domain_filter";
|
||||
|
||||
const loading = ref(false);
|
||||
const error = ref("");
|
||||
const overview = ref<VisitOverviewResponse>({
|
||||
totalPv: 0,
|
||||
totalPages: 0,
|
||||
last30Days: [],
|
||||
});
|
||||
const items = ref<VisitPageItem[]>([]);
|
||||
|
||||
const storedDomain =
|
||||
typeof window !== "undefined"
|
||||
? window.localStorage.getItem(DOMAIN_STORAGE_KEY) || ""
|
||||
: "";
|
||||
const domainFilter = ref(storedDomain);
|
||||
const domainOptions = ref<string[]>([]);
|
||||
const last30Days = 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;
|
||||
@@ -116,28 +143,157 @@ function formatTime(value: string | null): string {
|
||||
return `${y}-${m}-${d} ${h}:${mm}`;
|
||||
}
|
||||
|
||||
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;
|
||||
}
|
||||
}
|
||||
|
||||
async function loadData() {
|
||||
loading.value = true;
|
||||
error.value = "";
|
||||
try {
|
||||
const domain = domainFilter.value || undefined;
|
||||
const [overviewRes, pagesRes] = await Promise.all([
|
||||
fetchVisitOverview(),
|
||||
fetchVisitPages(),
|
||||
fetchVisitOverview(domain),
|
||||
fetchVisitPages(domain),
|
||||
]);
|
||||
overview.value = overviewRes;
|
||||
overview.value = {
|
||||
totalPv: overviewRes.totalPv,
|
||||
totalPages: overviewRes.totalPages,
|
||||
last30Days: Array.isArray(overviewRes.last30Days)
|
||||
? overviewRes.last30Days
|
||||
: [],
|
||||
};
|
||||
items.value = pagesRes.items || [];
|
||||
last30Days.value = Array.isArray(overviewRes.last30Days)
|
||||
? overviewRes.last30Days
|
||||
: [];
|
||||
|
||||
const domains = new Set<string>();
|
||||
for (const item of items.value) {
|
||||
const domainFromUrl =
|
||||
extractDomain(item.postUrl) || extractDomain(item.postSlug);
|
||||
if (domainFromUrl) {
|
||||
domains.add(domainFromUrl);
|
||||
}
|
||||
}
|
||||
const options = Array.from(domains);
|
||||
if (domainFilter.value && !options.includes(domainFilter.value)) {
|
||||
options.unshift(domainFilter.value);
|
||||
}
|
||||
domainOptions.value = options;
|
||||
} catch (e: any) {
|
||||
const msg = e.message || "加载访问统计数据失败";
|
||||
error.value = msg;
|
||||
showToast(msg, "error");
|
||||
} finally {
|
||||
loading.value = false;
|
||||
await nextTick();
|
||||
if (!error.value && last30Days.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 = last30Days.value.map((item) => item.date.slice(5));
|
||||
const values = last30Days.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(() => {
|
||||
loadData();
|
||||
window.addEventListener("resize", handleResize);
|
||||
});
|
||||
|
||||
onBeforeUnmount(() => {
|
||||
window.removeEventListener("resize", handleResize);
|
||||
if (chartInstance) {
|
||||
chartInstance.dispose();
|
||||
chartInstance = null;
|
||||
}
|
||||
});
|
||||
|
||||
watch(
|
||||
domainFilter,
|
||||
(value) => {
|
||||
if (typeof window !== "undefined") {
|
||||
window.localStorage.setItem(DOMAIN_STORAGE_KEY, value || "");
|
||||
}
|
||||
loadData();
|
||||
}
|
||||
);
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
@@ -153,6 +309,28 @@ onMounted(() => {
|
||||
color: #24292f;
|
||||
}
|
||||
|
||||
.toolbar {
|
||||
display: flex;
|
||||
justify-content: flex-start;
|
||||
align-items: center;
|
||||
margin: 0;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.toolbar-left {
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.toolbar-select {
|
||||
padding: 8px 8px;
|
||||
box-sizing: border-box;
|
||||
font-size: 13px;
|
||||
border: 1px solid #d0d7de;
|
||||
border-radius: 4px;
|
||||
background-color: #ffffff;
|
||||
}
|
||||
|
||||
.card {
|
||||
background-color: #ffffff;
|
||||
border-radius: 6px;
|
||||
@@ -233,6 +411,15 @@ onMounted(() => {
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.chart-wrapper {
|
||||
margin-top: 4px;
|
||||
}
|
||||
|
||||
.chart {
|
||||
width: 100%;
|
||||
height: 260px;
|
||||
}
|
||||
|
||||
.toast {
|
||||
position: fixed;
|
||||
top: 20px;
|
||||
@@ -263,4 +450,3 @@ onMounted(() => {
|
||||
}
|
||||
}
|
||||
</style>
|
||||
|
||||
|
||||
@@ -251,7 +251,7 @@
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { onMounted, ref, computed } from "vue";
|
||||
import { onMounted, ref, computed, watch } from "vue";
|
||||
import { useRoute, useRouter } from "vue-router";
|
||||
import {
|
||||
CommentItem,
|
||||
@@ -265,6 +265,8 @@ import {
|
||||
blockEmail,
|
||||
} from "../api/admin";
|
||||
|
||||
const DOMAIN_STORAGE_KEY = "cwd_admin_domain_filter";
|
||||
|
||||
const route = useRoute();
|
||||
const router = useRouter();
|
||||
|
||||
@@ -273,7 +275,11 @@ const pagination = ref<{ page: number; total: number }>({ page: 1, total: 1 });
|
||||
const loading = ref(false);
|
||||
const error = ref("");
|
||||
const statusFilter = ref("");
|
||||
const domainFilter = ref("");
|
||||
const storedDomain =
|
||||
typeof window !== "undefined"
|
||||
? window.localStorage.getItem(DOMAIN_STORAGE_KEY) || ""
|
||||
: "";
|
||||
const domainFilter = ref(storedDomain);
|
||||
const jumpPageInput = ref("");
|
||||
const editVisible = ref(false);
|
||||
const editSaving = ref(false);
|
||||
@@ -565,6 +571,15 @@ onMounted(() => {
|
||||
|
||||
loadComments(initialPage);
|
||||
});
|
||||
|
||||
watch(
|
||||
domainFilter,
|
||||
(value) => {
|
||||
if (typeof window !== "undefined") {
|
||||
window.localStorage.setItem(DOMAIN_STORAGE_KEY, value || "");
|
||||
}
|
||||
}
|
||||
);
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
|
||||
@@ -1,6 +1,18 @@
|
||||
<template>
|
||||
<div class="page">
|
||||
<h2 class="page-title">数据看板</h2>
|
||||
<div style="display: flex; align-items: center; gap: 20px">
|
||||
<h2 class="page-title">数据看板</h2>
|
||||
<div class="toolbar">
|
||||
<div class="toolbar-left">
|
||||
<select v-model="domainFilter" class="toolbar-select">
|
||||
<option value="">全部域名</option>
|
||||
<option v-for="item in domainOptions" :key="item" :value="item">
|
||||
{{ item }}
|
||||
</option>
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div
|
||||
v-if="toastVisible"
|
||||
class="toast"
|
||||
@@ -74,7 +86,7 @@
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { onMounted, onBeforeUnmount, ref, nextTick } from "vue";
|
||||
import { onMounted, onBeforeUnmount, ref, nextTick, watch } from "vue";
|
||||
import * as echarts from "echarts";
|
||||
import { fetchCommentStats } from "../api/admin";
|
||||
|
||||
@@ -86,6 +98,8 @@ type DomainStat = {
|
||||
rejected: number;
|
||||
};
|
||||
|
||||
const DOMAIN_STORAGE_KEY = "cwd_admin_domain_filter";
|
||||
|
||||
const statsLoading = ref(false);
|
||||
const statsError = ref("");
|
||||
const statsSummary = ref({
|
||||
@@ -97,6 +111,13 @@ const statsSummary = ref({
|
||||
const domainStats = ref<DomainStat[]>([]);
|
||||
const last7Days = ref<{ date: string; total: number }[]>([]);
|
||||
|
||||
const storedDomain =
|
||||
typeof window !== "undefined"
|
||||
? window.localStorage.getItem(DOMAIN_STORAGE_KEY) || ""
|
||||
: "";
|
||||
const domainFilter = ref(storedDomain);
|
||||
const domainOptions = ref<string[]>([]);
|
||||
|
||||
const toastMessage = ref("");
|
||||
const toastType = ref<"success" | "error">("success");
|
||||
const toastVisible = ref(false);
|
||||
@@ -117,7 +138,7 @@ async function loadStats() {
|
||||
statsLoading.value = true;
|
||||
statsError.value = "";
|
||||
try {
|
||||
const res = await fetchCommentStats();
|
||||
const res = await fetchCommentStats(domainFilter.value || undefined);
|
||||
statsSummary.value = {
|
||||
total: res.summary.total,
|
||||
approved: res.summary.approved,
|
||||
@@ -125,6 +146,14 @@ async function loadStats() {
|
||||
rejected: res.summary.rejected,
|
||||
};
|
||||
domainStats.value = res.domains;
|
||||
const domains = Array.isArray(res.domains)
|
||||
? res.domains.map((item) => item.domain)
|
||||
: [];
|
||||
const set = new Set(domains);
|
||||
if (domainFilter.value && !set.has(domainFilter.value)) {
|
||||
set.add(domainFilter.value);
|
||||
}
|
||||
domainOptions.value = Array.from(set);
|
||||
last7Days.value = Array.isArray(res.last7Days) ? res.last7Days : [];
|
||||
} catch (e: any) {
|
||||
const msg = e.message || "加载统计数据失败";
|
||||
@@ -207,6 +236,13 @@ onMounted(() => {
|
||||
window.addEventListener("resize", handleResize);
|
||||
});
|
||||
|
||||
watch(domainFilter, (value) => {
|
||||
if (typeof window !== "undefined") {
|
||||
window.localStorage.setItem(DOMAIN_STORAGE_KEY, value || "");
|
||||
}
|
||||
loadStats();
|
||||
});
|
||||
|
||||
onBeforeUnmount(() => {
|
||||
window.removeEventListener("resize", handleResize);
|
||||
if (chartInstance) {
|
||||
@@ -229,6 +265,28 @@ onBeforeUnmount(() => {
|
||||
color: #24292f;
|
||||
}
|
||||
|
||||
.toolbar {
|
||||
display: flex;
|
||||
justify-content: flex-start;
|
||||
align-items: center;
|
||||
margin: 0;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.toolbar-left {
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.toolbar-select {
|
||||
padding: 8px 8px;
|
||||
box-sizing: border-box;
|
||||
font-size: 13px;
|
||||
border: 1px solid #d0d7de;
|
||||
border-radius: 4px;
|
||||
background-color: #ffffff;
|
||||
}
|
||||
|
||||
.card {
|
||||
background-color: #ffffff;
|
||||
border-radius: 6px;
|
||||
@@ -350,7 +408,7 @@ onBeforeUnmount(() => {
|
||||
color: #ffffff;
|
||||
}
|
||||
|
||||
@media(max-width: 768px) {
|
||||
@media (max-width: 768px) {
|
||||
.stats-grid {
|
||||
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user