diff --git a/server/app.mjs b/server/app.mjs index 20881bd..0eeecd2 100644 --- a/server/app.mjs +++ b/server/app.mjs @@ -6,6 +6,7 @@ import { fileURLToPath } from 'node:url'; import express from 'express'; import { listUsagePaged, listLedgerPaged, getUsageStats, getUsageSummary } from './pagination.mjs'; import { fetchMemindDiscoveryPages } from './umami-analytics.mjs'; +import { importMemind } from './lib-path.mjs'; const projectRoot = path.join(path.dirname(fileURLToPath(import.meta.url)), '..'); @@ -252,6 +253,29 @@ export function createAdminApp(services) { } }); + adminApi.get('/analytics/seo-geo-catalog', requireAdmin, async (req, res) => { + try { + const { listAdminSeoGeoPublicationCatalog } = await importMemind('mindspace-seo-geo-admin-catalog.mjs'); + const startAt = Number(req.query.startAt); + const endAt = Number(req.query.endAt); + const result = await listAdminSeoGeoPublicationCatalog(pool, { + page: Number(req.query.page) || 1, + pageSize: Number(req.query.pageSize) || 20, + sortBy: typeof req.query.sortBy === 'string' ? req.query.sortBy : 'publishedAt', + sortOrder: req.query.sortOrder === 'asc' ? 'asc' : 'desc', + startAt: Number.isFinite(startAt) ? startAt : null, + endAt: Number.isFinite(endAt) ? endAt : null, + search: typeof req.query.search === 'string' ? req.query.search.trim() : '', + publicHost: process.env.H5_PUBLIC_BASE_URL?.replace(/^https?:\/\//, '').replace(/\/$/, '') || 'm.tkmind.cn', + }); + res.json(result); + } catch (error) { + res.status(503).json({ + message: error instanceof Error ? error.message : '无法加载 SEO/GEO 页面目录', + }); + } + }); + adminApi.get('/analytics/rybbit-sso', requireAdmin, async (_req, res) => { return res.status(410).json({ message: 'Rybbit 已退役,请使用 Umami 与 SEO/GEO 流量看板' }); }); diff --git a/src/admin/pages/SeoGeoAnalyticsPage.tsx b/src/admin/pages/SeoGeoAnalyticsPage.tsx index 259502e..dbb257e 100644 --- a/src/admin/pages/SeoGeoAnalyticsPage.tsx +++ b/src/admin/pages/SeoGeoAnalyticsPage.tsx @@ -1,49 +1,21 @@ import { useCallback, useEffect, useMemo, useState } from 'react'; -import { getSeoGeoAnalyticsPages, getUmamiSsoUrl } from '../../api/client'; - -type DiscoveryChannel = 'seo' | 'geo'; - -type PageRow = { - urlPath: string; - pageUrl?: string; - hostname?: string; - pageTitle: string; - views: number; - visitors: number; - visits: number; - clicks: number; - engagedVisits: number; - forms: number; - generatedAt?: string | null; - generatedAtInferred?: boolean; - firstSeenAt?: string | null; -}; +import { getSeoGeoPublicationCatalog, getUmamiSsoUrl } from '../../api/client'; +import type { SeoGeoCatalogRow } from '../../api/client'; type PageSort = - | 'generatedAt' - | 'views' - | 'visitors' - | 'visits' - | 'clicks' - | 'engagedVisits' - | 'forms' + | 'publishedAt' | 'pageTitle' - | 'firstSeenAt'; + | 'ownerLabel' + | 'totalViews' + | 'seoViews' + | 'geoViews' + | 'crawlerViews' + | 'lifetimeViewCount'; function formatNumber(value: number) { return new Intl.NumberFormat('zh-CN').format(Number(value) || 0); } -function resolvePageUrl(row: PageRow) { - const exact = String(row.pageUrl ?? '').trim(); - if (/^https?:\/\//i.test(exact)) return exact; - const hostname = String(row.hostname ?? '').trim(); - if (hostname === '127.0.0.1' || hostname === 'localhost') { - return `http://${hostname}:8081${row.urlPath}`; - } - return hostname ? `https://${hostname}${row.urlPath}` : row.urlPath; -} - function defaultRange() { const end = new Date(); const start = new Date(end); @@ -60,67 +32,76 @@ function toRangeMs(startDate: string, endDate: string) { return { startAt, endAt }; } +function accessModeLabel(accessMode: string) { + switch (accessMode) { + case 'public': + return '公开'; + case 'password': + return '密码'; + case 'login_required': + return '登录可见'; + case 'owner_only': + return '仅本人'; + case 'private_link': + return '私密链接'; + default: + return accessMode || '—'; + } +} + export function SeoGeoAnalyticsPage() { const initialRange = useMemo(() => defaultRange(), []); - const [channel, setChannel] = useState('seo'); const [startDate, setStartDate] = useState(initialRange.startDate); const [endDate, setEndDate] = useState(initialRange.endDate); - const [generatedStartDate, setGeneratedStartDate] = useState(''); - const [generatedEndDate, setGeneratedEndDate] = useState(''); - const [sortBy, setSortBy] = useState('views'); + const [search, setSearch] = useState(''); + const [sortBy, setSortBy] = useState('totalViews'); const [sortOrder, setSortOrder] = useState<'asc' | 'desc'>('desc'); const [page, setPage] = useState(1); - const [rows, setRows] = useState([]); + const [rows, setRows] = useState([]); const [total, setTotal] = useState(0); - const [totalViews, setTotalViews] = useState(0); + const [totals, setTotals] = useState({ + totalViews: 0, + seoViews: 0, + geoViews: 0, + crawlerViews: 0, + }); const [loading, setLoading] = useState(true); const [error, setError] = useState(null); const [openingUmami, setOpeningUmami] = useState(false); - const pageCount = Math.max(1, Math.ceil(total / 10)); + const pageCount = Math.max(1, Math.ceil(total / 20)); const load = useCallback(async () => { setLoading(true); setError(null); try { const { startAt, endAt } = toRangeMs(startDate, endDate); - const result = await getSeoGeoAnalyticsPages({ - discoveryChannel: channel, + const result = await getSeoGeoPublicationCatalog({ startAt, endAt, - timezone: 'Asia/Shanghai', page, - pageSize: 10, + pageSize: 20, sortBy, sortOrder, - generatedStartAt: generatedStartDate - ? new Date(`${generatedStartDate}T00:00:00`).getTime() - : undefined, - generatedEndAt: generatedEndDate - ? new Date(`${generatedEndDate}T23:59:59.999`).getTime() - : undefined, + search: search.trim() || undefined, }); setRows(Array.isArray(result.data) ? result.data : []); setTotal(Number(result.total) || 0); - setTotalViews(Number(result.totalViews) || 0); + setTotals(result.totals ?? { + totalViews: 0, + seoViews: 0, + geoViews: 0, + crawlerViews: 0, + }); } catch (err) { - setError(err instanceof Error ? err.message : '加载 SEO/GEO 页面统计失败'); + setError(err instanceof Error ? err.message : '加载 SEO/GEO 页面目录失败'); setRows([]); setTotal(0); - setTotalViews(0); + setTotals({ totalViews: 0, seoViews: 0, geoViews: 0, crawlerViews: 0 }); } finally { setLoading(false); } - }, [ - channel, - endDate, - generatedEndDate, - generatedStartDate, - page, - sortBy, - sortOrder, - startDate, - ]); + }, [endDate, page, search, sortBy, sortOrder, startDate]); useEffect(() => { void load(); @@ -128,7 +109,7 @@ export function SeoGeoAnalyticsPage() { useEffect(() => { setPage(1); - }, [channel, startDate, endDate, generatedStartDate, generatedEndDate, sortBy, sortOrder]); + }, [startDate, endDate, search, sortBy, sortOrder]); const openUmami = async () => { setOpeningUmami(true); @@ -141,22 +122,12 @@ export function SeoGeoAnalyticsPage() { } }; - const overview = useMemo(() => { - const pages = rows.length; - const views = rows.reduce((sum, row) => sum + Number(row.views || 0), 0); - const visitors = rows.reduce((sum, row) => sum + Number(row.visitors || 0), 0); - const clicks = rows.reduce((sum, row) => sum + Number(row.clicks || 0), 0); - const engaged = rows.reduce((sum, row) => sum + Number(row.engagedVisits || 0), 0); - const forms = rows.reduce((sum, row) => sum + Number(row.forms || 0), 0); - return { pages, views, visitors, clicks, engaged, forms }; - }, [rows]); - return (

SEO / GEO 流量

- 按页面查看来自搜索引擎(SEO)与生成式引擎(GEO)的真实用户访问,表格口径与 Umami「所有生成页面明细」一致。 + 列出全部在线公开页(含零流量页面),仅展示统计与 URL,不提供跳转访问。SEO / GEO 来源按 referrer 域名归类;爬虫按 User-Agent 识别。

@@ -164,20 +135,6 @@ export function SeoGeoAnalyticsPage() {
- - @@ -196,35 +153,25 @@ export function SeoGeoAnalyticsPage() { setEndDate(e.target.value)} /> -