Compare commits

..

9 Commits

Author SHA1 Message Date
john 66dd288015 feat(admin): open SEO/GEO defaults and clarify indexable pages
Align the MindSpace config UI with public-online indexing, add a one-click enable action, and document that 可收录 means public and unexpired.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-13 15:51:48 +08:00
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
john 6a7d6cec70 fix(analytics): use Umami SSO for SEO/GEO API on SSO-only deploy
Production Umami rejects password login when MEMIND_UMAMI_SSO_ONLY=1;
server-side memind-pages fetch now authenticates via MEMIND_UMAMI_SSO_SECRET.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-10 17:21:28 +08:00
john a67d5a137a docs: add analytics deploy guide for SEO/GEO and Umami SSO
Document gadm env vars, release order, and links to Memind runbook.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-10 16:22:10 +08:00
john 0e26db1857 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>
2026-08-12 01:34:01 +08:00
john ed8bc6d1b1 feat(admin): add MindSpace SEO/GEO controls and template catalog page
Expose SEO/GEO switches on the MindSpace config form and add template catalog management UI wired to shared Memind modules.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-10 08:06:21 +08:00
tkmind 7120d4b5ea feat(billing): show subscription quota usage in admin usage records (#6) 2026-08-06 10:52:54 +00:00
tkmind b45fcabbf3 Merge pull request 'feat(billing): add metering formula admin tab' (#5) from feature/billing-formula-admin-config into main 2026-08-06 09:00:04 +00:00
33 changed files with 1629 additions and 111 deletions
+7 -8
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
@@ -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
+9 -1
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 客户端。**
| 组件 | 端口 | 职责 |
|------|------|------|
@@ -32,3 +32,11 @@ bash scripts/check-release-ready.sh
| Memind 后端 | 8081 / 8082 | 业务逻辑与 PortalUI 不在此仓库 |
新增管理功能时:在本仓库添加 `src/admin/pages/*`、更新 `AdminNav.tsx``App.tsx`;若需新 API,在 `server/app.mjs` 挂载并复用 Memind 模块。Memind 侧仅实现共享业务,不在 `ops/` 做 UI。
## Analytics / SEO·GEO 看板
- 页面:`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 → md.tkmind.cn
- Rybbit 已退役,勿恢复相关配置
+44 -8
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
@@ -261,6 +279,24 @@ PY'
| `scripts/remote_restart.sh` | 仅在远端重启 `vite preview` |
| `scripts/.releaseignore-prod` | 生产发布包排除规则 |
## Analytics / SEO·GEOUmami
管理后台 **Analytics 配置** 与 **SEO / GEO 流量** 看板依赖 105 Umami。
| 文档 | 内容 |
|------|------|
| [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
MEMIND_UMAMI_SSO_SECRET=<与 105 Umami .env 一致>
UMAMI_SSO_USERNAME=admin
```
可选:`UMAMI_URL`、`UMAMI_WEBSITE_ID`、`UMAMI_SSO_USERNAME`。
## 规则文档
- `ENGINEERING_WORKFLOW_RULES.md`
+121
View File
@@ -0,0 +1,121 @@
# Analytics / SEO·GEO 部署说明
管理后台 **Analytics 配置****SEO / GEO 流量** 看板的生产配置与发布。
平台边界与字段口径见 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)。
## 功能入口(md.tkmind.cn · 103 进程 · 105 反代)
生产 URL**https://md.tkmind.cn/analytics/seo-geo**
| 路由 | 页面 | 说明 |
|------|------|------|
| `/analytics` | Analytics 配置 | Umami Website ID、域名、SSO 进入 Umami |
| `/analytics/seo-geo` | SEO / GEO 流量 | 按页展示搜索引擎 / 生成式引擎来源访问 |
**Rybbit 已退役** — 相关 SSO 接口返回 410;勿再配置 `MEMIND_RYBBIT_*`
## 依赖关系
```text
SeoGeoAnalyticsPage
-> GET /admin-api/analytics/seo-geo-pages
-> server/umami-analytics.mjs
-> 105 Umami API /api/websites/:id/memind-pages?discoveryChannel=seo|geo
memind-analytics overlay
Analytics 配置 / Umami SSO
-> GET /admin-api/analytics/sso
-> Memind 共享 SSO 逻辑 + MEMIND_UMAMI_SSO_SECRET
```
adm **不**直连浏览器到 Umami 写库;读统计通过服务端用 Umami 登录态拉 API。
## 生产环境变量(103 `.env`
`/Users/john/Project/memind_adm/.env` 增加或确认:
```env
# 管理后台公网域名(会话 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 公共页面 Website ID>
```
说明:
- 105 生产 Umami 启用 `MEMIND_UMAMI_SSO_ONLY=1` 时,须配置 `MEMIND_UMAMI_SSO_SECRET`;勿依赖 `UMAMI_ADMIN_PASSWORD`
- `websiteId` 也可在 **Analytics 配置** 页面保存(写入 MindSpace admin 配置),服务端优先读库内配置
- 103 必须能访问 `UMAMI_URL`(内网或公网,依网络拓扑)
## 发布
仅改 adm 前端或 `server/umami-analytics.mjs` 时:
```bash
cd /Users/john/Project/memind_adm
bash scripts/release-prod.sh --yes
```
若改了 Umami `memind-pages` API 或 overlay,需 **先** 发 memind-analytics105),再发 adm。
若改了 Memind discovery 埋点,需 **先** 发 Memind Portal103),再有 SEO/GEO 分列数据。
完整顺序见 Memind `docs/analytics-release-runbook.md`
## 发布后验收
```bash
# Admin API 健康
ssh john@10.10.0.2 'curl -sf http://127.0.0.1:8085/health'
# SEO/GEO API(需替换 cookie 或在内网浏览器登录后测)
# 期望 200 + pages 数组,而非 500 / 未配置 MEMIND_UMAMI_SSO_SECRET
```
浏览器:
1. `https://md.tkmind.cn/analytics` → 「打开 Umami 分析后台」跳转成功
2. `https://md.tkmind.cn/analytics/seo-geo` → 表格加载;SEO / GEO Tab 切换正常
3. 列口径与 Umami「所有生成页面明细 / public page」一致(页面、PV、UV、点击/表单/参与率等)
## 本地开发
```bash
pnpm dev # 5174
```
本地 `.env` 可指向本机或 105 Umami
```env
UMAMI_URL=http://127.0.0.1:3100
UMAMI_WEBSITE_ID=<local website id>
MEMIND_UMAMI_SSO_SECRET=<local secret>
```
Memind 本地 analytics 变量见 [local-analytics.md](https://git.tkmind.cn/tkmind/Memind/src/branch/main/docs/local-analytics.md)。
## 故障排查
| 现象 | 检查 |
|------|------|
| 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 |
## 相关代码
| 路径 | 说明 |
|------|------|
| `src/admin/pages/SeoGeoAnalyticsPage.tsx` | SEO/GEO 看板 UI |
| `src/admin/pages/AnalyticsConfigPage.tsx` | Analytics 配置 |
| `server/umami-analytics.mjs` | Umami memind-pages 代理 |
| `server/app.mjs` | `/admin-api/analytics/*` 路由 |
+2
View File
@@ -12,7 +12,9 @@
"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",
"dev:preview": "vite preview"
},
+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
+128
View File
@@ -0,0 +1,128 @@
#!/usr/bin/env node
/**
* Local smoke test for page template catalog admin API (memind_adm 8085).
* Usage: node scripts/verify-template-catalog-local.mjs
*/
import fs from 'node:fs';
import path from 'node:path';
import { fileURLToPath } from 'node:url';
const root = path.join(path.dirname(fileURLToPath(import.meta.url)), '..');
const base = process.env.ADM_DEV_BACKEND ?? 'http://127.0.0.1:8085';
function loadEnvFile(filePath) {
if (!fs.existsSync(filePath)) return;
for (const line of fs.readFileSync(filePath, 'utf8').split('\n')) {
const trimmed = line.trim();
if (!trimmed || trimmed.startsWith('#')) continue;
const eq = trimmed.indexOf('=');
if (eq <= 0) continue;
const key = trimmed.slice(0, eq).trim();
let value = trimmed.slice(eq + 1).trim();
if ((value.startsWith('"') && value.endsWith('"')) || (value.startsWith("'") && value.endsWith("'"))) {
value = value.slice(1, -1);
}
if (!(key in process.env)) process.env[key] = value;
}
}
loadEnvFile(path.join(root, '.env'));
const username = process.env.H5_ADMIN_USERNAME?.trim() || 'admin';
const password = process.env.H5_ADMIN_PASSWORD?.trim() || process.env.ADMIN_PASSWORD?.trim() || '';
async function request(method, urlPath, { body, cookie } = {}) {
const res = await fetch(`${base}${urlPath}`, {
method,
headers: {
...(body ? { 'Content-Type': 'application/json' } : {}),
...(cookie ? { Cookie: cookie } : {}),
},
body: body ? JSON.stringify(body) : undefined,
});
const text = await res.text();
let json = null;
try {
json = text ? JSON.parse(text) : null;
} catch {
json = { raw: text.slice(0, 300) };
}
return { status: res.status, json, setCookie: res.headers.getSetCookie?.() ?? [] };
}
function cookieFromSetCookie(setCookie) {
return setCookie.map((c) => c.split(';')[0]).join('; ');
}
function assertOk(cond, msg) {
if (!cond) throw new Error(msg);
}
async function main() {
console.log(`==> Admin API base: ${base}`);
if (!password) {
console.error('缺少 H5_ADMIN_PASSWORD(请在 memind_adm/.env 配置)');
process.exit(1);
}
const login = await request('POST', '/auth/login', {
body: { username, password },
});
assertOk(login.status === 200, `登录失败 HTTP ${login.status}: ${JSON.stringify(login.json)}`);
const cookie = cookieFromSetCookie(login.setCookie);
assertOk(cookie, '登录未返回 session cookie');
console.log(`✓ 管理员登录 (${username})`);
const catalog = await request('GET', '/admin-api/template-catalog', { cookie });
assertOk(catalog.status === 200, `template-catalog HTTP ${catalog.status}: ${JSON.stringify(catalog.json)}`);
assertOk(Array.isArray(catalog.json?.items), 'items 应为数组');
assertOk(catalog.json.items.length >= 3, `应至少 seed 3 个 page-template skill,实际 ${catalog.json.items.length}`);
const travel = catalog.json.items.find((item) => item.skillName === 'page-template-travel');
assertOk(travel, '缺少 page-template-travel');
console.log(
`✓ GET /admin-api/template-catalog (${catalog.json.items.length} 项,travel=${travel.priceCents} 分)`,
);
const originalSort = travel.sortOrder ?? 10;
const patchedSort = originalSort === 11 ? 12 : 11;
const update = await request('PUT', '/admin-api/template-catalog/page-template-travel', {
cookie,
body: { sortOrder: patchedSort },
});
assertOk(update.status === 200, `update HTTP ${update.status}: ${JSON.stringify(update.json)}`);
assertOk(update.json?.item?.sortOrder === patchedSort, 'sortOrder 未更新');
console.log(`✓ PUT sortOrder ${originalSort}${patchedSort}`);
const restore = await request('PUT', '/admin-api/template-catalog/page-template-travel', {
cookie,
body: { sortOrder: originalSort },
});
assertOk(restore.status === 200, `restore HTTP ${restore.status}`);
console.log(`✓ PUT sortOrder 恢复为 ${originalSort}`);
const users = await request('GET', '/admin-api/users?page=1&pageSize=5&role=user', { cookie });
assertOk(users.status === 200, `users HTTP ${users.status}`);
const sampleUser = users.json?.users?.[0];
assertOk(sampleUser?.id, '需要至少一个 user 账号做授权测试');
const grant = await request(
'POST',
`/admin-api/users/${sampleUser.id}/template-grants/page-template-travel`,
{ cookie },
);
assertOk(grant.status === 200, `grant HTTP ${grant.status}: ${JSON.stringify(grant.json)}`);
assertOk(grant.json?.ok === true, 'grant 应返回 ok:true');
assertOk(
Array.isArray(grant.json?.grantedSkills) && grant.json.grantedSkills.includes('page-template-travel'),
'grant 应包含 page-template-travel',
);
console.log(`✓ POST template-grants → user ${sampleUser.username}`);
console.log('\n全部 template-catalog Admin API 联调通过。');
}
main().catch((err) => {
console.error('\n联调失败:', err.message);
process.exit(1);
});
+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,
+70 -12
View File
@@ -5,6 +5,8 @@ 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';
import { importMemind } from './lib-path.mjs';
const projectRoot = path.join(path.dirname(fileURLToPath(import.meta.url)), '..');
@@ -111,6 +113,7 @@ export function createAdminApp(services) {
wordFilterService,
planCatalogService,
subscriptionService,
templateCatalogService,
planSyncService,
} = services;
const app = express();
@@ -235,19 +238,46 @@ 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/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) => {
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) => {
@@ -377,6 +407,7 @@ export function createAdminApp(services) {
if (!updateMindSpaceConfig) return res.status(503).json({ message: 'MindSpace 配置未启用' });
const result = await updateMindSpaceConfig(pool, {
publicPageLimit: req.body?.publicPageLimit,
seoGeo: req.body?.seoGeo,
analytics: req.body?.analytics,
});
res.json({ config: result });
@@ -1317,6 +1348,33 @@ export function createAdminApp(services) {
res.json(result);
});
// ── Page template catalog ─────────────────────────────────────────────────
adminApi.get('/template-catalog', requireAdmin, async (_req, res) => {
if (!templateCatalogService?.adminListCatalog) {
return res.status(503).json({ message: '模板商城未启用' });
}
res.json({ items: await templateCatalogService.adminListCatalog() });
});
adminApi.put('/template-catalog/:skillName', requireAdmin, async (req, res) => {
if (!templateCatalogService?.adminUpsertCatalog) {
return res.status(503).json({ message: '模板商城未启用' });
}
const result = await templateCatalogService.adminUpsertCatalog(req.params.skillName, req.body ?? {});
if (!result.ok) return res.status(400).json({ message: result.message });
res.json(result);
});
adminApi.post('/users/:userId/template-grants/:skillName', requireAdmin, async (req, res) => {
if (!templateCatalogService?.adminGrantTemplate) {
return res.status(503).json({ message: '模板商城未启用' });
}
const result = await templateCatalogService.adminGrantTemplate(req.params.userId, req.params.skillName);
if (!result.ok) return res.status(400).json({ message: result.message });
res.json(result);
});
app.use('/admin-api', adminApi);
if (services.createOpsApi) {
+7
View File
@@ -165,6 +165,12 @@ export async function bootstrapAdminServices() {
getPlanAsync: (planType) => planCatalogService.getPlan(planType),
});
subscriptionService._planCatalogService = planCatalogService;
const { createPageTemplateCatalogService } = await importMemind('page-template-catalog.mjs');
const templateCatalogService = createPageTemplateCatalogService(pool, {
userAuth,
h5Root,
});
await templateCatalogService.ensureReady();
await ensureSystemTestAccountSchema(pool);
const systemTestAccountService = createSystemTestAccountService(pool);
@@ -196,6 +202,7 @@ export async function bootstrapAdminServices() {
wordFilterService,
planCatalogService,
subscriptionService,
templateCatalogService,
USER_COOKIE,
userLoginCookies,
clearUserSessionCookie,
+2
View File
@@ -117,6 +117,7 @@ ready
wordFilterService,
planCatalogService,
subscriptionService,
templateCatalogService,
planSyncService,
USER_COOKIE,
userLoginCookies,
@@ -146,6 +147,7 @@ ready
wordFilterService,
planCatalogService,
subscriptionService,
templateCatalogService,
planSyncService,
USER_COOKIE,
userLoginCookies,
+2 -1
View File
@@ -156,7 +156,7 @@ export async function listUsagePaged(pool, query) {
);
const [rows] = await pool.query(
`SELECT r.id, r.user_id, u.username, u.display_name, r.agent_session_id, r.request_id,
r.input_tokens, r.output_tokens, r.cost_cents, r.balance_after_cents, r.created_at
r.input_tokens, r.output_tokens, r.cost_cents, r.balance_after_cents, r.billing_source, r.created_at
FROM h5_usage_records r
JOIN h5_users u ON u.id = r.user_id
${where}
@@ -176,6 +176,7 @@ export async function listUsagePaged(pool, query) {
outputTokens: Number(row.output_tokens),
costCents: Number(row.cost_cents),
balanceAfterCents: Number(row.balance_after_cents),
billingSource: row.billing_source ?? 'wallet',
createdAt: Number(row.created_at),
})),
total: Number(total),
+139
View File
@@ -0,0 +1,139 @@
import crypto from 'node:crypto';
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 sharedSecret = String(env.MEMIND_UMAMI_SSO_SECRET || '').trim();
const websiteId = String(analyticsConfig?.websiteId || env.UMAMI_WEBSITE_ID || '').trim();
return { baseUrl, username, password, sharedSecret, websiteId };
}
async function loginUmamiViaSso({ baseUrl, username, sharedSecret }) {
const encoded = Buffer.from(
JSON.stringify({
username,
exp: Math.floor(Date.now() / 1000) + 60,
nonce: crypto.randomUUID(),
}),
).toString('base64url');
const signature = crypto.createHmac('sha256', sharedSecret).update(encoded).digest('base64url');
const ticket = `${encoded}.${signature}`;
const response = await fetch(
`${baseUrl}/api/auth/memind?ticket=${encodeURIComponent(ticket)}`,
{ signal: AbortSignal.timeout(10_000) },
);
const body = await response.json().catch(() => ({}));
if (!response.ok) {
throw new Error(body?.error?.message || body?.message || `Umami SSO 登录失败 (${response.status})`);
}
const token = String(body?.token ?? '').trim();
if (!token) throw new Error('Umami SSO 登录响应缺少 token');
return token;
}
async function loginUmami({ baseUrl, username, password, sharedSecret }) {
if (sharedSecret) {
return loginUmamiViaSso({ baseUrl, username, sharedSecret });
}
if (!password) {
throw new Error('未配置 UMAMI_ADMIN_PASSWORD 或 MEMIND_UMAMI_SSO_SECRET,无法拉取 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 auth credentials', async () => {
await assert.rejects(
() => fetchMemindDiscoveryPages(
{ discoveryChannel: 'geo', startAt: '1', endAt: '2' },
{ env: {}, analyticsConfig: { websiteId: 'site-1' } },
),
/未配置 UMAMI_ADMIN_PASSWORD 或 MEMIND_UMAMI_SSO_SECRET/,
);
});
+6
View File
@@ -13,12 +13,14 @@ import { MindSearchPage } from './admin/pages/MindSearchPage';
import { ProvidersPage } from './admin/pages/ProvidersPage';
import { SkillsPage } from './admin/pages/SkillsPage';
import { SystemTestsPage } from './admin/pages/SystemTestsPage';
import { TemplateCatalogPage } from './admin/pages/TemplateCatalogPage';
import { UserDetailPage } from './admin/pages/UserDetailPage';
import { UsersPage } from './admin/pages/UsersPage';
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';
@@ -121,12 +123,14 @@ function AdminApp({ user, onLogout }: { user: PortalUser; onLogout: () => void }
<Route path="users/:userId" element={<UserDetailPage />} />
<Route path="billing/*" element={<BillingPage />} />
<Route path="image-quota" element={<ImageQuotaPage />} />
<Route path="template-catalog" element={<TemplateCatalogPage />} />
<Route path="capabilities" element={<CapabilitiesPage />} />
<Route path="skills" element={<SkillsPage />} />
<Route path="system-tests" element={<SystemTestsPage />} />
<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 />} />
@@ -173,11 +177,13 @@ function loginRedirectPath(pathname: string, role: string | undefined) {
pathname.startsWith('/users')
|| pathname.startsWith('/billing')
|| pathname.startsWith('/image-quota')
|| pathname.startsWith('/template-catalog')
|| pathname.startsWith('/capabilities')
|| pathname.startsWith('/skills')
|| pathname.startsWith('/system-tests')
|| pathname.startsWith('/policies')
|| pathname.startsWith('/mindspace')
|| pathname.startsWith('/analytics')
|| pathname.startsWith('/memory-v2')
|| pathname.startsWith('/skill-runtime')
|| pathname.startsWith('/orchestrator')
+2
View File
@@ -24,6 +24,7 @@ const NAV_SECTIONS: NavSection[] = [
items: [
{ to: '/billing', label: '计费中心', end: false },
{ to: '/image-quota', label: '图片额度' },
{ to: '/template-catalog', label: '页面模板' },
],
},
{
@@ -32,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 <code>MEMIND_UMAMI_SSO_SECRET</code> 105 Umami <code>UMAMI_ADMIN_PASSWORD</code> 访 Umami API Umami SSO-only </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>
+9 -2
View File
@@ -450,6 +450,11 @@ function UsageRecordsTable({
result: PagedResult<UsageRecord>;
onPage: (page: number) => void;
}) {
const formatBillingSource = (row: UsageRecord) => {
if (row.billingSource === 'subscription') return '套餐额度';
return `¥${formatYuan(row.costCents)}`;
};
return (
<>
<div className="admin-table-wrap">
@@ -474,8 +479,10 @@ function UsageRecordsTable({
</td>
<td className="billing-num">{row.inputTokens.toLocaleString()}</td>
<td className="billing-num">{row.outputTokens.toLocaleString()}</td>
<td className="billing-num">¥{formatYuan(row.costCents)}</td>
<td className="billing-num">¥{formatYuan(row.balanceAfterCents)}</td>
<td className="billing-num">{formatBillingSource(row)}</td>
<td className="billing-num">
{row.billingSource === 'subscription' ? '—' : `¥${formatYuan(row.balanceAfterCents)}`}
</td>
</tr>
))}
</tbody>
+3 -1
View File
@@ -238,7 +238,9 @@ export function DashboardPage() {
<td>
in {row.inputTokens} / out {row.outputTokens}
</td>
<td>¥{formatYuan(row.costCents)}</td>
<td>
{row.billingSource === 'subscription' ? '套餐额度' : `¥${formatYuan(row.costCents)}`}
</td>
</tr>
))
)}
+140 -14
View File
@@ -1,14 +1,37 @@
import { useCallback, useEffect, useState, type FormEvent } from 'react';
import { getMindSpaceAdminConfig, updateMindSpaceAdminConfig } from '../../api/client';
import type { MindSpaceAdminConfig } from '../../types';
import type { MindSpaceAdminConfig, MindSpaceSeoGeoConfig } from '../../types';
function defaultSeoGeoConfig(): MindSpaceSeoGeoConfig {
return {
enabled: true,
seo: {
enabled: true,
canonical: true,
sitemap: true,
robotsTxt: true,
baiduPush: true,
},
geo: {
enabled: true,
jsonLd: true,
llmsTxt: true,
},
};
}
function safeConfig(config: MindSpaceAdminConfig | null): MindSpaceAdminConfig {
return config ?? { publicPageLimit: 10, analytics: { enabled: false, websiteId: '', analyticsUrl: 'http://127.0.0.1:3100', domains: '127.0.0.1,localhost', idSecretConfigured: false } };
return config ?? {
publicPageLimit: 10,
seoGeo: defaultSeoGeoConfig(),
analytics: { enabled: false, websiteId: '', analyticsUrl: 'http://127.0.0.1:3100', domains: '127.0.0.1,localhost', idSecretConfigured: false },
};
}
export function MindSpacePage() {
const [config, setConfig] = useState<MindSpaceAdminConfig | null>(null);
const [limit, setLimit] = useState('10');
const [seoGeo, setSeoGeo] = useState<MindSpaceSeoGeoConfig>(defaultSeoGeoConfig());
const [loading, setLoading] = useState(true);
const [busy, setBusy] = useState(false);
const [error, setError] = useState<string | null>(null);
@@ -22,6 +45,7 @@ export function MindSpacePage() {
const nextConfig = await getMindSpaceAdminConfig();
setConfig(nextConfig);
setLimit(String(nextConfig.publicPageLimit));
setSeoGeo(nextConfig.seoGeo ?? defaultSeoGeoConfig());
} catch (err) {
setError(err instanceof Error ? err.message : '加载 MindSpace 配置失败');
} finally {
@@ -39,10 +63,14 @@ export function MindSpacePage() {
setError(null);
setMessage(null);
try {
const nextConfig = await updateMindSpaceAdminConfig({ publicPageLimit: Number(limit) });
const nextConfig = await updateMindSpaceAdminConfig({
publicPageLimit: Number(limit),
seoGeo,
});
setConfig(nextConfig);
setLimit(String(nextConfig.publicPageLimit));
setMessage(`已更新为 ${nextConfig.publicPageLimit},主站会立即按新值校验。`);
setSeoGeo(nextConfig.seoGeo ?? defaultSeoGeoConfig());
setMessage(`已更新 MindSpace 配置,SEO/GEO 总开关:${nextConfig.seoGeo?.enabled ? '开启' : '关闭'}`);
} catch (err) {
setError(err instanceof Error ? err.message : '保存 MindSpace 配置失败');
} finally {
@@ -52,6 +80,15 @@ export function MindSpacePage() {
const current = safeConfig(config);
const updateSeoGeo = (patch: Partial<MindSpaceSeoGeoConfig>) => {
setSeoGeo((currentSeoGeo) => ({
...currentSeoGeo,
...patch,
seo: { ...currentSeoGeo.seo, ...(patch.seo ?? {}) },
geo: { ...currentSeoGeo.geo, ...(patch.geo ?? {}) },
}));
};
return (
<div className="admin-page">
<div className="admin-page-head">
@@ -62,12 +99,12 @@ export function MindSpacePage() {
{error && <p className="banner banner-error">{error}</p>}
{message && <p className="banner banner-info">{message}</p>}
<form className="admin-form" onSubmit={handleSave}>
<section className="admin-card">
<h2></h2>
<p className="muted">
{loading ? '—' : current.publicPageLimit}
</p>
<form className="admin-form" onSubmit={handleSave}>
<label className="admin-form-row">
<span>线</span>
<input
@@ -80,16 +117,105 @@ export function MindSpacePage() {
required
/>
</label>
<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>
<section className="admin-card">
<h2>SEO / GEO</h2>
<p className="muted">
线 SEO/GEO noindex sitemap / llms.txt
</p>
<div className="admin-form">
<label className="admin-form-row">
<span> SEO / GEO </span>
<input
type="checkbox"
checked={seoGeo.enabled}
onChange={(e) => updateSeoGeo({ enabled: e.target.checked })}
disabled={loading || busy}
/>
</label>
<label className="admin-form-row">
<span>SEO meta + canonical </span>
<input
type="checkbox"
checked={seoGeo.seo.enabled}
onChange={(e) => updateSeoGeo({ seo: { ...seoGeo.seo, enabled: e.target.checked } })}
disabled={loading || busy || !seoGeo.enabled}
/>
</label>
<label className="admin-form-row">
<span>SEOsitemap.xml</span>
<input
type="checkbox"
checked={seoGeo.seo.sitemap}
onChange={(e) => updateSeoGeo({ seo: { ...seoGeo.seo, sitemap: e.target.checked } })}
disabled={loading || busy || !seoGeo.enabled || !seoGeo.seo.enabled}
/>
</label>
<label className="admin-form-row">
<span>SEOrobots.txt</span>
<input
type="checkbox"
checked={seoGeo.seo.robotsTxt}
onChange={(e) => updateSeoGeo({ seo: { ...seoGeo.seo, robotsTxt: e.target.checked } })}
disabled={loading || busy || !seoGeo.enabled || !seoGeo.seo.enabled}
/>
</label>
<label className="admin-form-row">
<span>SEO</span>
<input
type="checkbox"
checked={seoGeo.seo.baiduPush}
onChange={(e) => updateSeoGeo({ seo: { ...seoGeo.seo, baiduPush: e.target.checked } })}
disabled={loading || busy || !seoGeo.enabled || !seoGeo.seo.enabled}
/>
</label>
<label className="admin-form-row">
<span>GEOJSON-LD </span>
<input
type="checkbox"
checked={seoGeo.geo.jsonLd}
onChange={(e) => updateSeoGeo({ geo: { ...seoGeo.geo, jsonLd: e.target.checked, enabled: e.target.checked || seoGeo.geo.llmsTxt } })}
disabled={loading || busy || !seoGeo.enabled}
/>
</label>
<label className="admin-form-row">
<span>GEOllms.txtAI </span>
<input
type="checkbox"
checked={seoGeo.geo.llmsTxt}
onChange={(e) => updateSeoGeo({ geo: { ...seoGeo.geo, llmsTxt: e.target.checked, enabled: seoGeo.geo.jsonLd || e.target.checked } })}
disabled={loading || busy || !seoGeo.enabled}
/>
</label>
<label className="admin-form-row">
<span>GEO + </span>
<input
type="checkbox"
checked={seoGeo.geo.enabled}
onChange={(e) => updateSeoGeo({ geo: { ...seoGeo.geo, enabled: e.target.checked } })}
disabled={loading || busy || !seoGeo.enabled}
/>
</label>
</div>
</section>
<div className="admin-actions">
<button type="submit" className="send-btn" disabled={busy || loading}>
{busy ? '保存中...' : '保存配置'}
</button>
<button
type="button"
className="ghost-btn"
onClick={() => setSeoGeo(defaultSeoGeoConfig())}
disabled={busy || loading}
>
</button>
<button type="button" className="ghost-btn" onClick={() => void load()} disabled={busy}>
</button>
</div>
</form>
</div>
);
}
+281
View File
@@ -0,0 +1,281 @@
import { useCallback, useEffect, useMemo, useState } from 'react';
import { getSeoGeoPublicationCatalog, getUmamiSsoUrl } from '../../api/client';
import type { SeoGeoCatalogRow } from '../../api/client';
type PageSort =
| 'publishedAt'
| 'pageTitle'
| 'ownerLabel'
| 'totalViews'
| 'seoViews'
| 'geoViews'
| 'crawlerViews'
| 'lifetimeViewCount';
function formatNumber(value: number) {
return new Intl.NumberFormat('zh-CN').format(Number(value) || 0);
}
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 };
}
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 [startDate, setStartDate] = useState(initialRange.startDate);
const [endDate, setEndDate] = useState(initialRange.endDate);
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<SeoGeoCatalogRow[]>([]);
const [total, setTotal] = 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 / 20));
const load = useCallback(async () => {
setLoading(true);
setError(null);
try {
const { startAt, endAt } = toRangeMs(startDate, endDate);
const result = await getSeoGeoPublicationCatalog({
startAt,
endAt,
page,
pageSize: 20,
sortBy,
sortOrder,
search: search.trim() || undefined,
});
setRows(Array.isArray(result.data) ? result.data : []);
setTotal(Number(result.total) || 0);
setTotals(result.totals ?? {
totalViews: 0,
seoViews: 0,
geoViews: 0,
crawlerViews: 0,
});
} catch (err) {
setError(err instanceof Error ? err.message : '加载 SEO/GEO 页面目录失败');
setRows([]);
setTotal(0);
setTotals({ totalViews: 0, seoViews: 0, geoViews: 0, crawlerViews: 0 });
} finally {
setLoading(false);
}
}, [endDate, page, search, sortBy, sortOrder, startDate]);
useEffect(() => {
void load();
}, [load]);
useEffect(() => {
setPage(1);
}, [startDate, endDate, search, 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);
}
};
return (
<div className="admin-page">
<div className="admin-page-head">
<h2>SEO / GEO </h2>
<p className="muted">
线 URL访 = SEO / GEO referrer User-Agent
</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="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> / / URL</span>
<input
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="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">
<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(total)}</strong>
</div>
<div className="admin-card metric-card" style={{ minWidth: 140, padding: 16 }}>
<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>
<div className="admin-table-wrap">
<table className="admin-table">
<thead>
<tr>
<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>
{loading ? (
<tr>
<td colSpan={11}></td>
</tr>
) : rows.length === 0 ? (
<tr>
<td colSpan={11}>线</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>
</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>
);
}
+267
View File
@@ -0,0 +1,267 @@
import { useEffect, useMemo, useState } from 'react';
import { fetchAdminTemplateCatalog, updateAdminTemplateCatalogItem } from '../../api/client';
import type { AdminTemplateCatalogItem } from '../../types';
type DraftRow = {
label: string;
description: string;
previewUrl: string;
priceYuan: string;
billingMode: AdminTemplateCatalogItem['billingMode'];
status: AdminTemplateCatalogItem['status'];
sortOrder: string;
};
const STATUS_LABELS: Record<AdminTemplateCatalogItem['status'], string> = {
draft: '草稿',
active: '上架',
archived: '下架',
};
const BILLING_LABELS: Record<AdminTemplateCatalogItem['billingMode'], string> = {
free: '免费',
one_time: '一次性',
subscription: '订阅',
};
function toDraft(item: AdminTemplateCatalogItem): DraftRow {
return {
label: item.label,
description: item.description ?? '',
previewUrl: item.previewUrl ?? '',
priceYuan: item.priceCents > 0 ? (item.priceCents / 100).toFixed(item.priceCents % 100 === 0 ? 0 : 2) : '0',
billingMode: item.billingMode,
status: item.status,
sortOrder: String(item.sortOrder ?? 0),
};
}
function resolvePreviewSrc(previewUrl: string | null | undefined) {
if (!previewUrl) return null;
if (/^https?:\/\//i.test(previewUrl)) return previewUrl;
const portalOrigin = import.meta.env.VITE_PORTAL_ORIGIN?.trim() || 'http://127.0.0.1:5173';
return `${portalOrigin.replace(/\/$/, '')}${previewUrl.startsWith('/') ? previewUrl : `/${previewUrl}`}`;
}
export function TemplateCatalogPage() {
const [items, setItems] = useState<AdminTemplateCatalogItem[]>([]);
const [drafts, setDrafts] = useState<Record<string, DraftRow>>({});
const [loading, setLoading] = useState(false);
const [busySkill, setBusySkill] = useState<string | null>(null);
const [error, setError] = useState<string | null>(null);
const [message, setMessage] = useState<string | null>(null);
const portalPreviewBase =
import.meta.env.VITE_PORTAL_ORIGIN?.trim()?.replace(/\/$/, '') || 'http://127.0.0.1:5173';
const load = async () => {
setLoading(true);
setError(null);
try {
const catalog = await fetchAdminTemplateCatalog();
setItems(catalog);
setDrafts(Object.fromEntries(catalog.map((item) => [item.skillName, toDraft(item)])));
} catch (err) {
setError(err instanceof Error ? err.message : '加载模板目录失败');
} finally {
setLoading(false);
}
};
useEffect(() => {
void load();
}, []);
const sortedItems = useMemo(
() => [...items].sort((a, b) => a.sortOrder - b.sortOrder || a.label.localeCompare(b.label, 'zh-CN')),
[items],
);
const updateDraft = (skillName: string, patch: Partial<DraftRow>) => {
setDrafts((current) => ({
...current,
[skillName]: { ...(current[skillName] ?? toDraft(items.find((item) => item.skillName === skillName)!)), ...patch },
}));
};
const saveItem = async (skillName: string) => {
const draft = drafts[skillName];
if (!draft) return;
const priceYuan = Number(draft.priceYuan);
const sortOrder = Number(draft.sortOrder);
if (!Number.isFinite(priceYuan) || priceYuan < 0) {
setError('价格必须是非负数');
return;
}
if (!Number.isFinite(sortOrder)) {
setError('排序必须是数字');
return;
}
setBusySkill(skillName);
setError(null);
setMessage(null);
try {
const result = await updateAdminTemplateCatalogItem(skillName, {
label: draft.label.trim(),
description: draft.description.trim(),
previewUrl: draft.previewUrl.trim() || null,
priceCents: Math.round(priceYuan * 100),
billingMode: draft.billingMode,
status: draft.status,
sortOrder: Math.floor(sortOrder),
});
setMessage(`已保存「${result.item?.label ?? skillName}`);
await load();
} catch (err) {
setError(err instanceof Error ? err.message : '保存失败');
} finally {
setBusySkill(null);
}
};
return (
<div className="admin-page">
<div className="admin-page-head">
<h2></h2>
<p className="muted">
Portal page-template skill
</p>
</div>
{error && <p className="banner banner-error">{error}</p>}
{message && <p className="banner banner-info">{message}</p>}
<section className="admin-card">
{loading && items.length === 0 ? (
<p className="muted"></p>
) : sortedItems.length === 0 ? (
<p className="muted"> Memind seed page-template-* skill</p>
) : (
<div className="admin-table-wrap">
<table className="admin-table template-catalog-table">
<thead>
<tr>
<th></th>
<th>Skill / </th>
<th></th>
<th></th>
<th></th>
<th></th>
<th></th>
<th> URL</th>
<th />
</tr>
</thead>
<tbody>
{sortedItems.map((item) => {
const draft = drafts[item.skillName] ?? toDraft(item);
const previewSrc = resolvePreviewSrc(draft.previewUrl || item.previewUrl);
const livePreviewUrl = `${portalPreviewBase}/api/mindspace/v1/template-catalog/${encodeURIComponent(item.skillName)}/preview`;
return (
<tr key={item.skillName}>
<td>
{previewSrc ? (
<img src={previewSrc} alt="" className="template-catalog-thumb" loading="lazy" />
) : (
<div className="template-catalog-thumb template-catalog-thumb-fallback" aria-hidden="true" />
)}
</td>
<td>
<div className="mono">{item.skillName}</div>
<input
className="admin-inline-input"
value={draft.label}
onChange={(event) => updateDraft(item.skillName, { label: event.target.value })}
/>
</td>
<td>
<textarea
className="admin-inline-textarea"
rows={3}
value={draft.description}
onChange={(event) => updateDraft(item.skillName, { description: event.target.value })}
/>
</td>
<td>
<input
className="admin-inline-input admin-inline-input-narrow"
inputMode="decimal"
value={draft.priceYuan}
onChange={(event) => updateDraft(item.skillName, { priceYuan: event.target.value })}
/>
</td>
<td>
<select
className="admin-inline-input"
value={draft.billingMode}
onChange={(event) =>
updateDraft(item.skillName, {
billingMode: event.target.value as AdminTemplateCatalogItem['billingMode'],
})
}
>
{Object.entries(BILLING_LABELS).map(([value, label]) => (
<option key={value} value={value}>
{label}
</option>
))}
</select>
</td>
<td>
<select
className="admin-inline-input"
value={draft.status}
onChange={(event) =>
updateDraft(item.skillName, {
status: event.target.value as AdminTemplateCatalogItem['status'],
})
}
>
{Object.entries(STATUS_LABELS).map(([value, label]) => (
<option key={value} value={value}>
{label}
</option>
))}
</select>
</td>
<td>
<input
className="admin-inline-input admin-inline-input-narrow"
inputMode="numeric"
value={draft.sortOrder}
onChange={(event) => updateDraft(item.skillName, { sortOrder: event.target.value })}
/>
</td>
<td>
<input
className="admin-inline-input"
placeholder="/assets/template-previews/..."
value={draft.previewUrl}
onChange={(event) => updateDraft(item.skillName, { previewUrl: event.target.value })}
/>
<a href={livePreviewUrl} target="_blank" rel="noreferrer" className="admin-inline-link">
线
</a>
</td>
<td>
<button
type="button"
className="send-btn"
disabled={busySkill === item.skillName}
onClick={() => void saveItem(item.skillName)}
>
{busySkill === item.skillName ? '保存中…' : '保存'}
</button>
</td>
</tr>
);
})}
</tbody>
</table>
</div>
)}
</section>
</div>
);
}
+77 -2
View File
@@ -1,12 +1,12 @@
import { useEffect, useState } from 'react';
import { Link, Navigate, useParams } from 'react-router-dom';
import { getAdminUser, rechargeUser, updateAdminUser, fetchUserImageQuota, setUserImageQuota } from '../../api/client';
import { getAdminUser, rechargeUser, updateAdminUser, fetchUserImageQuota, setUserImageQuota, fetchAdminTemplateCatalog, grantUserPageTemplate } from '../../api/client';
import { CapabilitySettings } from '../../components/CapabilitySettings';
import { PolicySettings } from '../../components/PolicySettings';
import { SkillSettings } from '../../components/SkillSettings';
import { useAdminUsers } from '../hooks/useAdminUsers';
import { formatYuan } from '../utils/format';
import type { ImageQuotaView, PortalUser } from '../../types';
import type { AdminTemplateCatalogItem, ImageQuotaView, PortalUser } from '../../types';
function formatBytes(bytes: number) {
if (bytes < 1024) return `${bytes} B`;
@@ -27,6 +27,9 @@ export function UserDetailPage() {
const [imageQuotaLoading, setImageQuotaLoading] = useState(false);
const [imageRemaining, setImageRemaining] = useState('');
const [imageGrantNote, setImageGrantNote] = useState('');
const [templateCatalog, setTemplateCatalog] = useState<AdminTemplateCatalogItem[]>([]);
const [templateCatalogLoading, setTemplateCatalogLoading] = useState(false);
const [grantingTemplate, setGrantingTemplate] = useState<string | null>(null);
useEffect(() => {
if (!user?.spaceQuotaBytes) return;
@@ -91,6 +94,28 @@ export function UserDetailPage() {
setImageRemaining(String(imageQuota.remaining));
}, [imageQuota?.remaining, imageQuota?.unlimited]);
useEffect(() => {
if (!user || user.role !== 'user') {
setTemplateCatalog([]);
return;
}
let cancelled = false;
setTemplateCatalogLoading(true);
void fetchAdminTemplateCatalog()
.then((items) => {
if (!cancelled) setTemplateCatalog(items);
})
.catch(() => {
if (!cancelled) setTemplateCatalog([]);
})
.finally(() => {
if (!cancelled) setTemplateCatalogLoading(false);
});
return () => {
cancelled = true;
};
}, [user?.id, user?.role]);
const handleRecharge = async (e: React.FormEvent) => {
e.preventDefault();
if (!user) return;
@@ -148,6 +173,23 @@ export function UserDetailPage() {
: `剩余 ${imageQuota.remaining ?? 0} / 总计 ${imageQuota.total ?? 0}(套餐 ${imageQuota.limit} + 充值 ${imageQuota.bonus},已用 ${imageQuota.used}`
: '';
const handleTemplateGrant = async (skillName: string) => {
if (!user) return;
setMessage(null);
setLocalError(null);
setError(null);
setGrantingTemplate(skillName);
try {
await grantUserPageTemplate(user.id, skillName);
const label = templateCatalog.find((item) => item.skillName === skillName)?.label ?? skillName;
setMessage(`已为用户开通「${label}`);
} catch (err) {
setLocalError(err instanceof Error ? err.message : '模板授权失败');
} finally {
setGrantingTemplate(null);
}
};
if (!loading && !user && !error) {
return <Navigate to="/users" replace />;
}
@@ -315,6 +357,39 @@ export function UserDetailPage() {
<p className="muted"> {currentQuotaMb} MB</p>
</section>
<section className="admin-card">
<h2></h2>
<p className="muted">
page-template skill skill
{' '}
<Link to="/template-catalog"></Link>
</p>
{templateCatalogLoading ? (
<p className="muted"></p>
) : templateCatalog.length === 0 ? (
<p className="muted"></p>
) : (
<div className="template-grant-list">
{templateCatalog.map((item) => (
<div key={item.skillName} className="template-grant-row">
<div>
<strong>{item.label}</strong>
<div className="muted mono">{item.skillName}</div>
</div>
<button
type="button"
className="ghost-btn"
disabled={grantingTemplate === item.skillName}
onClick={() => void handleTemplateGrant(item.skillName)}
>
{grantingTemplate === item.skillName ? '开通中…' : '免费开通'}
</button>
</div>
))}
</div>
)}
</section>
<CapabilitySettings users={users} userId={user.id} userOnly />
<SkillSettings users={users} userId={user.id} userOnly />
<PolicySettings users={users} userId={user.id} userOnly />
+127 -2
View File
@@ -62,6 +62,8 @@ import type {
WechatWebNotification,
MindSearchConfig,
MindSearchServiceTestResult,
AdminTemplateCatalogItem,
AdminTemplateCatalogPatch,
} from '../types';
import type {
OrchestratorCanaryReadiness,
@@ -511,9 +513,110 @@ 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 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> {
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(
@@ -1432,3 +1535,25 @@ export async function fetchImageQuotaLedger(params: {
totalPages: number;
}>(`/admin-api/image-quota/ledger${q.toString() ? `?${q}` : ''}`);
}
export async function fetchAdminTemplateCatalog(): Promise<AdminTemplateCatalogItem[]> {
const result = await portalFetch<{ items: AdminTemplateCatalogItem[] }>('/admin-api/template-catalog');
return result.items ?? [];
}
export async function updateAdminTemplateCatalogItem(
skillName: string,
patch: AdminTemplateCatalogPatch,
): Promise<{ ok: boolean; item?: AdminTemplateCatalogItem }> {
return portalFetch(`/admin-api/template-catalog/${encodeURIComponent(skillName)}`, {
method: 'PUT',
body: JSON.stringify(patch),
});
}
export async function grantUserPageTemplate(userId: string, skillName: string) {
return portalFetch<{ ok: boolean; skillName: string; grantedSkills?: string[] }>(
`/admin-api/users/${encodeURIComponent(userId)}/template-grants/${encodeURIComponent(skillName)}`,
{ method: 'POST' },
);
}
+57
View File
@@ -2740,3 +2740,60 @@ body,
display: flex;
gap: 6px;
}
.template-catalog-table .admin-inline-input,
.template-catalog-table .admin-inline-textarea,
.template-catalog-table select {
width: 100%;
min-width: 108px;
padding: 8px 10px;
border-radius: var(--radius-md);
border: 1px solid var(--color-border-input);
background: var(--color-bg-base);
color: var(--color-text-primary);
font: inherit;
}
.template-catalog-table .admin-inline-input-narrow {
min-width: 72px;
max-width: 96px;
}
.template-catalog-table .admin-inline-textarea {
min-height: 72px;
resize: vertical;
}
.template-catalog-thumb {
width: 96px;
height: 54px;
object-fit: cover;
border-radius: var(--radius-md);
border: 1px solid var(--color-border-input);
display: block;
}
.template-catalog-thumb-fallback {
background: linear-gradient(135deg, #0a1628, #2f6f57);
}
.admin-inline-link {
display: inline-block;
margin-top: 6px;
font-size: 12px;
}
.template-grant-list {
display: grid;
gap: 10px;
}
.template-grant-row {
display: flex;
align-items: center;
justify-content: space-between;
gap: 12px;
padding: 10px 12px;
border: 1px solid var(--color-border-input);
border-radius: var(--radius-md);
}
+41
View File
@@ -244,6 +244,7 @@ export type UsageRecord = {
outputTokens: number;
costCents: number;
balanceAfterCents: number;
billingSource: 'wallet' | 'subscription';
createdAt: number;
};
@@ -343,8 +344,25 @@ export type WechatIntentRouterRuntimeState = {
config: WechatIntentRouterAdminConfig;
};
export type MindSpaceSeoGeoConfig = {
enabled: boolean;
seo: {
enabled: boolean;
canonical: boolean;
sitemap: boolean;
robotsTxt: boolean;
baiduPush: boolean;
};
geo: {
enabled: boolean;
jsonLd: boolean;
llmsTxt: boolean;
};
};
export type MindSpaceAdminConfig = {
publicPageLimit: number;
seoGeo: MindSpaceSeoGeoConfig;
analytics: {
enabled: boolean;
websiteId: string;
@@ -774,3 +792,26 @@ export type MindSearchServiceTestResult = {
resultCount?: number | null;
message: string;
};
export type AdminTemplateCatalogItem = {
skillName: string;
label: string;
description: string;
previewUrl: string | null;
priceCents: number;
currency: string;
billingMode: 'free' | 'one_time' | 'subscription';
status: 'draft' | 'active' | 'archived';
sortOrder: number;
chatSkillId: string;
};
export type AdminTemplateCatalogPatch = {
label?: string;
description?: string;
previewUrl?: string | null;
priceCents?: number;
billingMode?: AdminTemplateCatalogItem['billingMode'];
status?: AdminTemplateCatalogItem['status'];
sortOrder?: number;
};
+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,