Compare commits

...

2 Commits

Author SHA1 Message Date
john c79e1b7a35 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>
2026-08-12 16:41:39 +08:00
john 0da7f04878 docs(ops): 统一 md.tkmind.cn 域名与 105→103 反代拓扑
将管理后台公网域名从 gadm 更正为 md.tkmind.cn,补充 105 nginx 反代 103 的部署说明,
默认 VITE_BASE_PATH=/ 以支持 /analytics/seo-geo 根路径访问。

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-10 17:38:31 +08:00
16 changed files with 287 additions and 245 deletions
+4 -4
View File
@@ -2,10 +2,10 @@
ADM_API_PORT=8085
ADM_DEV_BACKEND=http://127.0.0.1:8085
# gadm 独立登录(会话 cookie 绑定 gadm 域名;勿设 H5_COOKIE_DOMAIN=.tkmind.cn
H5_PUBLIC_BASE_URL=https://gadm.tkmind.cn
# md.tkmind.cn 独立登录(会话 cookie 绑定 md 域名;勿设 H5_COOKIE_DOMAIN=.tkmind.cn
H5_PUBLIC_BASE_URL=https://md.tkmind.cn
# 前端根路径部署(https://gadm.tkmind.cn/ 直达后台;Plaza 运营在 /ops
# 前端根路径部署(https://md.tkmind.cn/ 直达后台;SEO/GEO 看板 /analytics/seo-geo
# ── MySQL(与 Memind 共用远程库,配置在本项目 .env)──
DATABASE_URL=mysql://boot:password@host:3306/goose
# 或分别设置:
@@ -27,7 +27,7 @@ H5_USERS_ROOT=/Users/john/Project/memind_adm/data/users
# TKMIND_SERVER__SECRET_KEY=local-dev-secret
# 套餐同步到生产后台(可选;配置后套餐增删改会自动镜像到目标后台)
# PLAN_SYNC_TARGET_BASE_URL=https://gadm.tkmind.cn
# PLAN_SYNC_TARGET_BASE_URL=https://md.tkmind.cn
# PLAN_SYNC_USERNAME=admin
# PLAN_SYNC_PASSWORD=change-me-admin
# PLAN_SYNC_TIMEOUT_MS=10000
+2 -2
View File
@@ -22,7 +22,7 @@ bash scripts/check-release-ready.sh
## 必读:本仓库是唯一合法的管理后台 UI(5174)
**所有平台管理后台的前端功能只能在本仓库(memind_adm)开发,本地端口 5174,生产 gadm。禁止在 Memind 仓库的 `ops/`(约 3002)新增任何管理页面、导航或 API 客户端。**
**所有平台管理后台的前端功能只能在本仓库(memind_adm)开发,本地端口 5174,生产 [md.tkmind.cn](https://md.tkmind.cn)。禁止在 Memind 仓库的 `ops/`(约 3002)新增任何管理页面、导航或 API 客户端。**
| 组件 | 端口 | 职责 |
|------|------|------|
@@ -38,5 +38,5 @@ bash scripts/check-release-ready.sh
- 页面:`src/admin/pages/AnalyticsConfigPage.tsx``SeoGeoAnalyticsPage.tsx`
- API`server/umami-analytics.mjs` → 105 Umami `memind-pages`
- 部署与环境变量:[docs/analytics-deploy.md](docs/analytics-deploy.md)
- 跨仓库发布顺序:Memind `docs/analytics-release-runbook.md`105 Umami → 103 Portal → gadm
- 跨仓库发布顺序:Memind `docs/analytics-release-runbook.md`105 Umami → 103 Portal → md.tkmind.cn
- Rybbit 已退役,勿恢复相关配置
+29 -11
View File
@@ -11,12 +11,29 @@
| 项 | 默认值 |
|---|---|
| **公网域名** | `https://md.tkmind.cn`DNS → **105** `120.26.184.105` |
| **应用进程** | 跑在 **103** Studio`5174` preview + `8085` Admin API |
| **105 角色** | HTTPS 终止 + nginx 反代到 103**不在 105 跑 memind_adm** |
| 部署目标 | `john@10.10.0.2:/Users/john/Project/memind_adm`fallback `john@58.38.22.103` |
| 服务端口 | `5174` |
| 运行方式 | `vite preview`(静态资源 + API 反代) |
| 后端 API | 本仓库 `server/``ADM_API_PORT` 默认 `8085`,与 Memind portal 解耦) |
| 日志 | `/Users/john/Project/memind_adm/adm-preview.log` |
### 公网访问路径(105 → 103
```text
浏览器 https://md.tkmind.cn
→ 105 nginx/etc/nginx/conf.d/md.tkmind.cn.conf
→ 103:5174 /、静态与 SPA
→ 103:8085 /auth、/admin-api、/api
gadm.tkmind.cn → 301 到 md.tkmind.cn105 nginx
```
105 活动 upstream 当前为 `58.38.22.103`103 公网 IP);内网 `10.10.0.2` 可达时可改用 LAN 地址。
nginx 示例:`scripts/md-nginx-105.conf`(与 105 线上一致)。
## 硬规则
`memind_adm` 允许项目独立,**不允许用户体系、权限体系、技能体系、策略体系独立**。
@@ -57,19 +74,20 @@
`MEMIND_LIB_ROOT=/Users/john/Project/Memind`
`H5_USERS_ROOT=/Users/john/Project/Memind/users`
## gadm 独立登录
## md.tkmind.cn 独立登录
`https://gadm.tkmind.cn` 应部署 **本仓库 memind_adm**,勿再反代到 `Memind/ops` Vite dev(旧版未登录会跳 `localhost:5173`)。
`https://md.tkmind.cn` 应部署 **本仓库 memind_adm**,勿再反代到 `Memind/ops` Vite dev(旧版未登录会跳 `localhost:5173`)。
| 项 | 说明 |
|---|---|
| 登录 | 本页 `/auth/login`,账号来自共用 MySQL |
| 会话 | 设置 `H5_PUBLIC_BASE_URL=https://gadm.tkmind.cn`**不要**设 `H5_COOKIE_DOMAIN=.tkmind.cn` |
| 路径 | `https://gadm.tkmind.cn/` 直达超管后台;Plaza 运营在 `/ops``/ops/admin` 已废弃 |
| 会话 | 设置 `H5_PUBLIC_BASE_URL=https://md.tkmind.cn`**不要**设 `H5_COOKIE_DOMAIN=.tkmind.cn` |
| 路径 | 公网 `https://md.tkmind.cn/` 直达超管后台;Analytics / SEO·GEO 在 `/analytics`、`/analytics/seo-geo`Plaza 运营仍在 `/ops` |
| API | `ADM_DEV_BACKEND=http://127.0.0.1:8085`preview 反代目标) |
| 初始化 admin | `npm run admin:init` 只更新 `Memind` 主用户表中的 admin 密码,不创建独立后台账号 |
nginx 示例见 `scripts/gadm-nginx.conf.example``/ops/` → preview`/auth` `/admin-api` `/api` → Admin API `8085`)。
nginx 示例见 `scripts/md-nginx.conf.example``/` → preview`/auth` `/admin-api` `/api` → Admin API `8085`)。
105 公网入口见 `scripts/md-nginx-105.conf`。
## 当前规则
@@ -187,8 +205,8 @@ ssh john@10.10.0.2 'tail -f /Users/john/Project/memind_adm/adm-preview.log'
# preview 反代目标(/auth、/admin-api、/api)— 指向本仓库 Admin API
ADM_API_PORT=8085
ADM_DEV_BACKEND=http://127.0.0.1:8085
H5_PUBLIC_BASE_URL=https://gadm.tkmind.cn
VITE_BASE_PATH=/ops
H5_PUBLIC_BASE_URL=https://md.tkmind.cn
VITE_BASE_PATH=/
# 共享 Memind 主库与主实现
DATABASE_URL=mysql://<user>:<password>@<host>:3306/<db>
@@ -196,7 +214,7 @@ MEMIND_LIB_ROOT=/Users/john/Project/Memind
H5_USERS_ROOT=/Users/john/Project/Memind/users
# 套餐自动同步到生产后台(可选)
PLAN_SYNC_TARGET_BASE_URL=https://gadm.tkmind.cn
PLAN_SYNC_TARGET_BASE_URL=https://md.tkmind.cn
PLAN_SYNC_USERNAME=admin
PLAN_SYNC_PASSWORD=<admin-password>
PLAN_SYNC_TIMEOUT_MS=10000
@@ -267,14 +285,14 @@ PY'
| 文档 | 内容 |
|------|------|
| [docs/analytics-deploy.md](analytics-deploy.md) | gadm 环境变量、验收、故障排查 |
| Memind [docs/analytics-release-runbook.md](../Memind/docs/analytics-release-runbook.md) | 跨仓库发布顺序(105 → 103 Portal → gadm |
| [docs/analytics-deploy.md](analytics-deploy.md) | md.tkmind.cn Analytics / SEO·GEO 配置 |
| Memind [docs/analytics-release-runbook.md](../Memind/docs/analytics-release-runbook.md) | 跨仓库发布顺序(105 → 103 Portal → md.tkmind.cn |
生产 `.env` 至少配置:
```env
UMAMI_ADMIN_PASSWORD=<Umami admin 密码>
MEMIND_UMAMI_SSO_SECRET=<与 105 Umami .env 一致>
UMAMI_SSO_USERNAME=admin
```
可选:`UMAMI_URL`、`UMAMI_WEBSITE_ID`、`UMAMI_SSO_USERNAME`。
+16 -15
View File
@@ -5,7 +5,9 @@
平台边界与字段口径见 Memind [docs/analytics-platform.md](https://git.tkmind.cn/tkmind/Memind/src/branch/main/docs/analytics-platform.md)。
跨仓库发布顺序见 Memind [docs/analytics-release-runbook.md](https://git.tkmind.cn/tkmind/Memind/src/branch/main/docs/analytics-release-runbook.md)。
## 功能入口(gadm 5174
## 功能入口(md.tkmind.cn · 103 进程 · 105 反代
生产 URL**https://md.tkmind.cn/analytics/seo-geo**
| 路由 | 页面 | 说明 |
|------|------|------|
@@ -35,22 +37,22 @@ adm **不**直连浏览器到 Umami 写库;读统计通过服务端用 Umami
`/Users/john/Project/memind_adm/.env` 增加或确认:
```env
# SEO/GEO 看板(必填
UMAMI_ADMIN_PASSWORD=<Umami admin 密码>
# 管理后台公网域名(会话 cookie 绑定 md 域名
H5_PUBLIC_BASE_URL=https://md.tkmind.cn
# SEO/GEO 看板 + Umami SSO(必填,与 105 Umami .env 一致)
MEMIND_UMAMI_SSO_SECRET=<与 /opt/memind-analytics/.env 一致>
UMAMI_SSO_USERNAME=admin
# 可选:覆盖默认
UMAMI_URL=https://fxi.tkmind.cn
UMAMI_WEBSITE_ID=<Memind Generated Pages 的 Website ID>
UMAMI_SSO_USERNAME=admin
# Umami SSOAnalytics 配置页)
MEMIND_UMAMI_SSO_SECRET=<与 105 /opt/memind-analytics/.env 一致>
UMAMI_WEBSITE_ID=<Memind 公共页面 Website ID>
```
说明:
- `UMAMI_ADMIN_PASSWORD` 未配置时,SEO/GEO 看板 API 会报错「未配置 UMAMI_ADMIN_PASSWORD
- `websiteId` 也可在 gadm **Analytics 配置** 页面保存(写入 MindSpace admin 配置),服务端优先读库内配置
- 105 生产 Umami 启用 `MEMIND_UMAMI_SSO_ONLY=1` 时,须配置 `MEMIND_UMAMI_SSO_SECRET`;勿依赖 `UMAMI_ADMIN_PASSWORD`
- `websiteId` 也可在 **Analytics 配置** 页面保存(写入 MindSpace admin 配置),服务端优先读库内配置
- 103 必须能访问 `UMAMI_URL`(内网或公网,依网络拓扑)
## 发布
@@ -75,13 +77,13 @@ bash scripts/release-prod.sh --yes
ssh john@10.10.0.2 'curl -sf http://127.0.0.1:8085/health'
# SEO/GEO API(需替换 cookie 或在内网浏览器登录后测)
# 期望 200 + pages 数组,而非 500 / 未配置 UMAMI_ADMIN_PASSWORD
# 期望 200 + pages 数组,而非 500 / 未配置 MEMIND_UMAMI_SSO_SECRET
```
浏览器:
1. `https://gadm.tkmind.cn/analytics` → 「打开 Umami 分析后台」跳转成功
2. `https://gadm.tkmind.cn/analytics/seo-geo` → 表格加载;SEO / GEO Tab 切换正常
1. `https://md.tkmind.cn/analytics` → 「打开 Umami 分析后台」跳转成功
2. `https://md.tkmind.cn/analytics/seo-geo` → 表格加载;SEO / GEO Tab 切换正常
3. 列口径与 Umami「所有生成页面明细 / public page」一致(页面、PV、UV、点击/表单/参与率等)
## 本地开发
@@ -94,7 +96,6 @@ pnpm dev # 5174
```env
UMAMI_URL=http://127.0.0.1:3100
UMAMI_ADMIN_PASSWORD=umami
UMAMI_WEBSITE_ID=<local website id>
MEMIND_UMAMI_SSO_SECRET=<local secret>
```
@@ -105,7 +106,7 @@ Memind 本地 analytics 变量见 [local-analytics.md](https://git.tkmind.cn/tkm
| 现象 | 检查 |
|------|------|
| SEO/GEO 看板 500 | 103 `adm-api.log``UMAMI_ADMIN_PASSWORD`105 Umami 是否 active |
| SEO/GEO 看板 500 | 103 `adm-api.log``MEMIND_UMAMI_SSO_SECRET`105 Umami 是否 active |
| Umami SSO 失败 | `MEMIND_UMAMI_SSO_SECRET` 与 105 `.env` 是否一致 |
| 看板无 SEO/GEO 行 | Memind discovery 是否已发布;Umami 是否有带 `discovery_channel` 的事件 |
| Rybbit 链接/报错 | 清理旧书签;代码已移除 Rybbit SSO |
+3 -3
View File
@@ -33,6 +33,6 @@ scripts/local_restart.sh
scripts/pro_restart.sh
scripts/rsync_to_server.sh
scripts/audit-103-state.sh
scripts/gadm-nginx-105.conf
scripts/gadm-nginx.conf.example
scripts/gadm-lb.Caddyfile
scripts/md-nginx-105.conf
scripts/md-nginx.conf.example
scripts/md-lb.Caddyfile
-11
View File
@@ -1,11 +0,0 @@
# gadm.tkmind.cn → memind_admvite preview :5174 + Admin API :8085
# 生产 nginx 需把 /ops/ 反代到 5174,勿再指向 Memind/ops dev server
:8091 {
reverse_proxy 127.0.0.1:5174 {
flush_interval -1
transport http {
read_timeout 0
write_timeout 0
}
}
}
+12
View File
@@ -0,0 +1,12 @@
# md.tkmind.cn → memind_admvite preview :5174 + Admin API :8085
# 生产 nginx 需把 / 反代到 5174/auth /admin-api /api 反代到 8085
:8091 {
reverse_proxy 127.0.0.1:5174 {
flush_interval -1
transport http {
read_timeout 0
write_timeout 0
}
}
}
@@ -1,10 +1,13 @@
# gadm.tkmind.cn @ 105 nginx → Studio LAN 10.10.0.2fallback 58.38.22.103
# memind_adm: preview :5174, Admin API :8085(根路径部署,/ 直达后台)
# md.tkmind.cn @ 105 nginx → Studio 10358.38.22.103
# memind_adm 进程在 103;105 仅做 HTTPS 入口与反代,不跑 adm 应用。
# preview :5174 / Admin API :8085
#
# 内网可达时 upstream 可改为 10.10.0.2105 当前活动配置使用公网 IP 58.38.22.103。
server {
listen 80;
listen [::]:80;
server_name gadm.tkmind.cn;
server_name md.tkmind.cn;
location /.well-known/acme-challenge/ {
root /var/www/certbot;
@@ -18,9 +21,9 @@ server {
server {
listen 443 ssl http2;
listen [::]:443 ssl http2;
server_name gadm.tkmind.cn;
ssl_certificate /etc/letsencrypt/live/gadm.tkmind.cn/fullchain.pem;
ssl_certificate_key /etc/letsencrypt/live/gadm.tkmind.cn/privkey.pem;
server_name md.tkmind.cn;
ssl_certificate /etc/letsencrypt/live/md.tkmind.cn/fullchain.pem;
ssl_certificate_key /etc/letsencrypt/live/md.tkmind.cn/privkey.pem;
include /etc/letsencrypt/options-ssl-nginx.conf;
ssl_dhparam /etc/letsencrypt/ssl-dhparams.pem;
@@ -28,8 +31,13 @@ server {
return 301 /;
}
# 旧 VITE_BASE_PATH=/ops 下的 admin 深链 → 根路径
location = /ops/analytics/seo-geo {
return 301 /analytics/seo-geo;
}
location /auth {
proxy_pass http://10.10.0.2:8085;
proxy_pass http://58.38.22.103:8085;
proxy_http_version 1.1;
proxy_set_header Host $host;
proxy_set_header X-Forwarded-Host $host;
@@ -42,7 +50,7 @@ server {
}
location /admin-api {
proxy_pass http://10.10.0.2:8085;
proxy_pass http://58.38.22.103:8085;
proxy_http_version 1.1;
proxy_set_header Host $host;
proxy_set_header X-Forwarded-Host $host;
@@ -55,7 +63,7 @@ server {
}
location /api {
proxy_pass http://10.10.0.2:8085;
proxy_pass http://58.38.22.103:8085;
proxy_http_version 1.1;
proxy_set_header Host $host;
proxy_set_header X-Forwarded-Host $host;
@@ -68,7 +76,7 @@ server {
}
location / {
proxy_pass http://10.10.0.2:5174;
proxy_pass http://58.38.22.103:5174;
proxy_http_version 1.1;
proxy_set_header Host $host;
proxy_set_header X-Forwarded-Host $host;
@@ -1,21 +1,18 @@
# gadm.tkmind.cn — memind_adm 独立管理端(替换原 Memind /ops Vite dev
# md.tkmind.cn — memind_adm 独立管理端(替换原 Memind /ops Vite dev
#
# 前置:100 服务器 remote_restart.sh 已启动
# 前置:103 服务器 remote_restart.sh 已启动
# Admin API: 127.0.0.1:8085
# vite preview: 127.0.0.1:5174静态资源 base=/ops/
# vite preview: 127.0.0.1:5174根路径 base=/
#
# 前端 fetch 使用根路径 /auth、/admin-api、/api(非 /ops/auth),nginx 需单独反代到 Admin API。
# 前端 fetch 使用根路径 /auth、/admin-api、/apinginx 需单独反代到 Admin API。
# Analytics / SEO·GEO 看板:https://md.tkmind.cn/analytics/seo-geo
server {
listen 443 ssl http2;
server_name gadm.tkmind.cn;
server_name md.tkmind.cn;
# ssl_certificate ...;
location = / {
return 302 /ops/admin/;
}
location /auth {
proxy_pass http://127.0.0.1:8085;
proxy_http_version 1.1;
@@ -43,7 +40,7 @@ server {
proxy_set_header X-Forwarded-Proto $scheme;
}
location /ops/ {
location / {
proxy_pass http://127.0.0.1:5174;
proxy_http_version 1.1;
proxy_set_header Host $host;
+2 -2
View File
@@ -11,7 +11,7 @@ RELEASES_DIR="${REMOTE_ROOT}/releases"
BACKUP_DIR="${REMOTE_ROOT}/backups/memind_adm"
ARCHIVE_DIR="${REMOTE_ROOT}/archives"
IGNORE_FILE="${ROOT}/scripts/.releaseignore-prod"
WEB_HEALTH_URL="${STUDIO_WEB_HEALTH_URL:-http://127.0.0.1:5174/ops/}"
WEB_HEALTH_URL="${STUDIO_WEB_HEALTH_URL:-http://127.0.0.1:5174/}"
API_HEALTH_URL="${STUDIO_API_HEALTH_URL:-http://127.0.0.1:8085/health}"
RELEASE_TS="$(date +%Y%m%d-%H%M%S)"
SHORT_SHA="$(git -C "${ROOT}" rev-parse --short HEAD 2>/dev/null || echo no-git)"
@@ -119,7 +119,7 @@ if [[ "${SKIP_BUILD}" -ne 1 ]]; then
say "本地构建 dist103 不再执行前端构建)"
(
cd "${ROOT}"
VITE_BASE_PATH="${VITE_BASE_PATH:-/ops}" npm run build
VITE_BASE_PATH="${VITE_BASE_PATH:-/}" npm run build
)
fi
+1 -1
View File
@@ -23,7 +23,7 @@ if [[ -f "${ROOT}/.env" ]]; then
set +a
fi
BASE_PATH="${VITE_BASE_PATH:-/ops}"
BASE_PATH="${VITE_BASE_PATH:-/}"
if [[ "${BASE_PATH}" != */ ]]; then
BASE_PATH="${BASE_PATH}/"
fi
+2 -2
View File
@@ -3,7 +3,7 @@ import { defineConfig, loadEnv } from 'vite';
export default defineConfig(({ mode }) => {
const env = loadEnv(mode, process.cwd(), '');
const backend = env.ADM_DEV_BACKEND ?? 'http://127.0.0.1:8085';
const basePath = env.VITE_BASE_PATH?.trim() || '/ops';
const basePath = env.VITE_BASE_PATH?.trim() || '/';
const base = basePath.endsWith('/') ? basePath : `${basePath}/`;
return {
@@ -12,7 +12,7 @@ export default defineConfig(({ mode }) => {
host: env.ADM_WEB_HOST ?? env.ADM_API_HOST ?? '127.0.0.1',
port: Number(env.ADM_PORT ?? 5174),
strictPort: true,
allowedHosts: ['gadm.tkmind.cn', 'localhost', '127.0.0.1', '10.10.0.2', '58.38.22.103'],
allowedHosts: ['md.tkmind.cn', 'gadm.tkmind.cn', 'localhost', '127.0.0.1', '10.10.0.2', '58.38.22.103'],
proxy: {
'/api': backend,
'/auth': backend,
+24
View File
@@ -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 流量看板' });
});
+111 -172
View File
@@ -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">
SEOGEO访 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>
+54
View File
@@ -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> {
+2 -2
View File
@@ -14,7 +14,7 @@ export default defineConfig(({ mode }) => {
host: true,
port: 5174,
strictPort: true,
allowedHosts: ['gadm.tkmind.cn', 'localhost', '127.0.0.1'],
allowedHosts: ['md.tkmind.cn', 'gadm.tkmind.cn', 'localhost', '127.0.0.1'],
proxy: {
'/api': backendTarget,
'/auth': backendTarget,
@@ -25,7 +25,7 @@ export default defineConfig(({ mode }) => {
host: '127.0.0.1',
port: 5174,
strictPort: true,
allowedHosts: ['gadm.tkmind.cn', 'localhost', '127.0.0.1'],
allowedHosts: ['md.tkmind.cn', 'gadm.tkmind.cn', 'localhost', '127.0.0.1'],
proxy: {
'/api': backendTarget,
'/auth': backendTarget,