feat(数据分析): 添加域名过滤功能并持久化存储
- 在评论、统计和访问分析页面添加域名过滤功能 - 使用localStorage持久化存储用户选择的域名过滤条件 - 新增30天访问趋势图表展示 - 优化API接口支持按域名过滤数据 - 添加每日访问统计记录功能
This commit is contained in:
@@ -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);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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));
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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 }>) => {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@@ -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);
|
||||||
|
|||||||
Reference in New Issue
Block a user