feat(admin): list all pages on SEO/GEO dashboard without links
Replace Umami-only traffic rows with the Memind publication catalog API so admins see every online page plus SEO, GEO, visit, and crawler counts. Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
@@ -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 流量看板' });
|
||||
});
|
||||
|
||||
@@ -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<DiscoveryChannel>('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<PageSort>('views');
|
||||
const [search, setSearch] = useState('');
|
||||
const [sortBy, setSortBy] = useState<PageSort>('totalViews');
|
||||
const [sortOrder, setSortOrder] = useState<'asc' | 'desc'>('desc');
|
||||
const [page, setPage] = useState(1);
|
||||
const [rows, setRows] = useState<PageRow[]>([]);
|
||||
const [rows, setRows] = useState<SeoGeoCatalogRow[]>([]);
|
||||
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<string | null>(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 (
|
||||
<div className="admin-page">
|
||||
<div className="admin-page-head">
|
||||
<h2>SEO / GEO 流量</h2>
|
||||
<p className="muted">
|
||||
按页面查看来自搜索引擎(SEO)与生成式引擎(GEO)的真实用户访问,表格口径与 Umami「所有生成页面明细」一致。
|
||||
列出全部在线公开页(含零流量页面),仅展示统计与 URL,不提供跳转访问。SEO / GEO 来源按 referrer 域名归类;爬虫按 User-Agent 识别。
|
||||
</p>
|
||||
</div>
|
||||
|
||||
@@ -164,20 +135,6 @@ export function SeoGeoAnalyticsPage() {
|
||||
|
||||
<section className="admin-card">
|
||||
<div className="admin-actions" style={{ marginBottom: 16, flexWrap: 'wrap', gap: 8 }}>
|
||||
<button
|
||||
type="button"
|
||||
className={channel === 'seo' ? 'send-btn' : 'ghost-btn'}
|
||||
onClick={() => setChannel('seo')}
|
||||
>
|
||||
SEO 流量
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className={channel === 'geo' ? 'send-btn' : 'ghost-btn'}
|
||||
onClick={() => setChannel('geo')}
|
||||
>
|
||||
GEO 流量
|
||||
</button>
|
||||
<button type="button" className="ghost-btn" onClick={() => void load()} disabled={loading}>
|
||||
{loading ? '刷新中…' : '刷新数据'}
|
||||
</button>
|
||||
@@ -196,35 +153,25 @@ export function SeoGeoAnalyticsPage() {
|
||||
<input type="date" value={endDate} min={startDate} onChange={(e) => setEndDate(e.target.value)} />
|
||||
</label>
|
||||
<label className="admin-form-row">
|
||||
<span>生成日期从</span>
|
||||
<span>搜索标题 / 用户 / URL</span>
|
||||
<input
|
||||
type="date"
|
||||
value={generatedStartDate}
|
||||
max={generatedEndDate || undefined}
|
||||
onChange={(e) => setGeneratedStartDate(e.target.value)}
|
||||
/>
|
||||
</label>
|
||||
<label className="admin-form-row">
|
||||
<span>生成日期到</span>
|
||||
<input
|
||||
type="date"
|
||||
value={generatedEndDate}
|
||||
min={generatedStartDate || undefined}
|
||||
onChange={(e) => setGeneratedEndDate(e.target.value)}
|
||||
type="search"
|
||||
value={search}
|
||||
placeholder="例如 john、sitemap、问卷"
|
||||
onChange={(e) => setSearch(e.target.value)}
|
||||
/>
|
||||
</label>
|
||||
<label className="admin-form-row">
|
||||
<span>排序字段</span>
|
||||
<select value={sortBy} onChange={(e) => setSortBy(e.target.value as PageSort)}>
|
||||
<option value="views">浏览量</option>
|
||||
<option value="visitors">访客</option>
|
||||
<option value="visits">访问</option>
|
||||
<option value="clicks">点击</option>
|
||||
<option value="engagedVisits">参与</option>
|
||||
<option value="forms">表单</option>
|
||||
<option value="generatedAt">生成日期</option>
|
||||
<option value="pageTitle">Title</option>
|
||||
<option value="firstSeenAt">首次访问</option>
|
||||
<option value="totalViews">筛选期总访问</option>
|
||||
<option value="seoViews">SEO 搜索来源</option>
|
||||
<option value="geoViews">GEO 来源</option>
|
||||
<option value="crawlerViews">爬虫访问</option>
|
||||
<option value="lifetimeViewCount">累计访问</option>
|
||||
<option value="publishedAt">发布时间</option>
|
||||
<option value="pageTitle">标题</option>
|
||||
<option value="ownerLabel">创建用户</option>
|
||||
</select>
|
||||
</label>
|
||||
<label className="admin-form-row">
|
||||
@@ -238,20 +185,24 @@ export function SeoGeoAnalyticsPage() {
|
||||
|
||||
<div className="admin-metrics-grid" style={{ display: 'flex', gap: 12, flexWrap: 'wrap', marginBottom: 16 }}>
|
||||
<div className="admin-card metric-card" style={{ minWidth: 140, padding: 16 }}>
|
||||
<div className="muted">当前页合计浏览量</div>
|
||||
<strong>{formatNumber(overview.views)}</strong>
|
||||
</div>
|
||||
<div className="admin-card metric-card" style={{ minWidth: 140, padding: 16 }}>
|
||||
<div className="muted">筛选期总浏览量</div>
|
||||
<strong>{formatNumber(totalViews)}</strong>
|
||||
</div>
|
||||
<div className="admin-card metric-card" style={{ minWidth: 140, padding: 16 }}>
|
||||
<div className="muted">有 {channel.toUpperCase()} 流量的页面</div>
|
||||
<div className="muted">在线页面总数</div>
|
||||
<strong>{formatNumber(total)}</strong>
|
||||
</div>
|
||||
<div className="admin-card metric-card" style={{ minWidth: 140, padding: 16 }}>
|
||||
<div className="muted">点击 / 参与 / 表单</div>
|
||||
<strong>{formatNumber(overview.clicks)} / {formatNumber(overview.engaged)} / {formatNumber(overview.forms)}</strong>
|
||||
<div className="muted">筛选期总访问</div>
|
||||
<strong>{formatNumber(totals.totalViews)}</strong>
|
||||
</div>
|
||||
<div className="admin-card metric-card" style={{ minWidth: 140, padding: 16 }}>
|
||||
<div className="muted">SEO 搜索来源</div>
|
||||
<strong>{formatNumber(totals.seoViews)}</strong>
|
||||
</div>
|
||||
<div className="admin-card metric-card" style={{ minWidth: 140, padding: 16 }}>
|
||||
<div className="muted">GEO 来源</div>
|
||||
<strong>{formatNumber(totals.geoViews)}</strong>
|
||||
</div>
|
||||
<div className="admin-card metric-card" style={{ minWidth: 140, padding: 16 }}>
|
||||
<div className="muted">爬虫访问</div>
|
||||
<strong>{formatNumber(totals.crawlerViews)}</strong>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -259,17 +210,17 @@ export function SeoGeoAnalyticsPage() {
|
||||
<table className="admin-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Title</th>
|
||||
<th>页面 URL</th>
|
||||
<th>浏览量</th>
|
||||
<th>访客</th>
|
||||
<th>访问</th>
|
||||
<th>点击</th>
|
||||
<th>参与</th>
|
||||
<th>表单</th>
|
||||
<th>贡献值</th>
|
||||
<th>生成日期</th>
|
||||
<th>筛选期首次访问</th>
|
||||
<th>标题</th>
|
||||
<th>创建用户</th>
|
||||
<th>页面 URL(仅展示)</th>
|
||||
<th>访问模式</th>
|
||||
<th>可收录</th>
|
||||
<th>SEO</th>
|
||||
<th>GEO</th>
|
||||
<th>总访问</th>
|
||||
<th>爬虫</th>
|
||||
<th>累计访问</th>
|
||||
<th>发布时间</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
@@ -279,42 +230,30 @@ export function SeoGeoAnalyticsPage() {
|
||||
</tr>
|
||||
) : rows.length === 0 ? (
|
||||
<tr>
|
||||
<td colSpan={11}>当前日期范围内暂无 {channel.toUpperCase()} 来源的页面访问数据</td>
|
||||
<td colSpan={11}>暂无在线页面</td>
|
||||
</tr>
|
||||
) : (
|
||||
rows.map((row) => {
|
||||
const pageUrl = resolvePageUrl(row);
|
||||
const contribution = totalViews
|
||||
? `${((Number(row.views) / totalViews) * 100).toFixed(2)}%`
|
||||
: '0.00%';
|
||||
return (
|
||||
<tr key={`${row.urlPath}-${row.firstSeenAt ?? ''}`}>
|
||||
<td className="admin-table-path" title={row.pageTitle}>{row.pageTitle}</td>
|
||||
<td className="admin-table-path">
|
||||
<a href={pageUrl} target="_blank" rel="noreferrer" title={pageUrl}>
|
||||
{pageUrl}
|
||||
</a>
|
||||
</td>
|
||||
<td>{formatNumber(row.views)}</td>
|
||||
<td>{formatNumber(row.visitors)}</td>
|
||||
<td>{formatNumber(row.visits)}</td>
|
||||
<td>{formatNumber(row.clicks)}</td>
|
||||
<td>{formatNumber(row.engagedVisits)}</td>
|
||||
<td>{formatNumber(row.forms)}</td>
|
||||
<td>{contribution}</td>
|
||||
<td>
|
||||
{row.generatedAt
|
||||
? `${new Date(row.generatedAt).toLocaleDateString('zh-CN')}${row.generatedAtInferred ? '(首访回推)' : ''}`
|
||||
: '—'}
|
||||
</td>
|
||||
<td>
|
||||
{row.firstSeenAt
|
||||
? new Date(row.firstSeenAt).toLocaleString('zh-CN')
|
||||
: '—'}
|
||||
</td>
|
||||
</tr>
|
||||
);
|
||||
})
|
||||
rows.map((row) => (
|
||||
<tr key={row.publicationId}>
|
||||
<td className="admin-table-path" title={row.pageTitle}>{row.pageTitle}</td>
|
||||
<td>{row.ownerLabel}</td>
|
||||
<td className="admin-table-path">
|
||||
<span title={row.publicUrl}>{row.publicUrl}</span>
|
||||
</td>
|
||||
<td>{accessModeLabel(row.accessMode)}</td>
|
||||
<td>{row.indexable ? '是' : '否'}</td>
|
||||
<td>{formatNumber(row.seoViews)}</td>
|
||||
<td>{formatNumber(row.geoViews)}</td>
|
||||
<td>{formatNumber(row.totalViews)}</td>
|
||||
<td>{formatNumber(row.crawlerViews)}</td>
|
||||
<td>{formatNumber(row.lifetimeViewCount)}</td>
|
||||
<td>
|
||||
{row.publishedAt
|
||||
? new Date(row.publishedAt).toLocaleString('zh-CN')
|
||||
: '—'}
|
||||
</td>
|
||||
</tr>
|
||||
))
|
||||
)}
|
||||
</tbody>
|
||||
</table>
|
||||
|
||||
@@ -549,6 +549,60 @@ export type SeoGeoAnalyticsPagesResult = {
|
||||
pageSize: number;
|
||||
};
|
||||
|
||||
export type SeoGeoCatalogQuery = {
|
||||
startAt: number;
|
||||
endAt: number;
|
||||
page?: number;
|
||||
pageSize?: number;
|
||||
sortBy?: string;
|
||||
sortOrder?: 'asc' | 'desc';
|
||||
search?: string;
|
||||
};
|
||||
|
||||
export type SeoGeoCatalogRow = {
|
||||
publicationId: string;
|
||||
pageId: string;
|
||||
userId: string;
|
||||
ownerLabel: string;
|
||||
username: string;
|
||||
pageTitle: string;
|
||||
pageSummary: string;
|
||||
publicUrl: string;
|
||||
accessMode: string;
|
||||
indexable: boolean;
|
||||
publishedAt: number | null;
|
||||
lifetimeViewCount: number;
|
||||
totalViews: number;
|
||||
seoViews: number;
|
||||
geoViews: number;
|
||||
crawlerViews: number;
|
||||
};
|
||||
|
||||
export type SeoGeoCatalogResult = {
|
||||
data: SeoGeoCatalogRow[];
|
||||
total: number;
|
||||
page: number;
|
||||
pageSize: number;
|
||||
totals: {
|
||||
totalViews: number;
|
||||
seoViews: number;
|
||||
geoViews: number;
|
||||
crawlerViews: number;
|
||||
};
|
||||
};
|
||||
|
||||
export async function getSeoGeoPublicationCatalog(
|
||||
query: SeoGeoCatalogQuery,
|
||||
): Promise<SeoGeoCatalogResult> {
|
||||
const params = new URLSearchParams();
|
||||
Object.entries(query).forEach(([key, value]) => {
|
||||
if (value !== undefined && value !== null && value !== '') {
|
||||
params.set(key, String(value));
|
||||
}
|
||||
});
|
||||
return portalFetch(`/admin-api/analytics/seo-geo-catalog?${params.toString()}`);
|
||||
}
|
||||
|
||||
export async function getSeoGeoAnalyticsPages(
|
||||
query: SeoGeoAnalyticsQuery,
|
||||
): Promise<SeoGeoAnalyticsPagesResult> {
|
||||
|
||||
Reference in New Issue
Block a user