feat(analytics): add SEO/GEO page dashboard and retire Rybbit SSO

Expose Umami memind-pages discoveryChannel data in adm with a public-page
style table, proxy it via admin-api, and remove Rybbit configuration paths.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
john
2026-08-12 01:34:01 +08:00
parent ed8bc6d1b1
commit 0e26db1857
10 changed files with 567 additions and 37 deletions
+3 -4
View File
@@ -41,11 +41,10 @@ VITE_MAIN_APP_URL=https://h5.tkmind.cn
# Umami 仅允许从 MemindAdm 单点登录进入(必须与 memind-analytics 一致)
MEMIND_UMAMI_SSO_SECRET=replace-with-the-same-random-secret-used-by-umami
UMAMI_SSO_USERNAME=admin
# SEO/GEO 看板拉取 Umami memind-pages API 时使用(勿提交到 Git)
# UMAMI_ADMIN_PASSWORD=change-me-umami-admin
# Rybbit 仅允许从 MemindAdm 单点登录进入(必须与 105 Rybbit 一致)
MEMIND_RYBBIT_SSO_SECRET=replace-with-the-same-random-secret-used-by-rybbit
RYBBIT_SSO_EMAIL=admin@tkmind.cn
RYBBIT_URL=https://rybbit.tkmind.cn
# Rybbit 已退役(2026-08),勿再配置 MEMIND_RYBBIT_* / RYBBIT_*
# Plaza 帖子预览链接
# VITE_PLAZA_BASE=https://plaza.tkmind.cn
+1
View File
@@ -12,6 +12,7 @@
"local_restart": "bash scripts/local_restart.sh",
"pro_restart": "bash scripts/pro_restart.sh",
"build": "vite build",
"test:umami-analytics": "node --test server/umami-analytics.test.mjs",
"test:orchestrator": "node --test server/orchestrator-routes.test.mjs",
"verify:template-catalog-local": "node scripts/verify-template-catalog-local.mjs",
"preview": "node scripts/preview.mjs",
+17 -12
View File
@@ -5,6 +5,7 @@ import { spawn } from 'node:child_process';
import { fileURLToPath } from 'node:url';
import express from 'express';
import { listUsagePaged, listLedgerPaged, getUsageStats, getUsageSummary } from './pagination.mjs';
import { fetchMemindDiscoveryPages } from './umami-analytics.mjs';
const projectRoot = path.join(path.dirname(fileURLToPath(import.meta.url)), '..');
@@ -236,19 +237,23 @@ export function createAdminApp(services) {
res.json({ url: `${baseUrl}/auth/memind?ticket=${encoded}.${signature}` });
});
adminApi.get('/analytics/seo-geo-pages', requireAdmin, async (req, res) => {
try {
const config = loadMindSpaceConfig ? await loadMindSpaceConfig(pool) : null;
const result = await fetchMemindDiscoveryPages(req.query, {
env: process.env,
analyticsConfig: config?.analytics ?? {},
});
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) => {
const sharedSecret = process.env.MEMIND_RYBBIT_SSO_SECRET?.trim();
if (!sharedSecret) return res.status(503).json({ message: '未配置 Rybbit 单点登录密钥' });
const email = process.env.RYBBIT_SSO_EMAIL?.trim().toLowerCase();
if (!email) return res.status(503).json({ message: '未配置 Rybbit 单点登录账号' });
const baseUrl = String(process.env.RYBBIT_URL || 'https://rybbit.tkmind.cn').replace(/\/$/, '');
const encoded = Buffer.from(JSON.stringify({
email,
exp: Math.floor(Date.now() / 1000) + 60,
nonce: crypto.randomUUID(),
})).toString('base64url');
const signature = crypto.createHmac('sha256', sharedSecret).update(encoded).digest('base64url');
res.json({ url: `${baseUrl}/api/auth/memind?ticket=${encoded}.${signature}` });
return res.status(410).json({ message: 'Rybbit 已退役,请使用 Umami 与 SEO/GEO 流量看板' });
});
adminApi.get('/users', requireAdmin, async (req, res) => {
+110
View File
@@ -0,0 +1,110 @@
const TOKEN_TTL_MS = 55 * 60 * 1000;
let cachedToken = '';
let cachedTokenAt = 0;
let cachedBaseUrl = '';
function normalizeBaseUrl(value = '') {
return String(value ?? '').trim().replace(/\/$/, '');
}
function resolveUmamiCredentials(env = process.env, analyticsConfig = {}) {
const baseUrl = normalizeBaseUrl(
analyticsConfig?.analyticsUrl || env.UMAMI_URL || 'http://127.0.0.1:3100',
);
const username = String(env.UMAMI_SSO_USERNAME || env.UMAMI_ADMIN_USERNAME || 'admin').trim();
const password = String(env.UMAMI_ADMIN_PASSWORD || '').trim();
const websiteId = String(analyticsConfig?.websiteId || env.UMAMI_WEBSITE_ID || '').trim();
return { baseUrl, username, password, websiteId };
}
async function loginUmami({ baseUrl, username, password }) {
if (!password) {
throw new Error('未配置 UMAMI_ADMIN_PASSWORD,无法拉取 SEO/GEO 页面统计');
}
const response = await fetch(`${baseUrl}/api/auth/login`, {
method: 'POST',
headers: { 'content-type': 'application/json' },
body: JSON.stringify({ username, password }),
signal: AbortSignal.timeout(10_000),
});
const body = await response.json().catch(() => ({}));
if (!response.ok) {
throw new Error(body?.message || `Umami 登录失败 (${response.status})`);
}
const token = String(body?.token ?? '').trim();
if (!token) throw new Error('Umami 登录响应缺少 token');
return token;
}
async function getUmamiAuthToken(credentials) {
const now = Date.now();
if (
cachedToken &&
cachedBaseUrl === credentials.baseUrl &&
now - cachedTokenAt < TOKEN_TTL_MS
) {
return cachedToken;
}
cachedToken = await loginUmami(credentials);
cachedTokenAt = now;
cachedBaseUrl = credentials.baseUrl;
return cachedToken;
}
export async function fetchMemindDiscoveryPages(
query = {},
{ env = process.env, analyticsConfig = {} } = {},
) {
const credentials = resolveUmamiCredentials(env, analyticsConfig);
if (!credentials.websiteId) {
throw new Error('未配置 Umami Website ID');
}
const channel = String(query.discoveryChannel ?? query.channel ?? '').trim().toLowerCase();
if (channel !== 'seo' && channel !== 'geo') {
throw new Error('discoveryChannel 必须是 seo 或 geo');
}
const params = new URLSearchParams();
params.set('discoveryChannel', channel);
params.set('page', String(Math.max(Number(query.page) || 1, 1)));
params.set('pageSize', String(Math.min(Math.max(Number(query.pageSize) || 10, 1), 100)));
params.set('sortBy', String(query.sortBy || 'views'));
params.set('sortOrder', String(query.sortOrder || 'desc'));
if (query.startAt) params.set('startAt', String(query.startAt));
if (query.endAt) params.set('endAt', String(query.endAt));
if (query.timezone) params.set('timezone', String(query.timezone));
if (query.generatedStartAt) params.set('generatedStartAt', String(query.generatedStartAt));
if (query.generatedEndAt) params.set('generatedEndAt', String(query.generatedEndAt));
const token = await getUmamiAuthToken(credentials);
const response = await fetch(
`${credentials.baseUrl}/api/websites/${encodeURIComponent(credentials.websiteId)}/memind-pages?${params.toString()}`,
{
headers: {
authorization: `Bearer ${token}`,
'content-type': 'application/json',
},
signal: AbortSignal.timeout(15_000),
},
);
const body = await response.json().catch(() => ({}));
if (!response.ok) {
throw new Error(body?.message || `Umami memind-pages 请求失败 (${response.status})`);
}
return {
discoveryChannel: channel,
...body,
};
}
export function resolvePublicPageUrl(row = {}, fallbackHost = 'm.tkmind.cn') {
const exact = String(row.pageUrl ?? '').trim();
if (/^https?:\/\//i.test(exact)) return exact.split('#')[0];
const hostname = String(row.hostname ?? '').trim();
if (hostname === '127.0.0.1' || hostname === 'localhost') {
return `http://${hostname}:8081${row.urlPath}`;
}
const host = hostname || fallbackHost;
return `https://${host}${row.urlPath}`;
}
+34
View File
@@ -0,0 +1,34 @@
import assert from 'node:assert/strict';
import test from 'node:test';
import { fetchMemindDiscoveryPages } from './umami-analytics.mjs';
test('fetchMemindDiscoveryPages rejects invalid discoveryChannel', async () => {
await assert.rejects(
() => fetchMemindDiscoveryPages(
{ discoveryChannel: 'direct' },
{ env: { UMAMI_ADMIN_PASSWORD: 'secret' }, analyticsConfig: { websiteId: 'site-1' } },
),
/discoveryChannel 必须是 seo 或 geo/,
);
});
test('fetchMemindDiscoveryPages rejects missing website id', async () => {
await assert.rejects(
() => fetchMemindDiscoveryPages(
{ discoveryChannel: 'seo', startAt: '1', endAt: '2' },
{ env: { UMAMI_ADMIN_PASSWORD: 'secret' }, analyticsConfig: {} },
),
/未配置 Umami Website ID/,
);
});
test('fetchMemindDiscoveryPages rejects missing admin password', async () => {
await assert.rejects(
() => fetchMemindDiscoveryPages(
{ discoveryChannel: 'geo', startAt: '1', endAt: '2' },
{ env: {}, analyticsConfig: { websiteId: 'site-1' } },
),
/未配置 UMAMI_ADMIN_PASSWORD/,
);
});
+3
View File
@@ -20,6 +20,7 @@ import { WechatPage } from './admin/pages/WechatPage';
import { AssetGatewayPage } from './admin/pages/AssetGatewayPage';
import { BlockedWordsPage } from './admin/pages/BlockedWordsPage';
import { AnalyticsConfigPage } from './admin/pages/AnalyticsConfigPage';
import { SeoGeoAnalyticsPage } from './admin/pages/SeoGeoAnalyticsPage';
import { SkillRuntimePage } from './admin/pages/SkillRuntimePage';
import { OrchestratorPage } from './admin/pages/OrchestratorPage';
import { defaultHomePath } from './lib/routes';
@@ -129,6 +130,7 @@ function AdminApp({ user, onLogout }: { user: PortalUser; onLogout: () => void }
<Route path="policies" element={<PoliciesPage />} />
<Route path="mindspace" element={<MindSpacePage />} />
<Route path="analytics" element={<AnalyticsConfigPage />} />
<Route path="analytics/seo-geo" element={<SeoGeoAnalyticsPage />} />
<Route path="memory-v2" element={<MemoryV2Page />} />
<Route path="mindsearch" element={<MindSearchPage />} />
<Route path="skill-runtime" element={<SkillRuntimePage />} />
@@ -181,6 +183,7 @@ function loginRedirectPath(pathname: string, role: string | undefined) {
|| pathname.startsWith('/system-tests')
|| pathname.startsWith('/policies')
|| pathname.startsWith('/mindspace')
|| pathname.startsWith('/analytics')
|| pathname.startsWith('/memory-v2')
|| pathname.startsWith('/skill-runtime')
|| pathname.startsWith('/orchestrator')
+1
View File
@@ -33,6 +33,7 @@ const NAV_SECTIONS: NavSection[] = [
{ to: '/wechat', label: '服务号' },
{ to: '/mindspace', label: 'MindSpace 配置' },
{ to: '/analytics', label: 'Analytics 配置' },
{ to: '/analytics/seo-geo', label: 'SEO / GEO 流量' },
{ to: '/memory-v2', label: 'Memory V2' },
{ to: '/mindsearch', label: 'MindSearch' },
{ to: '/skill-runtime', label: 'Skill Runtime' },
+7 -19
View File
@@ -1,5 +1,6 @@
import { useCallback, useEffect, useState, type FormEvent } from 'react';
import { getMindSpaceAdminConfig, getRybbitSsoUrl, getUmamiSsoUrl, updateMindSpaceAdminConfig } from '../../api/client';
import { Link } from 'react-router-dom';
import { getMindSpaceAdminConfig, getUmamiSsoUrl, updateMindSpaceAdminConfig } from '../../api/client';
const initial = {
enabled: false,
@@ -17,7 +18,6 @@ export function AnalyticsConfigPage() {
const [error, setError] = useState<string | null>(null);
const [message, setMessage] = useState<string | null>(null);
const [openingUmami, setOpeningUmami] = useState(false);
const [openingRybbit, setOpeningRybbit] = useState(false);
const load = useCallback(async () => {
setLoading(true);
@@ -63,28 +63,15 @@ export function AnalyticsConfigPage() {
}
};
const openRybbit = async () => {
setOpeningRybbit(true);
setError(null);
try {
window.location.assign(await getRybbitSsoUrl());
} catch (err) {
setError(err instanceof Error ? err.message : '无法打开 Rybbit');
setOpeningRybbit(false);
}
};
return <div className="admin-page">
<div className="admin-page-head"><h2>Analytics </h2><p className="muted"> Memind Umami </p></div>
<div className="admin-page-head"><h2>Analytics </h2><p className="muted"> Umami Rybbit 退 Memind analytics-platform.md</p></div>
{error && <p className="banner banner-error">{error}</p>}
{message && <p className="banner banner-info">{message}</p>}
<section className="admin-card">
<h2>Rybbit </h2>
<p className="muted">访</p>
<h2>SEO / GEO </h2>
<p className="muted">访 Umami</p>
<div className="admin-actions">
<button type="button" className="send-btn" onClick={() => void openRybbit()} disabled={openingRybbit}>
{openingRybbit ? '正在进入 Rybbit...' : '打开 Rybbit 分析后台'}
</button>
<Link to="/analytics/seo-geo" className="send-btn"> SEO / GEO </Link>
</div>
</section>
<section className="admin-card">
@@ -96,6 +83,7 @@ export function AnalyticsConfigPage() {
<label className="admin-form-row"><span>Umami </span><input value={config.analyticsUrl} disabled={loading || busy} onChange={(e) => setConfig({ ...config, analyticsUrl: e.target.value })} placeholder="http://127.0.0.1:3100" /></label>
<label className="admin-form-row"><span></span><input value={config.domains} disabled={loading || busy} onChange={(e) => setConfig({ ...config, domains: e.target.value })} placeholder="127.0.0.1,localhost" /></label>
<label className="admin-form-row"><span> {config.idSecretConfigured ? '(已配置,留空保持不变)' : ''}</span><input type="password" value={secret} disabled={loading || busy} onChange={(e) => setSecret(e.target.value)} placeholder={config.idSecretConfigured ? '留空保持当前密钥' : '输入本地随机密钥'} autoComplete="new-password" /></label>
<p className="muted">SEO/GEO memind_adm <code>UMAMI_ADMIN_PASSWORD</code> Umami admin </p>
<div className="admin-actions"><button type="submit" className="send-btn" disabled={busy || loading}>{busy ? '保存中...' : '保存配置'}</button><button type="button" className="ghost-btn" onClick={() => void load()} disabled={busy}></button></div>
</form>
</section>
+342
View File
@@ -0,0 +1,342 @@
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;
};
type PageSort =
| 'generatedAt'
| 'views'
| 'visitors'
| 'visits'
| 'clicks'
| 'engagedVisits'
| 'forms'
| 'pageTitle'
| 'firstSeenAt';
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);
start.setDate(start.getDate() - 29);
return {
startDate: start.toISOString().slice(0, 10),
endDate: end.toISOString().slice(0, 10),
};
}
function toRangeMs(startDate: string, endDate: string) {
const startAt = new Date(`${startDate}T00:00:00`).getTime();
const endAt = new Date(`${endDate}T23:59:59.999`).getTime();
return { startAt, endAt };
}
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 [sortOrder, setSortOrder] = useState<'asc' | 'desc'>('desc');
const [page, setPage] = useState(1);
const [rows, setRows] = useState<PageRow[]>([]);
const [total, setTotal] = useState(0);
const [totalViews, setTotalViews] = useState(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 load = useCallback(async () => {
setLoading(true);
setError(null);
try {
const { startAt, endAt } = toRangeMs(startDate, endDate);
const result = await getSeoGeoAnalyticsPages({
discoveryChannel: channel,
startAt,
endAt,
timezone: 'Asia/Shanghai',
page,
pageSize: 10,
sortBy,
sortOrder,
generatedStartAt: generatedStartDate
? new Date(`${generatedStartDate}T00:00:00`).getTime()
: undefined,
generatedEndAt: generatedEndDate
? new Date(`${generatedEndDate}T23:59:59.999`).getTime()
: undefined,
});
setRows(Array.isArray(result.data) ? result.data : []);
setTotal(Number(result.total) || 0);
setTotalViews(Number(result.totalViews) || 0);
} catch (err) {
setError(err instanceof Error ? err.message : '加载 SEO/GEO 页面统计失败');
setRows([]);
setTotal(0);
setTotalViews(0);
} finally {
setLoading(false);
}
}, [
channel,
endDate,
generatedEndDate,
generatedStartDate,
page,
sortBy,
sortOrder,
startDate,
]);
useEffect(() => {
void load();
}, [load]);
useEffect(() => {
setPage(1);
}, [channel, startDate, endDate, generatedStartDate, generatedEndDate, sortBy, sortOrder]);
const openUmami = async () => {
setOpeningUmami(true);
setError(null);
try {
window.location.assign(await getUmamiSsoUrl());
} catch (err) {
setError(err instanceof Error ? err.message : '无法打开 Umami');
setOpeningUmami(false);
}
};
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">
SEOGEO访 Umami
</p>
</div>
{error && <p className="banner banner-error">{error}</p>}
<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>
<button type="button" className="ghost-btn" onClick={() => void openUmami()} disabled={openingUmami}>
{openingUmami ? '正在进入 Umami…' : '打开 Umami 完整看板'}
</button>
</div>
<div className="admin-form" style={{ marginBottom: 16 }}>
<label className="admin-form-row">
<span></span>
<input type="date" value={startDate} max={endDate} onChange={(e) => setStartDate(e.target.value)} />
</label>
<label className="admin-form-row">
<span></span>
<input type="date" value={endDate} min={startDate} onChange={(e) => setEndDate(e.target.value)} />
</label>
<label className="admin-form-row">
<span></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)}
/>
</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>
</select>
</label>
<label className="admin-form-row">
<span></span>
<select value={sortOrder} onChange={(e) => setSortOrder(e.target.value as 'asc' | 'desc')}>
<option value="desc"></option>
<option value="asc"></option>
</select>
</label>
</div>
<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>
<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>
</div>
<div className="admin-table-wrap">
<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>
</tr>
</thead>
<tbody>
{loading ? (
<tr>
<td colSpan={11}></td>
</tr>
) : rows.length === 0 ? (
<tr>
<td colSpan={11}> {channel.toUpperCase()} 访</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>
);
})
)}
</tbody>
</table>
</div>
<div className="admin-actions" style={{ marginTop: 16 }}>
<button type="button" className="ghost-btn" disabled={page <= 1 || loading} onClick={() => setPage((p) => p - 1)}>
</button>
<span className="muted">
{page} / {pageCount} {formatNumber(total)}
</span>
<button
type="button"
className="ghost-btn"
disabled={page >= pageCount || loading}
onClick={() => setPage((p) => p + 1)}
>
</button>
</div>
</section>
</div>
);
}
+49 -2
View File
@@ -513,9 +513,56 @@ export async function getUmamiSsoUrl(): Promise<string> {
return result.url;
}
export type SeoGeoAnalyticsQuery = {
discoveryChannel: 'seo' | 'geo';
startAt: number;
endAt: number;
timezone?: string;
page?: number;
pageSize?: number;
sortBy?: string;
sortOrder?: 'asc' | 'desc';
generatedStartAt?: number;
generatedEndAt?: number;
};
export type SeoGeoAnalyticsPagesResult = {
discoveryChannel: 'seo' | 'geo';
data: Array<{
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;
}>;
total: number;
totalViews: number;
page: number;
pageSize: number;
};
export async function getSeoGeoAnalyticsPages(
query: SeoGeoAnalyticsQuery,
): Promise<SeoGeoAnalyticsPagesResult> {
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-pages?${params.toString()}`);
}
export async function getRybbitSsoUrl(): Promise<string> {
const result = await portalFetch<{ url: string }>('/admin-api/analytics/rybbit-sso');
return result.url;
throw new Error('Rybbit 已退役,请使用 SEO / GEO 流量看板或 Umami');
}
export async function updateMindSpaceAdminConfig(