feat(analytics): 添加按最新访问排序功能

在访问统计页面增加按最新访问排序的选项,并优化加载状态管理
修改相关API以支持排序参数,同时调整页面布局和样式
This commit is contained in:
anghunk
2026-01-22 09:42:39 +08:00
parent 6c1651b4e9
commit 4550b3c972
6 changed files with 118 additions and 21 deletions

View File

@@ -1,7 +1,7 @@
<template>
<div class="app-root">
<router-view />
</div>
<div class="app-root">
<router-view />
</div>
</template>
<script setup lang="ts"></script>
@@ -11,13 +11,12 @@ html,
body,
#app,
.app-root {
height: 100%;
height: 100%;
}
body {
margin: 0;
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif;
background-color: #f5f5f5;
margin: 0;
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif;
background-color: #f5f5f5;
}
</style>

View File

@@ -250,11 +250,14 @@ export function fetchVisitOverview(domain?: string): Promise<VisitOverviewRespon
return get<VisitOverviewResponse>(url);
}
export function fetchVisitPages(domain?: string): Promise<VisitPagesResponse> {
export function fetchVisitPages(domain?: string, order?: 'pv' | 'latest'): Promise<VisitPagesResponse> {
const searchParams = new URLSearchParams();
if (domain) {
searchParams.set('domain', domain);
}
if (order) {
searchParams.set('order', order);
}
const query = searchParams.toString();
const url = query ? `/admin/analytics/pages?${query}` : '/admin/analytics/pages';
return get<VisitPagesResponse>(url);

View File

@@ -49,8 +49,28 @@
</div>
<div class="card">
<h3 class="card-title">页面访问明细 PV 排序</h3>
<div v-if="loading" class="page-hint">加载中...</div>
<div class="card-title-row">
<h3 class="card-title">页面访问明细</h3>
<div class="visit-tabs">
<button
class="visit-tab"
:class="{ 'visit-tab-active': visitTab === 'pv' }"
type="button"
@click="changeVisitTab('pv')"
>
PV 排序
</button>
<button
class="visit-tab"
:class="{ 'visit-tab-active': visitTab === 'latest' }"
type="button"
@click="changeVisitTab('latest')"
>
最新访问
</button>
</div>
</div>
<div v-if="listLoading" class="page-hint">加载中...</div>
<div v-else-if="error" class="page-error">{{ error }}</div>
<div v-else-if="items.length === 0" class="page-hint">暂无访问数据</div>
<div v-else class="domain-table-wrapper">
@@ -102,6 +122,7 @@ import {
const DOMAIN_STORAGE_KEY = "cwd_admin_domain_filter";
const loading = ref(false);
const listLoading = ref(false);
const error = ref("");
const overview = ref<VisitOverviewResponse>({
totalPv: 0,
@@ -109,6 +130,7 @@ const overview = ref<VisitOverviewResponse>({
last30Days: [],
});
const items = ref<VisitPageItem[]>([]);
const visitTab = ref<"pv" | "latest">("pv");
const storedDomain =
typeof window !== "undefined"
@@ -183,14 +205,23 @@ function extractDomain(source: string | null | undefined): string | null {
}
}
function getVisitOrderParam(): "pv" | "latest" | undefined {
if (visitTab.value === "latest") {
return "latest";
}
return undefined;
}
async function loadData() {
loading.value = true;
listLoading.value = true;
error.value = "";
try {
const domain = domainFilter.value || undefined;
const order = getVisitOrderParam();
const [overviewRes, pagesRes] = await Promise.all([
fetchVisitOverview(domain),
fetchVisitPages(domain),
fetchVisitPages(domain, order),
]);
overview.value = {
totalPv: overviewRes.totalPv,
@@ -223,6 +254,7 @@ async function loadData() {
showToast(msg, "error");
} finally {
loading.value = false;
listLoading.value = false;
await nextTick();
if (!error.value && last30Days.value.length > 0) {
renderChart();
@@ -232,6 +264,31 @@ async function loadData() {
}
}
async function loadVisitPagesOnly() {
listLoading.value = true;
error.value = "";
try {
const domain = domainFilter.value || undefined;
const order = getVisitOrderParam();
const pagesRes = await fetchVisitPages(domain, order);
items.value = pagesRes.items || [];
} catch (e: any) {
const msg = e.message || "加载访问统计数据失败";
error.value = msg;
showToast(msg, "error");
} finally {
listLoading.value = false;
}
}
function changeVisitTab(tab: "pv" | "latest") {
if (visitTab.value === tab) {
return;
}
visitTab.value = tab;
loadVisitPagesOnly();
}
function renderChart() {
const el = chartEl.value;
if (!el) {
@@ -364,6 +421,14 @@ watch(
font-size: 16px;
}
.card-title-row {
display: flex;
align-items: center;
justify-content: space-between;
gap: 12px;
margin-bottom: 12px;
}
.page-hint {
font-size: 14px;
color: #57606a;
@@ -476,10 +541,6 @@ watch(
}
}
.chart-wrapper {
margin-top: 4px;
}
.chart {
width: 100%;
height: 260px;
@@ -508,4 +569,29 @@ watch(
background-color: #d1242f;
color: #ffffff;
}
.visit-tabs {
display: inline-flex;
border-radius: 999px;
border: 1px solid #d0d7de;
overflow: hidden;
}
.visit-tab {
padding: 4px 10px;
font-size: 12px;
border: none;
background-color: #ffffff;
color: #57606a;
cursor: pointer;
}
.visit-tab + .visit-tab {
border-left: 1px solid #d0d7de;
}
.visit-tab-active {
background-color: #0969da;
color: #ffffff;
}
</style>

View File

@@ -94,7 +94,7 @@
:class="{ active: isRouteActive('data') }"
@click="goData"
>
数据管理
数据迁移
</li>
</ul>
</nav>

View File

@@ -556,7 +556,7 @@ onMounted(() => {
display: flex;
flex-direction: column;
gap: 12px;
max-width: 520px;
max-width: 620px;
}
.page-title {

View File

@@ -148,14 +148,23 @@ export const getVisitPages = async (c: Context<{ Bindings: Bindings }>) => {
try {
const rawDomain = c.req.query('domain') || '';
const domainFilter = rawDomain.trim().toLowerCase();
const rawOrder = c.req.query('order') || '';
const order = rawOrder.trim().toLowerCase();
const isLatest = order === 'latest';
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 INTEGER, created_at INTEGER NOT NULL, updated_at INTEGER NOT NULL)'
).run();
const { results } = await c.env.CWD_DB.prepare(
'SELECT post_slug, post_title, post_url, pv, last_visit_at FROM page_stats ORDER BY pv DESC, last_visit_at DESC'
).all<{
let sql =
'SELECT post_slug, post_title, post_url, pv, last_visit_at FROM page_stats ORDER BY pv DESC, last_visit_at DESC';
if (isLatest) {
sql =
'SELECT post_slug, post_title, post_url, pv, last_visit_at FROM page_stats ORDER BY last_visit_at DESC, pv DESC';
}
const { results } = await c.env.CWD_DB.prepare(sql).all<{
post_slug: string;
post_title: string | null;
post_url: string | null;