From 4550b3c972290721f54af1deb79735ca63b6a89a Mon Sep 17 00:00:00 2001 From: anghunk Date: Thu, 22 Jan 2026 09:42:39 +0800 Subject: [PATCH] =?UTF-8?q?feat(analytics):=20=E6=B7=BB=E5=8A=A0=E6=8C=89?= =?UTF-8?q?=E6=9C=80=E6=96=B0=E8=AE=BF=E9=97=AE=E6=8E=92=E5=BA=8F=E5=8A=9F?= =?UTF-8?q?=E8=83=BD?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 在访问统计页面增加按最新访问排序的选项,并优化加载状态管理 修改相关API以支持排序参数,同时调整页面布局和样式 --- cwd-admin/src/App.vue | 15 ++-- cwd-admin/src/api/admin.ts | 5 +- cwd-admin/src/views/AnalyticsVisitView.vue | 100 +++++++++++++++++++-- cwd-admin/src/views/LayoutView.vue | 2 +- cwd-admin/src/views/SettingsView.vue | 2 +- cwd-api/src/api/admin/visitAnalytics.ts | 15 +++- 6 files changed, 118 insertions(+), 21 deletions(-) diff --git a/cwd-admin/src/App.vue b/cwd-admin/src/App.vue index b8e6c22..08cde1c 100644 --- a/cwd-admin/src/App.vue +++ b/cwd-admin/src/App.vue @@ -1,7 +1,7 @@ @@ -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; } - diff --git a/cwd-admin/src/api/admin.ts b/cwd-admin/src/api/admin.ts index 2f4290a..72f5042 100644 --- a/cwd-admin/src/api/admin.ts +++ b/cwd-admin/src/api/admin.ts @@ -250,11 +250,14 @@ export function fetchVisitOverview(domain?: string): Promise(url); } -export function fetchVisitPages(domain?: string): Promise { +export function fetchVisitPages(domain?: string, order?: 'pv' | 'latest'): Promise { 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(url); diff --git a/cwd-admin/src/views/AnalyticsVisitView.vue b/cwd-admin/src/views/AnalyticsVisitView.vue index 483d288..b21c29b 100644 --- a/cwd-admin/src/views/AnalyticsVisitView.vue +++ b/cwd-admin/src/views/AnalyticsVisitView.vue @@ -49,8 +49,28 @@
-

页面访问明细(按 PV 排序)

-
加载中...
+
+

页面访问明细

+
+ + +
+
+
加载中...
{{ error }}
暂无访问数据
@@ -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({ totalPv: 0, @@ -109,6 +130,7 @@ const overview = ref({ last30Days: [], }); const items = ref([]); +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; +} diff --git a/cwd-admin/src/views/LayoutView.vue b/cwd-admin/src/views/LayoutView.vue index 5a0901f..b6fc856 100644 --- a/cwd-admin/src/views/LayoutView.vue +++ b/cwd-admin/src/views/LayoutView.vue @@ -94,7 +94,7 @@ :class="{ active: isRouteActive('data') }" @click="goData" > - 数据管理 + 数据迁移 diff --git a/cwd-admin/src/views/SettingsView.vue b/cwd-admin/src/views/SettingsView.vue index 30461ec..639232e 100644 --- a/cwd-admin/src/views/SettingsView.vue +++ b/cwd-admin/src/views/SettingsView.vue @@ -556,7 +556,7 @@ onMounted(() => { display: flex; flex-direction: column; gap: 12px; - max-width: 520px; + max-width: 620px; } .page-title { diff --git a/cwd-api/src/api/admin/visitAnalytics.ts b/cwd-api/src/api/admin/visitAnalytics.ts index d6a4a0f..41dc9d3 100644 --- a/cwd-api/src/api/admin/visitAnalytics.ts +++ b/cwd-api/src/api/admin/visitAnalytics.ts @@ -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;