feat(数据分析): 添加域名过滤功能并持久化存储

- 在评论、统计和访问分析页面添加域名过滤功能
- 使用localStorage持久化存储用户选择的域名过滤条件
- 新增30天访问趋势图表展示
- 优化API接口支持按域名过滤数据
- 添加每日访问统计记录功能
This commit is contained in:
anghunk
2026-01-21 22:35:59 +08:00
parent 73eaa975fe
commit 5d2cf49d96
7 changed files with 574 additions and 81 deletions

View File

@@ -86,6 +86,10 @@ export type CommentStatsResponse = {
export type VisitOverviewResponse = { export type VisitOverviewResponse = {
totalPv: number; totalPv: number;
totalPages: number; totalPages: number;
last30Days?: {
date: string;
total: number;
}[];
}; };
export type VisitPageItem = { export type VisitPageItem = {
@@ -226,14 +230,32 @@ 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> { export function fetchCommentStats(domain?: string): Promise<CommentStatsResponse> {
return get<CommentStatsResponse>('/admin/stats/comments'); 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> { export function fetchVisitOverview(domain?: string): Promise<VisitOverviewResponse> {
return get<VisitOverviewResponse>('/admin/analytics/overview'); 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> { export function fetchVisitPages(domain?: string): Promise<VisitPagesResponse> {
return get<VisitPagesResponse>('/admin/analytics/pages'); 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);
} }

View File

@@ -1,6 +1,18 @@
<template> <template>
<div class="page"> <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 <div
v-if="toastVisible" v-if="toastVisible"
class="toast" class="toast"
@@ -27,6 +39,15 @@
</div> </div>
</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"> <div class="card">
<h3 class="card-title">页面访问明细 PV 排序</h3> <h3 class="card-title">页面访问明细 PV 排序</h3>
<div v-if="loading" class="page-hint">加载中...</div> <div v-if="loading" class="page-hint">加载中...</div>
@@ -39,11 +60,7 @@
<div class="domain-cell">最后访问时间</div> <div class="domain-cell">最后访问时间</div>
<div class="domain-cell">页面地址</div> <div class="domain-cell">页面地址</div>
</div> </div>
<div <div v-for="item in items" :key="item.postSlug" class="domain-table-row">
v-for="item in items"
:key="item.postSlug"
class="domain-table-row"
>
<div class="domain-cell domain-cell-domain"> <div class="domain-cell domain-cell-domain">
{{ item.postTitle || item.postSlug }} {{ item.postTitle || item.postSlug }}
</div> </div>
@@ -54,12 +71,7 @@
{{ formatTime(item.lastVisitAt) }} {{ formatTime(item.lastVisitAt) }}
</div> </div>
<div class="domain-cell"> <div class="domain-cell">
<a <a v-if="item.postUrl" :href="item.postUrl" target="_blank" rel="noreferrer">
v-if="item.postUrl"
:href="item.postUrl"
target="_blank"
rel="noreferrer"
>
{{ item.postUrl }} {{ item.postUrl }}
</a> </a>
<span v-else>-</span> <span v-else>-</span>
@@ -71,7 +83,8 @@
</template> </template>
<script setup lang="ts"> <script setup lang="ts">
import { onMounted, ref } from "vue"; import { onMounted, onBeforeUnmount, ref, nextTick, watch } from "vue";
import * as echarts from "echarts";
import { import {
fetchVisitOverview, fetchVisitOverview,
fetchVisitPages, fetchVisitPages,
@@ -79,18 +92,32 @@ import {
type VisitPageItem, type VisitPageItem,
} from "../api/admin"; } from "../api/admin";
const DOMAIN_STORAGE_KEY = "cwd_admin_domain_filter";
const loading = ref(false); const loading = ref(false);
const error = ref(""); const error = ref("");
const overview = ref<VisitOverviewResponse>({ const overview = ref<VisitOverviewResponse>({
totalPv: 0, totalPv: 0,
totalPages: 0, totalPages: 0,
last30Days: [],
}); });
const items = ref<VisitPageItem[]>([]); 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 toastMessage = ref("");
const toastType = ref<"success" | "error">("success"); const toastType = ref<"success" | "error">("success");
const toastVisible = ref(false); const toastVisible = ref(false);
const chartEl = ref<HTMLDivElement | null>(null);
let chartInstance: echarts.ECharts | null = null;
function showToast(msg: string, type: "success" | "error" = "success") { function showToast(msg: string, type: "success" | "error" = "success") {
toastMessage.value = msg; toastMessage.value = msg;
toastType.value = type; toastType.value = type;
@@ -116,28 +143,157 @@ function formatTime(value: string | null): string {
return `${y}-${m}-${d} ${h}:${mm}`; 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() { async function loadData() {
loading.value = true; loading.value = true;
error.value = ""; error.value = "";
try { try {
const domain = domainFilter.value || undefined;
const [overviewRes, pagesRes] = await Promise.all([ const [overviewRes, pagesRes] = await Promise.all([
fetchVisitOverview(), fetchVisitOverview(domain),
fetchVisitPages(), fetchVisitPages(domain),
]); ]);
overview.value = overviewRes; overview.value = {
totalPv: overviewRes.totalPv,
totalPages: overviewRes.totalPages,
last30Days: Array.isArray(overviewRes.last30Days)
? overviewRes.last30Days
: [],
};
items.value = pagesRes.items || []; 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) { } catch (e: any) {
const msg = e.message || "加载访问统计数据失败"; const msg = e.message || "加载访问统计数据失败";
error.value = msg; error.value = msg;
showToast(msg, "error"); showToast(msg, "error");
} finally { } finally {
loading.value = false; 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(() => { onMounted(() => {
loadData(); 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> </script>
<style scoped> <style scoped>
@@ -153,6 +309,28 @@ onMounted(() => {
color: #24292f; 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 { .card {
background-color: #ffffff; background-color: #ffffff;
border-radius: 6px; border-radius: 6px;
@@ -233,6 +411,15 @@ onMounted(() => {
font-weight: 500; font-weight: 500;
} }
.chart-wrapper {
margin-top: 4px;
}
.chart {
width: 100%;
height: 260px;
}
.toast { .toast {
position: fixed; position: fixed;
top: 20px; top: 20px;
@@ -263,4 +450,3 @@ onMounted(() => {
} }
} }
</style> </style>

View File

@@ -251,7 +251,7 @@
</template> </template>
<script setup lang="ts"> <script setup lang="ts">
import { onMounted, ref, computed } from "vue"; import { onMounted, ref, computed, watch } from "vue";
import { useRoute, useRouter } from "vue-router"; import { useRoute, useRouter } from "vue-router";
import { import {
CommentItem, CommentItem,
@@ -265,6 +265,8 @@ import {
blockEmail, blockEmail,
} from "../api/admin"; } from "../api/admin";
const DOMAIN_STORAGE_KEY = "cwd_admin_domain_filter";
const route = useRoute(); const route = useRoute();
const router = useRouter(); const router = useRouter();
@@ -273,7 +275,11 @@ const pagination = ref<{ page: number; total: number }>({ page: 1, total: 1 });
const loading = ref(false); const loading = ref(false);
const error = ref(""); const error = ref("");
const statusFilter = 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 jumpPageInput = ref("");
const editVisible = ref(false); const editVisible = ref(false);
const editSaving = ref(false); const editSaving = ref(false);
@@ -565,6 +571,15 @@ onMounted(() => {
loadComments(initialPage); loadComments(initialPage);
}); });
watch(
domainFilter,
(value) => {
if (typeof window !== "undefined") {
window.localStorage.setItem(DOMAIN_STORAGE_KEY, value || "");
}
}
);
</script> </script>
<style scoped> <style scoped>

View File

@@ -1,6 +1,18 @@
<template> <template>
<div class="page"> <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 <div
v-if="toastVisible" v-if="toastVisible"
class="toast" class="toast"
@@ -74,7 +86,7 @@
</template> </template>
<script setup lang="ts"> <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 * as echarts from "echarts";
import { fetchCommentStats } from "../api/admin"; import { fetchCommentStats } from "../api/admin";
@@ -86,6 +98,8 @@ type DomainStat = {
rejected: number; rejected: number;
}; };
const DOMAIN_STORAGE_KEY = "cwd_admin_domain_filter";
const statsLoading = ref(false); const statsLoading = ref(false);
const statsError = ref(""); const statsError = ref("");
const statsSummary = ref({ const statsSummary = ref({
@@ -97,6 +111,13 @@ const statsSummary = ref({
const domainStats = ref<DomainStat[]>([]); const domainStats = ref<DomainStat[]>([]);
const last7Days = ref<{ date: string; total: number }[]>([]); 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 toastMessage = ref("");
const toastType = ref<"success" | "error">("success"); const toastType = ref<"success" | "error">("success");
const toastVisible = ref(false); const toastVisible = ref(false);
@@ -117,7 +138,7 @@ async function loadStats() {
statsLoading.value = true; statsLoading.value = true;
statsError.value = ""; statsError.value = "";
try { try {
const res = await fetchCommentStats(); const res = await fetchCommentStats(domainFilter.value || undefined);
statsSummary.value = { statsSummary.value = {
total: res.summary.total, total: res.summary.total,
approved: res.summary.approved, approved: res.summary.approved,
@@ -125,6 +146,14 @@ async function loadStats() {
rejected: res.summary.rejected, rejected: res.summary.rejected,
}; };
domainStats.value = res.domains; 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 : []; last7Days.value = Array.isArray(res.last7Days) ? res.last7Days : [];
} catch (e: any) { } catch (e: any) {
const msg = e.message || "加载统计数据失败"; const msg = e.message || "加载统计数据失败";
@@ -207,6 +236,13 @@ onMounted(() => {
window.addEventListener("resize", handleResize); window.addEventListener("resize", handleResize);
}); });
watch(domainFilter, (value) => {
if (typeof window !== "undefined") {
window.localStorage.setItem(DOMAIN_STORAGE_KEY, value || "");
}
loadStats();
});
onBeforeUnmount(() => { onBeforeUnmount(() => {
window.removeEventListener("resize", handleResize); window.removeEventListener("resize", handleResize);
if (chartInstance) { if (chartInstance) {
@@ -229,6 +265,28 @@ onBeforeUnmount(() => {
color: #24292f; 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 { .card {
background-color: #ffffff; background-color: #ffffff;
border-radius: 6px; border-radius: 6px;
@@ -350,7 +408,7 @@ onBeforeUnmount(() => {
color: #ffffff; color: #ffffff;
} }
@media(max-width: 768px) { @media (max-width: 768px) {
.stats-grid { .stats-grid {
grid-template-columns: repeat(2, minmax(0, 1fr)); grid-template-columns: repeat(2, minmax(0, 1fr));
} }

View File

@@ -33,32 +33,40 @@ function extractDomain(source: string | null | undefined): string | null {
export const getStats = async (c: Context<{ Bindings: Bindings }>) => { export const getStats = async (c: Context<{ Bindings: Bindings }>) => {
try { try {
const summaryRow = await c.env.CWD_DB.prepare( const rawDomain = c.req.query('domain') || '';
"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" const domainFilter = rawDomain.trim().toLowerCase();
).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( const { results } = await c.env.CWD_DB.prepare(
'SELECT post_slug, url, status FROM Comment' 'SELECT created, post_slug, url, status FROM Comment'
).all<{ ).all<{
created: number;
post_slug: string; post_slug: string;
url: string | null; url: string | null;
status: string; status: string;
}>(); }>();
const summaryAll: StatusCounts = {
total: 0,
approved: 0,
pending: 0,
rejected: 0
};
const summaryFiltered: StatusCounts = {
total: 0,
approved: 0,
pending: 0,
rejected: 0
};
const domainMap = new Map<string, StatusCounts>(); const domainMap = new Map<string, StatusCounts>();
const dailyMapAll = new Map<string, number>();
const dailyMapFiltered = new Map<string, number>();
const now = Date.now();
const thirtyDaysAgo = now - 29 * 24 * 60 * 60 * 1000;
for (const row of results) { for (const row of results) {
const domain = const domain =
extractDomain(row.post_slug) || extractDomain(row.url) || 'unknown'; extractDomain(row.post_slug) || extractDomain(row.url) || 'unknown';
@@ -81,6 +89,45 @@ export const getStats = async (c: Context<{ Bindings: Bindings }>) => {
} else if (row.status === 'rejected') { } else if (row.status === 'rejected') {
counts.rejected += 1; counts.rejected += 1;
} }
summaryAll.total += 1;
if (row.status === 'approved') {
summaryAll.approved += 1;
} else if (row.status === 'pending') {
summaryAll.pending += 1;
} else if (row.status === 'rejected') {
summaryAll.rejected += 1;
}
const matchesFilter = domainFilter && domain === domainFilter;
if (matchesFilter) {
summaryFiltered.total += 1;
if (row.status === 'approved') {
summaryFiltered.approved += 1;
} else if (row.status === 'pending') {
summaryFiltered.pending += 1;
} else if (row.status === 'rejected') {
summaryFiltered.rejected += 1;
}
}
if (row.created >= thirtyDaysAgo) {
const d = new Date(row.created);
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}`;
dailyMapAll.set(key, (dailyMapAll.get(key) || 0) + 1);
if (matchesFilter) {
dailyMapFiltered.set(
key,
(dailyMapFiltered.get(key) || 0) + 1
);
}
}
} }
const domains: DomainCounts[] = Array.from(domainMap.entries()) const domains: DomainCounts[] = Array.from(domainMap.entries())
@@ -93,21 +140,7 @@ export const getStats = async (c: Context<{ Bindings: Bindings }>) => {
})) }))
.sort((a, b) => b.total - a.total); .sort((a, b) => b.total - a.total);
const now = Date.now(); const dailyMap = domainFilter ? dailyMapFiltered : dailyMapAll;
const thirtyDaysAgo = now - 29 * 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(thirtyDaysAgo)
.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 }[] = []; const last7Days: { date: string; total: number }[] = [];
for (let i = 29; i >= 0; i--) { for (let i = 29; i >= 0; i--) {
@@ -122,6 +155,8 @@ export const getStats = async (c: Context<{ Bindings: Bindings }>) => {
}); });
} }
const summary = domainFilter ? summaryFiltered : summaryAll;
return c.json({ return c.json({
summary, summary,
domains, domains,

View File

@@ -4,6 +4,10 @@ import type { Bindings } from '../../bindings';
type VisitOverview = { type VisitOverview = {
totalPv: number; totalPv: number;
totalPages: number; totalPages: number;
last30Days: {
date: string;
total: number;
}[];
}; };
type VisitPageItem = { type VisitPageItem = {
@@ -14,24 +18,116 @@ type VisitPageItem = {
lastVisitAt: string | null; lastVisitAt: string | null;
}; };
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 getVisitOverview = async ( export const getVisitOverview = async (
c: Context<{ Bindings: Bindings }> c: Context<{ Bindings: Bindings }>
) => { ) => {
try { try {
const rawDomain = c.req.query('domain') || '';
const domainFilter = rawDomain.trim().toLowerCase();
await c.env.CWD_DB.prepare( await c.env.CWD_DB.prepare(
'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 TEXT, created_at TEXT NOT NULL, updated_at TEXT NOT NULL)' '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 TEXT, created_at TEXT NOT NULL, updated_at TEXT NOT NULL)'
).run(); ).run();
const row = await c.env.CWD_DB.prepare( await c.env.CWD_DB.prepare(
'SELECT COUNT(*) as totalPages, COALESCE(SUM(pv), 0) as totalPv FROM page_stats' '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 TEXT NOT NULL, updated_at TEXT NOT NULL)'
).first<{ ).run();
totalPages: number | null;
totalPv: number | null; const { results } = await c.env.CWD_DB.prepare(
'SELECT post_slug, post_title, post_url, pv, last_visit_at FROM page_stats'
).all<{
post_slug: string;
post_title: string | null;
post_url: string | null;
pv: number;
last_visit_at: string | null;
}>(); }>();
let totalPv = 0;
let totalPages = 0;
for (const row of results) {
const domain =
extractDomain(row.post_url) ||
extractDomain(row.post_slug) ||
null;
if (domainFilter && domain !== domainFilter) {
continue;
}
totalPv += row.pv || 0;
totalPages += 1;
}
const now = new Date();
const thirtyDaysAgo = new Date(now.getTime() - 29 * 24 * 60 * 60 * 1000);
const startDate = thirtyDaysAgo.toISOString().slice(0, 10);
let dailySql =
'SELECT date, domain, count FROM page_visit_daily WHERE date >= ?';
const params: string[] = [startDate];
if (domainFilter) {
dailySql += ' AND domain = ?';
params.push(domainFilter);
}
const { results: dailyRows } = await c.env.CWD_DB.prepare(dailySql)
.bind(...params)
.all<{
date: string;
domain: string | null;
count: number;
}>();
const dailyMap = new Map<string, number>();
for (const row of dailyRows) {
if (!row || !row.date) {
continue;
}
const key = row.date;
const value = row.count || 0;
dailyMap.set(key, (dailyMap.get(key) || 0) + value);
}
const last30Days: { date: string; total: number }[] = [];
for (let i = 29; i >= 0; i--) {
const d = new Date(now.getTime() - 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}`;
last30Days.push({
date: key,
total: dailyMap.get(key) || 0
});
}
const data: VisitOverview = { const data: VisitOverview = {
totalPv: row?.totalPv || 0, totalPv,
totalPages: row?.totalPages || 0 totalPages,
last30Days
}; };
return c.json(data); return c.json(data);
@@ -45,6 +141,9 @@ export const getVisitOverview = async (
export const getVisitPages = async (c: Context<{ Bindings: Bindings }>) => { export const getVisitPages = async (c: Context<{ Bindings: Bindings }>) => {
try { try {
const rawDomain = c.req.query('domain') || '';
const domainFilter = rawDomain.trim().toLowerCase();
await c.env.CWD_DB.prepare( await c.env.CWD_DB.prepare(
'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 TEXT, created_at TEXT NOT NULL, updated_at TEXT NOT NULL)' '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 TEXT, created_at TEXT NOT NULL, updated_at TEXT NOT NULL)'
).run(); ).run();
@@ -59,13 +158,26 @@ export const getVisitPages = async (c: Context<{ Bindings: Bindings }>) => {
last_visit_at: string | null; last_visit_at: string | null;
}>(); }>();
const items: VisitPageItem[] = results.map((row) => ({ const items: VisitPageItem[] = [];
postSlug: row.post_slug,
postTitle: row.post_title, for (const row of results) {
postUrl: row.post_url, const domain =
pv: row.pv || 0, extractDomain(row.post_url) ||
lastVisitAt: row.last_visit_at extractDomain(row.post_slug) ||
})); null;
if (domainFilter && domain !== domainFilter) {
continue;
}
items.push({
postSlug: row.post_slug,
postTitle: row.post_title,
postUrl: row.post_url,
pv: row.pv || 0,
lastVisitAt: row.last_visit_at
});
}
return c.json({ items }); return c.json({ items });
} catch (e: any) { } catch (e: any) {
@@ -75,4 +187,3 @@ export const getVisitPages = async (c: Context<{ Bindings: Bindings }>) => {
); );
} }
}; };

View File

@@ -7,6 +7,25 @@ type TrackVisitBody = {
postUrl?: string; postUrl?: 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 trackVisit = async (c: Context<{ Bindings: Bindings }>) => { export const trackVisit = async (c: Context<{ Bindings: Bindings }>) => {
try { try {
const body = (await c.req.json().catch(() => ({}))) as TrackVisitBody; const body = (await c.req.json().catch(() => ({}))) as TrackVisitBody;
@@ -23,7 +42,18 @@ export const trackVisit = async (c: Context<{ Bindings: Bindings }>) => {
'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 TEXT, created_at TEXT NOT NULL, updated_at TEXT NOT NULL)' '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 TEXT, created_at TEXT NOT NULL, updated_at TEXT NOT NULL)'
).run(); ).run();
const now = new Date().toISOString(); await c.env.CWD_DB.prepare(
'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 TEXT NOT NULL, updated_at TEXT NOT NULL)'
).run();
const nowDate = new Date();
const nowIso = nowDate.toISOString();
const today = nowIso.slice(0, 10);
const domain =
extractDomain(rawPostUrl) ||
extractDomain(rawPostSlug) ||
null;
const existing = await c.env.CWD_DB.prepare( const existing = await c.env.CWD_DB.prepare(
'SELECT id, pv FROM page_stats WHERE post_slug = ?' 'SELECT id, pv FROM page_stats WHERE post_slug = ?'
@@ -40,9 +70,9 @@ export const trackVisit = async (c: Context<{ Bindings: Bindings }>) => {
rawPostTitle || null, rawPostTitle || null,
rawPostUrl || null, rawPostUrl || null,
1, 1,
now, nowIso,
now, nowIso,
now nowIso
) )
.run(); .run();
} else { } else {
@@ -54,13 +84,49 @@ export const trackVisit = async (c: Context<{ Bindings: Bindings }>) => {
rawPostTitle || null, rawPostTitle || null,
rawPostUrl || null, rawPostUrl || null,
newPv, newPv,
now, nowIso,
now, nowIso,
existing.id existing.id
) )
.run(); .run();
} }
let dailyRow:
| {
id: number;
count: number;
}
| null = null;
if (domain) {
dailyRow = await c.env.CWD_DB.prepare(
'SELECT id, count FROM page_visit_daily WHERE date = ? AND domain = ?'
)
.bind(today, domain)
.first<{ id: number; count: number }>();
} else {
dailyRow = await c.env.CWD_DB.prepare(
'SELECT id, count FROM page_visit_daily WHERE date = ? AND domain IS NULL'
)
.bind(today)
.first<{ id: number; count: number }>();
}
if (!dailyRow) {
await c.env.CWD_DB.prepare(
'INSERT INTO page_visit_daily (date, domain, count, created_at, updated_at) VALUES (?, ?, ?, ?, ?)'
)
.bind(today, domain, 1, nowIso, nowIso)
.run();
} else {
const newCount = (dailyRow.count || 0) + 1;
await c.env.CWD_DB.prepare(
'UPDATE page_visit_daily SET count = ?, updated_at = ? WHERE id = ?'
)
.bind(newCount, nowIso, dailyRow.id)
.run();
}
return c.json({ success: true }); return c.json({ success: true });
} catch (e: any) { } catch (e: any) {
return c.json({ message: e.message || '记录访问数据失败' }, 500); return c.json({ message: e.message || '记录访问数据失败' }, 500);