Compare commits

...

12 Commits

Author SHA1 Message Date
john e07686f29d feat(admin): show user subscription plan and token quota in user pages
Expose active subscription summary on user list and detail so operators can see plan type, total/used/remaining tokens without opening billing subscriptions.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-09-07 16:22:08 +08:00
john c1aa1ba459 Add independent Cursor channel feature toggles in admin UI.
Replace free-text intent allowlist with per-capability switches aligned with Memind runtime policy schema.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-31 09:40:53 +08:00
john 86c7c5fb08 docs(admin): clarify subscription quota uses margin multiplier in billing UI
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-28 20:31:38 +08:00
john ec38ee086d Add dedicated admin page for TKMind Cursor experience channel (H5 + WeChat).
Centralize whitelist and channel toggles away from the WeChat page so Cursor stays an opt-in path for selected users only.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-28 15:27:10 +08:00
john 70433a3dcd feat(wechat): add intent router and Cursor executor admin controls
Expose WeChat routing policy and Cursor experience channel settings in md.tkmind.cn with matching admin-api routes.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-26 21:56:15 +08:00
john 80c2091baf feat(billing): add all/A/B tabs for formula user filter
Replace the formula assignment dropdown with tab buttons so admins can
quickly view all users or filter by billing formula A or B.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-24 11:36:13 +08:00
john f9f8b541dc fix(deploy): load vite preview config for md.tkmind.cn host
LaunchAgent web startup must use vite-preview.config.mjs so preview
allowedHosts includes md.tkmind.cn instead of blocking nginx requests.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-22 08:55:07 +08:00
john f6bb02254e feat(billing): add formula A/B admin UI and subscription renew status
Expose dual metering formula editing with batch user assignment, show
auto-renew and pending-recharge states in subscriptions, and document
billing acceptance checks for production rollout.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-22 08:45:43 +08:00
john a503fa9cbe fix(deploy): add LaunchAgent prod runner scripts for adm api and web
Restore the missing run-memind-adm-*-prod.sh entrypoints expected by 103
LaunchAgents so admin services can restart cleanly after reboot.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-16 08:57:00 +08:00
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
33 changed files with 1866 additions and 435 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 |
+28
View File
@@ -0,0 +1,28 @@
# 计费赠金 / A-B 计量 / 包月补扣 — 业务验收清单
## 1. 新用户赠金
- [ ] 新注册用户钱包仅入账 **5 元**,流水备注为「新用户赠送」
- [ ] 余额消耗至 1 元以下时,**不再**出现「新用户低余额自动赠送」流水或站内通知
- [ ] 生产环境可设置 `H5_LOW_BALANCE_GIFT_AMOUNT_CENTS=0` 锁定关闭
## 2. 计量公式 A / B
- [ ] 管理后台「计费中心 → 计量公式」可分别编辑并保存公式 A、公式 B
- [ ] 修改公式 A 不影响已分配到公式 B 的用户扣费
- [ ] 批量分配可将用户划入 A 或 B,用户列表显示当前公式
- [ ] Portal 对话扣费在数秒内按用户所属公式生效(无需重启)
## 3. 包月自动续费补扣
- [ ] 到期且开启自动续费、余额不足:套餐过期降为免费,**自动续费标记保持开启**
- [ ] 订阅列表显示「待补扣」(已过期 + 自动续费开启)
- [ ] 用户充值后余额足够:自动扣款并恢复套餐,无需等待小时任务
- [ ] 小时任务对仍开启自动续费且已过期的订阅持续重试
## 发布顺序
1. Memind103 Portal 8081)— schema + 扣费/续费运行时
2. memind_adm5174 / 8085)— 管理后台 UI 与 API
发布前分别执行各仓库 `bash scripts/check-release-ready.sh`
+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
+8
View File
@@ -0,0 +1,8 @@
#!/usr/bin/env bash
set -euo pipefail
ROOT="$(cd "$(dirname "$0")/.." && pwd)"
export ADM_API_PORT="${ADM_API_PORT:-8085}"
export NODE_BIN="${NODE_BIN:-/opt/homebrew/opt/node@24/bin/node}"
export PATH="/opt/homebrew/opt/node@24/bin:/opt/homebrew/bin:/usr/local/bin:/usr/bin:/bin:${PATH}"
cd "${ROOT}"
exec "${NODE_BIN}" server/index.mjs
+18
View File
@@ -0,0 +1,18 @@
#!/usr/bin/env bash
set -euo pipefail
ROOT="$(cd "$(dirname "$0")/.." && pwd)"
export ADM_PORT="${ADM_PORT:-5174}"
export ADM_WEB_HOST="${ADM_WEB_HOST:-0.0.0.0}"
export ADM_DEV_BACKEND="${ADM_DEV_BACKEND:-http://127.0.0.1:8085}"
export NODE_BIN="${NODE_BIN:-/opt/homebrew/opt/node@24/bin/node}"
export PATH="/opt/homebrew/opt/node@24/bin:/opt/homebrew/bin:/usr/local/bin:/usr/bin:/bin:${PATH}"
cd "${ROOT}"
if [[ ! -d dist ]]; then
echo "dist/ missing; run npm run build first" >&2
exit 1
fi
exec "${NODE_BIN}" ./node_modules/vite/bin/vite.js preview \
--config scripts/vite-preview.config.mjs \
--host 0.0.0.0 \
--port "${ADM_PORT}" \
--strictPort
+11 -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,16 @@ 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'],
// Behind nginx/Caddy on md.tkmind.cn; allow production hostnames explicitly.
allowedHosts: [
'md.tkmind.cn',
'gadm.tkmind.cn',
'localhost',
'127.0.0.1',
'10.10.0.2',
'58.38.22.103',
'.tkmind.cn',
],
proxy: {
'/api': backend,
'/auth': backend,
+144 -6
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)), '..');
@@ -107,6 +108,8 @@ export function createAdminApp(services) {
skillRuntimeConfigService,
billingConfigService,
wechatScheduleLlmConfigService,
wechatIntentRouterConfigService,
wechatCursorExecutorPolicyService,
adminSystemTestService,
systemTestAccountService,
wordFilterService,
@@ -252,6 +255,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 流量看板' });
});
@@ -264,13 +290,36 @@ export function createAdminApp(services) {
role: typeof req.query.role === 'string' ? req.query.role.trim() : '',
status: typeof req.query.status === 'string' ? req.query.status.trim() : '',
});
if (subscriptionService?.getActiveSubscription && Array.isArray(result.users) && result.users.length) {
result.users = await Promise.all(
result.users.map(async (user) => {
if (user.role !== 'user') return user;
const sub = await subscriptionService.getActiveSubscription(user.id);
if (!sub) return { ...user, subscription: null };
return {
...user,
subscription: {
planType: sub.planType,
status: sub.status,
periodTokensLimit: sub.periodTokensLimit,
periodTokensUsed: sub.periodTokensUsed,
expiresAt: sub.expiresAt,
},
};
}),
);
}
res.json(result);
});
adminApi.get('/users/:userId', requireAdmin, async (req, res) => {
const user = await userAuth.getUserPublic(req.params.userId);
if (!user) return res.status(404).json({ message: '用户不存在' });
res.json({ user });
let subscription = null;
if (subscriptionService?.getActiveSubscription && user.role === 'user') {
subscription = await subscriptionService.getActiveSubscription(req.params.userId);
}
res.json({ user, subscription });
});
adminApi.get('/summary', requireAdmin, async (_req, res) => {
@@ -371,6 +420,79 @@ export function createAdminApp(services) {
res.json(result);
});
adminApi.get('/wechat/intent-router/config', requireAdmin, async (_req, res) => {
if (!wechatIntentRouterConfigService?.getConfig) {
return res.status(503).json({ message: '微信意图路由配置未启用' });
}
const config = await wechatIntentRouterConfigService.getConfig();
return res.json({ config });
});
adminApi.get('/wechat/intent-router/runtime', requireAdmin, async (_req, res) => {
if (!wechatIntentRouterConfigService?.getRuntimeState) {
return res.status(503).json({ message: '微信意图路由配置未启用' });
}
return res.json(await wechatIntentRouterConfigService.getRuntimeState());
});
const updateWechatIntentRouterConfig = async (req, res) => {
if (!wechatIntentRouterConfigService?.updateConfig) {
return res.status(503).json({ message: '微信意图路由配置未启用' });
}
const config = await wechatIntentRouterConfigService.updateConfig(req.body ?? {}, {
updatedBy: req.currentUser.id,
});
return res.json({ config });
};
adminApi.put('/wechat/intent-router/config', requireAdmin, updateWechatIntentRouterConfig);
adminApi.patch('/wechat/intent-router/config', requireAdmin, updateWechatIntentRouterConfig);
adminApi.get('/wechat/cursor-executor/config', requireAdmin, async (_req, res) => {
if (!wechatCursorExecutorPolicyService?.getAdminConfig) {
return res.status(503).json({ message: '微信 Cursor 体验通道未启用' });
}
return res.json(await wechatCursorExecutorPolicyService.getAdminConfig());
});
adminApi.get('/wechat/cursor-executor/runtime', requireAdmin, async (_req, res) => {
if (!wechatCursorExecutorPolicyService?.getRuntimeState) {
return res.status(503).json({ message: '微信 Cursor 体验通道未启用' });
}
return res.json(await wechatCursorExecutorPolicyService.getRuntimeState());
});
const updateWechatCursorExecutorConfig = async (req, res) => {
if (!wechatCursorExecutorPolicyService?.updateAdminConfig) {
return res.status(503).json({ message: '微信 Cursor 体验通道未启用' });
}
const result = await wechatCursorExecutorPolicyService.updateAdminConfig(
req.body?.config ?? req.body ?? {},
{ updatedBy: req.currentUser.id },
);
return res.json(result);
};
adminApi.put('/wechat/cursor-executor/config', requireAdmin, updateWechatCursorExecutorConfig);
adminApi.patch('/wechat/cursor-executor/config', requireAdmin, updateWechatCursorExecutorConfig);
adminApi.get('/cursor-executor-channel/config', requireAdmin, async (_req, res) => {
if (!wechatCursorExecutorPolicyService?.getAdminConfig) {
return res.status(503).json({ message: 'TKMind 智趣体验通道未启用' });
}
return res.json(await wechatCursorExecutorPolicyService.getAdminConfig());
});
adminApi.get('/cursor-executor-channel/runtime', requireAdmin, async (_req, res) => {
if (!wechatCursorExecutorPolicyService?.getRuntimeState) {
return res.status(503).json({ message: 'TKMind 智趣体验通道未启用' });
}
return res.json(await wechatCursorExecutorPolicyService.getRuntimeState());
});
adminApi.put('/cursor-executor-channel/config', requireAdmin, updateWechatCursorExecutorConfig);
adminApi.patch('/cursor-executor-channel/config', requireAdmin, updateWechatCursorExecutorConfig);
adminApi.get('/mindspace/config', requireAdmin, async (_req, res) => {
if (!loadMindSpaceConfig) return res.status(503).json({ message: 'MindSpace 配置未启用' });
const config = await loadMindSpaceConfig(pool);
@@ -636,11 +758,12 @@ export function createAdminApp(services) {
res.json(await skillRuntimeConfigService.getPublicRuntimeConfig());
});
adminApi.get('/billing/config', requireAdmin, async (_req, res) => {
adminApi.get('/billing/config', requireAdmin, async (req, res) => {
if (!billingConfigService?.getAdminConfig) {
return res.status(503).json({ message: '计费公式配置服务未启用' });
}
return res.json(await billingConfigService.getAdminConfig());
const formula = req.query.formula ? String(req.query.formula) : undefined;
return res.json(await billingConfigService.getAdminConfig({ formula }));
});
const updateBillingConfig = async (req, res) => {
@@ -648,9 +771,10 @@ export function createAdminApp(services) {
return res.status(503).json({ message: '计费公式配置服务未启用' });
}
try {
const formula = req.body?.formula ? String(req.body.formula) : undefined;
return res.json(await billingConfigService.updateAdminConfig(
req.body?.config ?? req.body ?? {},
{ updatedBy: req.currentUser.id },
{ updatedBy: req.currentUser.id, formula },
));
} catch (error) {
if (error?.code === 'BILLING_CONFIG_ENV_LOCKED') {
@@ -663,11 +787,25 @@ export function createAdminApp(services) {
adminApi.put('/billing/config', requireAdmin, updateBillingConfig);
adminApi.patch('/billing/config', requireAdmin, updateBillingConfig);
adminApi.get('/billing/runtime', requireAdmin, async (_req, res) => {
adminApi.post('/billing/formula-assignments', requireAdmin, async (req, res) => {
if (!billingConfigService?.assignBillingFormula) {
return res.status(503).json({ message: '计费公式配置服务未启用' });
}
const { userIds, formula } = req.body ?? {};
const result = await billingConfigService.assignBillingFormula(userIds, formula);
if (!result.ok) {
return res.status(400).json(result);
}
return res.json(result);
});
adminApi.get('/billing/runtime', requireAdmin, async (req, res) => {
if (!billingConfigService?.getRuntimeState) {
return res.status(503).json({ message: '计费公式配置服务未启用' });
}
return res.json(await billingConfigService.getRuntimeState());
const formula = req.query.formula ? String(req.query.formula) : undefined;
const userId = req.query.userId ? String(req.query.userId) : undefined;
return res.json(await billingConfigService.getRuntimeState({ formula, userId }));
});
adminApi.get('/system-tests/accounts', requireAdmin, async (_req, res) => {
+20 -1
View File
@@ -28,8 +28,15 @@ function createServices({ role = 'admin' } = {}) {
getMe: async () => ({ id: 'admin-id', username: 'admin', role }),
},
billingConfigService: {
getAdminConfig: async () => ({
getAdminConfig: async ({ formula = 'A' } = {}) => ({
config: stored,
formulas: { A: stored, B: stored },
formulaMeta: {
A: { source: 'env', updatedAt: null, updatedBy: null },
B: { source: 'env', updatedAt: null, updatedBy: null },
},
activeFormula: formula,
defaultFormula: 'A',
source: 'env',
updatedAt: null,
updatedBy: null,
@@ -39,12 +46,24 @@ function createServices({ role = 'admin' } = {}) {
stored = { ...stored, ...(patch.config ?? patch) };
return {
config: stored,
formulas: { A: stored, B: stored },
formulaMeta: {
A: { source: 'admin-db', updatedAt: Date.now(), updatedBy: context?.updatedBy ?? null },
B: { source: 'admin-db', updatedAt: null, updatedBy: null },
},
activeFormula: context?.formula ?? 'A',
defaultFormula: 'A',
source: 'admin-db',
updatedAt: Date.now(),
updatedBy: context?.updatedBy ?? null,
formula: '最终扣费 = 上游成本(USD) × 汇率 × 毛利倍数',
};
},
assignBillingFormula: async (userIds, formula) => ({
ok: true,
updated: userIds.length,
formula,
}),
getRuntimeState: async () => ({
source: 'admin-db',
config: stored,
+9
View File
@@ -43,6 +43,10 @@ export async function bootstrapAdminServices() {
const { createAssetGatewayConfigService } = await importMemind('asset-gateway.mjs');
const { ensureAssetGatewaySchema } = await importMemind('db.mjs');
const { createWechatScheduleLlmConfigService } = await importMemind('wechat-schedule-llm-config.mjs');
const { createWechatIntentRouterConfigService } = await importMemind('wechat-intent-router-config.mjs');
const { createWechatCursorExecutorAdminConfigService } = await importMemind(
'wechat-cursor-executor-admin-config.mjs',
);
const { createAdminSystemTestService } = await importMemind('admin-system-tests.mjs');
const { createOpsApi } = await importMemind('admin-routes.mjs');
const { createWordFilterService, ensureWordFilterSchema } = await importMemind('word-filter.mjs');
@@ -126,6 +130,8 @@ export async function bootstrapAdminServices() {
const wechatScheduleLlmConfigService = createWechatScheduleLlmConfigService(pool, {
env: process.env,
});
const wechatIntentRouterConfigService = createWechatIntentRouterConfigService(pool);
const wechatCursorExecutorPolicyService = createWechatCursorExecutorAdminConfigService(pool);
const adminSystemTestService = createAdminSystemTestService({
pool,
userAuth,
@@ -165,6 +171,7 @@ export async function bootstrapAdminServices() {
getPlanAsync: (planType) => planCatalogService.getPlan(planType),
});
subscriptionService._planCatalogService = planCatalogService;
userAuth.setSubscriptionService(subscriptionService);
const { createPageTemplateCatalogService } = await importMemind('page-template-catalog.mjs');
const templateCatalogService = createPageTemplateCatalogService(pool, {
userAuth,
@@ -197,6 +204,8 @@ export async function bootstrapAdminServices() {
skillRuntimeConfigService,
billingConfigService,
wechatScheduleLlmConfigService,
wechatIntentRouterConfigService,
wechatCursorExecutorPolicyService,
adminSystemTestService,
systemTestAccountService,
wordFilterService,
+4
View File
@@ -112,6 +112,8 @@ ready
skillRuntimeConfigService,
billingConfigService,
wechatScheduleLlmConfigService,
wechatIntentRouterConfigService,
wechatCursorExecutorPolicyService,
adminSystemTestService,
systemTestAccountService,
wordFilterService,
@@ -142,6 +144,8 @@ ready
skillRuntimeConfigService,
billingConfigService,
wechatScheduleLlmConfigService,
wechatIntentRouterConfigService,
wechatCursorExecutorPolicyService,
adminSystemTestService,
systemTestAccountService,
wordFilterService,
+2
View File
@@ -17,6 +17,7 @@ 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 { CursorChannelPage } from './admin/pages/CursorChannelPage';
import { AssetGatewayPage } from './admin/pages/AssetGatewayPage';
import { BlockedWordsPage } from './admin/pages/BlockedWordsPage';
import { AnalyticsConfigPage } from './admin/pages/AnalyticsConfigPage';
@@ -137,6 +138,7 @@ function AdminApp({ user, onLogout }: { user: PortalUser; onLogout: () => void }
<Route path="orchestrator" element={<OrchestratorPage />} />
<Route path="providers" element={<ProvidersPage />} />
<Route path="wechat" element={<WechatPage />} />
<Route path="cursor-channel" element={<CursorChannelPage />} />
<Route path="asset-gateway" element={<AssetGatewayPage />} />
<Route path="blocked-words" element={<BlockedWordsPage />} />
<Route path="*" element={<Navigate to="/" replace />} />
+1
View File
@@ -30,6 +30,7 @@ const NAV_SECTIONS: NavSection[] = [
{
label: '平台配置',
items: [
{ to: '/cursor-channel', label: '智趣体验通道' },
{ to: '/wechat', label: '服务号' },
{ to: '/mindspace', label: 'MindSpace 配置' },
{ to: '/analytics', label: 'Analytics 配置' },
+385 -162
View File
@@ -1,6 +1,7 @@
import { useCallback, useEffect, useRef, useState } from 'react';
import { useLocation, useNavigate } from 'react-router-dom';
import {
assignBillingFormula,
cancelUserSubscription,
createSubscriptionPlan,
deleteSubscriptionPlan,
@@ -22,6 +23,7 @@ import type {
AdminSubscription,
AdminUserRow,
BillingAdminConfig,
BillingFormulaKey,
LedgerEntry,
PlanDefinition,
UsageRecord,
@@ -175,11 +177,304 @@ const DEFAULT_BILLING_FORMULA: BillingAdminConfig = {
costEstimateOutputUsdPer1M: 1.1,
};
const FORMULA_TABS: BillingFormulaKey[] = ['A', 'B'];
function FormulaConfigForm({
formulaKey,
draft,
source,
updatedAt,
envLocked,
saving,
onDraftChange,
onSave,
}: {
formulaKey: BillingFormulaKey;
draft: BillingAdminConfig;
source: string;
updatedAt: number | null;
envLocked: boolean;
saving: boolean;
onDraftChange: (next: BillingAdminConfig) => void;
onSave: (event: React.FormEvent) => void;
}) {
const patchNumber = (key: keyof BillingAdminConfig, value: string) => {
const num = Number(value);
onDraftChange({
...draft,
[key]: Number.isFinite(num) ? num : draft[key],
});
};
return (
<form className="admin-form" onSubmit={onSave}>
<p className="muted">
{formulaKey} · {SOURCE_LABELS[source] ?? source}
{updatedAt
? ` · 更新于 ${new Date(updatedAt).toLocaleString('zh-CN', { hour12: false })}`
: ''}
</p>
<label className="inline-check">
<input
type="checkbox"
checked={draft.useBackendCost}
disabled={envLocked}
onChange={(event) => onDraftChange({ ...draft, useBackendCost: event.target.checked })}
/>
<span>
<strong></strong>
<span className="muted"> USD </span>
</span>
</label>
<label>
<span>USDCNY</span>
<input
type="number"
min="0.01"
step="0.01"
disabled={envLocked}
value={draft.usdCnyRate}
onChange={(e) => patchNumber('usdCnyRate', e.target.value)}
/>
</label>
<label>
<span></span>
<input
type="number"
min="0.01"
step="0.01"
disabled={envLocked}
value={draft.marginMultiplier}
onChange={(e) => patchNumber('marginMultiplier', e.target.value)}
/>
</label>
<label>
<span></span>
<input
type="number"
min="1"
step="1"
disabled={envLocked}
value={draft.minBillCents}
onChange={(e) => patchNumber('minBillCents', e.target.value)}
/>
</label>
<h3>Token 退</h3>
<label>
<span> / 1k tokens</span>
<input
type="number"
min="0"
step="0.01"
disabled={envLocked}
value={draft.inputCentsPer1k}
onChange={(e) => patchNumber('inputCentsPer1k', e.target.value)}
/>
</label>
<label>
<span> / 1k tokens</span>
<input
type="number"
min="0"
step="0.01"
disabled={envLocked}
value={draft.outputCentsPer1k}
onChange={(e) => patchNumber('outputCentsPer1k', e.target.value)}
/>
</label>
<h3>Finish cost </h3>
<label className="inline-check">
<input
type="checkbox"
checked={draft.costEstimateFromTokens}
disabled={envLocked || !draft.useBackendCost}
onChange={(event) => onDraftChange({
...draft,
costEstimateFromTokens: event.target.checked,
})}
/>
<span>
<strong> Token </strong>
<span className="muted"> </span>
</span>
</label>
<label>
<span>USD / 1M</span>
<input
type="number"
min="0"
step="0.01"
disabled={envLocked || !draft.useBackendCost}
value={draft.costEstimateInputUsdPer1M}
onChange={(e) => patchNumber('costEstimateInputUsdPer1M', e.target.value)}
/>
</label>
<label>
<span>USD / 1M</span>
<input
type="number"
min="0"
step="0.01"
disabled={envLocked || !draft.useBackendCost}
value={draft.costEstimateOutputUsdPer1M}
onChange={(e) => patchNumber('costEstimateOutputUsdPer1M', e.target.value)}
/>
</label>
<p className="muted">
USD × {draft.usdCnyRate} × {draft.marginMultiplier}
Token × {draft.marginMultiplier}
</p>
<button type="submit" className="send-btn" disabled={saving || envLocked}>
{saving ? '保存中…' : `保存计量公式 ${formulaKey}`}
</button>
</form>
);
}
function FormulaAssignmentSection() {
const { users, reload, error, setError } = useAdminUsers();
const [selected, setSelected] = useState<string[]>([]);
const [formulaFilter, setFormulaFilter] = useState<'all' | BillingFormulaKey>('all');
const [search, setSearch] = useState('');
const [assigning, setAssigning] = useState(false);
const [message, setMessage] = useState<string | null>(null);
const filteredUsers = users.filter((user) => {
if (user.role !== 'user') return false;
const formula = user.billingFormula ?? 'A';
if (formulaFilter !== 'all' && formula !== formulaFilter) return false;
if (!search.trim()) return true;
const q = search.toLowerCase();
return user.username.toLowerCase().includes(q) || user.displayName.toLowerCase().includes(q);
});
const toggleUser = (userId: string) => {
setSelected((prev) => (
prev.includes(userId) ? prev.filter((id) => id !== userId) : [...prev, userId]
));
};
const toggleAll = () => {
const ids = filteredUsers.map((user) => user.id);
const allSelected = ids.length > 0 && ids.every((id) => selected.includes(id));
setSelected((prev) => (
allSelected ? prev.filter((id) => !ids.includes(id)) : [...new Set([...prev, ...ids])]
));
};
const handleAssign = async (formula: BillingFormulaKey) => {
if (!selected.length) return;
setAssigning(true);
setMessage(null);
setError(null);
try {
const result = await assignBillingFormula(selected, formula);
setMessage(`已将 ${result.updated} 位用户分配到计量公式 ${formula}`);
setSelected([]);
await reload();
} catch (err) {
setError(err instanceof Error ? err.message : '批量分配失败');
} finally {
setAssigning(false);
}
};
return (
<section className="admin-card">
<h2></h2>
<p className="muted"> A B使 A</p>
{error && <p className="banner banner-error">{error}</p>}
{message && <p className="banner banner-info">{message}</p>}
<div className="admin-tabs billing-formula-filter-tabs" role="tablist" aria-label="按计量公式筛选用户">
{(['all', 'A', 'B'] as const).map((key) => (
<button
key={key}
type="button"
role="tab"
aria-selected={formulaFilter === key}
className={`admin-tab${formulaFilter === key ? ' active' : ''}`}
onClick={() => setFormulaFilter(key)}
>
{key === 'all' ? '全部' : `公式 ${key}`}
</button>
))}
</div>
<div className="billing-toolbar">
<input
className="users-search-input"
placeholder="搜索用户名 / 显示名"
value={search}
onChange={(event) => setSearch(event.target.value)}
/>
<button type="button" className="ghost-btn" onClick={() => void reload()} disabled={assigning}>
</button>
<button type="button" className="send-btn" disabled={!selected.length || assigning} onClick={() => void handleAssign('A')}>
A
</button>
<button type="button" className="send-btn" disabled={!selected.length || assigning} onClick={() => void handleAssign('B')}>
B
</button>
</div>
<div className="admin-table-wrap">
<table className="admin-table">
<thead>
<tr>
<th>
<input
type="checkbox"
checked={filteredUsers.length > 0 && filteredUsers.every((user) => selected.includes(user.id))}
onChange={toggleAll}
/>
</th>
<th></th>
<th></th>
<th></th>
</tr>
</thead>
<tbody>
{filteredUsers.map((user) => (
<tr key={user.id}>
<td>
<input
type="checkbox"
checked={selected.includes(user.id)}
onChange={() => toggleUser(user.id)}
/>
</td>
<td>
<span>{user.displayName}</span>
<span className="muted"> @{user.username}</span>
</td>
<td><strong>{user.billingFormula ?? 'A'}</strong></td>
<td className="billing-num">¥{formatYuan(user.balanceCents)}</td>
</tr>
))}
</tbody>
</table>
</div>
{filteredUsers.length === 0 ? <p className="muted billing-empty"></p> : null}
</section>
);
}
function FormulaTab() {
const [draft, setDraft] = useState<BillingAdminConfig>(DEFAULT_BILLING_FORMULA);
const [source, setSource] = useState('default');
const [updatedAt, setUpdatedAt] = useState<number | null>(null);
const [formula, setFormula] = useState<string>('');
const [activeFormula, setActiveFormula] = useState<BillingFormulaKey>('A');
const [drafts, setDrafts] = useState<Record<BillingFormulaKey, BillingAdminConfig>>({
A: DEFAULT_BILLING_FORMULA,
B: DEFAULT_BILLING_FORMULA,
});
const [meta, setMeta] = useState<Record<BillingFormulaKey, { source: string; updatedAt: number | null }>>({
A: { source: 'default', updatedAt: null },
B: { source: 'default', updatedAt: null },
});
const [formulaText, setFormulaText] = useState('');
const [envLocked, setEnvLocked] = useState(false);
const [loading, setLoading] = useState(true);
const [saving, setSaving] = useState(false);
@@ -190,11 +485,22 @@ function FormulaTab() {
setLoading(true);
setError(null);
try {
const result = await getBillingConfig();
setDraft(result.config);
setSource(result.source ?? 'default');
setUpdatedAt(result.updatedAt ?? null);
setFormula(result.formula ?? '');
const result = await getBillingConfig('A');
setDrafts({
A: result.formulas?.A ?? result.config,
B: result.formulas?.B ?? result.config,
});
setMeta({
A: {
source: result.formulaMeta?.A?.source ?? result.source ?? 'default',
updatedAt: result.formulaMeta?.A?.updatedAt ?? result.updatedAt ?? null,
},
B: {
source: result.formulaMeta?.B?.source ?? result.source ?? 'default',
updatedAt: result.formulaMeta?.B?.updatedAt ?? null,
},
});
setFormulaText(result.formula ?? '');
setEnvLocked(Boolean(result.envOverrideActive));
} catch (err) {
setError(err instanceof Error ? err.message : '加载计量公式失败');
@@ -207,16 +513,9 @@ function FormulaTab() {
void load();
}, [load]);
const patchNumber = (key: keyof BillingAdminConfig, value: string) => {
const num = Number(value);
setDraft((prev) => ({
...prev,
[key]: Number.isFinite(num) ? num : prev[key],
}));
};
const handleSave = async (e: React.FormEvent) => {
e.preventDefault();
const draft = drafts[activeFormula];
if (envLocked) {
setError('当前环境已锁定为仅读 envH5_BILLING_CONFIG_SOURCE=env),无法保存。');
return;
@@ -228,7 +527,7 @@ function FormulaTab() {
if (
draft.useBackendCost
&& !window.confirm(
`确认保存成本模式扣费?\n最终扣费 = 上游成本(USD) × ${draft.usdCnyRate} × ${draft.marginMultiplier}`,
`确认保存公式 ${activeFormula} 成本模式扣费?\n最终扣费 = 上游成本(USD) × ${draft.usdCnyRate} × ${draft.marginMultiplier}`,
)
) {
return;
@@ -237,13 +536,24 @@ function FormulaTab() {
setError(null);
setMessage(null);
try {
const result = await updateBillingConfig(draft);
setDraft(result.config);
setSource(result.source ?? 'admin-db');
setUpdatedAt(result.updatedAt ?? null);
setFormula(result.formula ?? '');
const result = await updateBillingConfig(draft, activeFormula);
setDrafts({
A: result.formulas?.A ?? drafts.A,
B: result.formulas?.B ?? drafts.B,
});
setMeta({
A: {
source: result.formulaMeta?.A?.source ?? result.source ?? 'admin-db',
updatedAt: result.formulaMeta?.A?.updatedAt ?? result.updatedAt ?? null,
},
B: {
source: result.formulaMeta?.B?.source ?? result.source ?? 'admin-db',
updatedAt: result.formulaMeta?.B?.updatedAt ?? result.updatedAt ?? null,
},
});
setFormulaText(result.formula ?? '');
setEnvLocked(Boolean(result.envOverrideActive));
setMessage('计量公式已保存。Portal 扣费会在数秒内读取新配置,无需重启。');
setMessage(`计量公式 ${activeFormula} 已保存。Portal 扣费会在数秒内读取新配置,无需重启。`);
} catch (err) {
setError(err instanceof Error ? err.message : '保存失败');
} finally {
@@ -252,144 +562,45 @@ function FormulaTab() {
};
return (
<section className="admin-card">
<h2></h2>
<p className="muted">
= (USD) × × 退 Token
</p>
{loading ? <p className="muted"></p> : null}
{error && <p className="banner banner-error">{error}</p>}
{message && <p className="banner banner-info">{message}</p>}
{!loading ? (
<form className="admin-form" onSubmit={handleSave}>
<p className="muted">
{SOURCE_LABELS[source] ?? source}
{updatedAt
? ` · 更新于 ${new Date(updatedAt).toLocaleString('zh-CN', { hour12: false })}`
: ''}
</p>
{formula ? <p className="muted">{formula}</p> : null}
<label className="inline-check">
<input
type="checkbox"
checked={draft.useBackendCost}
disabled={envLocked}
onChange={(event) => setDraft((prev) => ({ ...prev, useBackendCost: event.target.checked }))}
/>
<span>
<strong></strong>
<span className="muted"> USD </span>
</span>
</label>
<label>
<span>USDCNY</span>
<input
type="number"
min="0.01"
step="0.01"
disabled={envLocked}
value={draft.usdCnyRate}
onChange={(e) => patchNumber('usdCnyRate', e.target.value)}
/>
</label>
<label>
<span></span>
<input
type="number"
min="0.01"
step="0.01"
disabled={envLocked}
value={draft.marginMultiplier}
onChange={(e) => patchNumber('marginMultiplier', e.target.value)}
/>
</label>
<label>
<span></span>
<input
type="number"
min="1"
step="1"
disabled={envLocked}
value={draft.minBillCents}
onChange={(e) => patchNumber('minBillCents', e.target.value)}
/>
</label>
<h3>Token 退</h3>
<label>
<span> / 1k tokens</span>
<input
type="number"
min="0"
step="0.01"
disabled={envLocked}
value={draft.inputCentsPer1k}
onChange={(e) => patchNumber('inputCentsPer1k', e.target.value)}
/>
</label>
<label>
<span> / 1k tokens</span>
<input
type="number"
min="0"
step="0.01"
disabled={envLocked}
value={draft.outputCentsPer1k}
onChange={(e) => patchNumber('outputCentsPer1k', e.target.value)}
/>
</label>
<h3>Finish cost </h3>
<label className="inline-check">
<input
type="checkbox"
checked={draft.costEstimateFromTokens}
disabled={envLocked || !draft.useBackendCost}
onChange={(event) => setDraft((prev) => ({
...prev,
costEstimateFromTokens: event.target.checked,
}))}
/>
<span>
<strong> Token </strong>
<span className="muted"> </span>
</span>
</label>
<label>
<span>USD / 1M</span>
<input
type="number"
min="0"
step="0.01"
disabled={envLocked || !draft.useBackendCost}
value={draft.costEstimateInputUsdPer1M}
onChange={(e) => patchNumber('costEstimateInputUsdPer1M', e.target.value)}
/>
</label>
<label>
<span>USD / 1M</span>
<input
type="number"
min="0"
step="0.01"
disabled={envLocked || !draft.useBackendCost}
value={draft.costEstimateOutputUsdPer1M}
onChange={(e) => patchNumber('costEstimateOutputUsdPer1M', e.target.value)}
/>
</label>
<p className="muted">
USD × {draft.usdCnyRate} × {draft.marginMultiplier}
</p>
<button type="submit" className="send-btn" disabled={saving || envLocked}>
{saving ? '保存中…' : '保存计量公式'}
</button>
</form>
) : null}
</section>
<>
<section className="admin-card">
<h2></h2>
<p className="muted">
A / B A B
</p>
{formulaText ? <p className="muted">{formulaText}</p> : null}
<div className="admin-tabs" role="tablist">
{FORMULA_TABS.map((key) => (
<button
key={key}
type="button"
role="tab"
aria-selected={activeFormula === key}
className={`admin-tab${activeFormula === key ? ' active' : ''}`}
onClick={() => setActiveFormula(key)}
>
{key}
</button>
))}
</div>
{loading ? <p className="muted"></p> : null}
{error && <p className="banner banner-error">{error}</p>}
{message && <p className="banner banner-info">{message}</p>}
{!loading ? (
<FormulaConfigForm
formulaKey={activeFormula}
draft={drafts[activeFormula]}
source={meta[activeFormula].source}
updatedAt={meta[activeFormula].updatedAt}
envLocked={envLocked}
saving={saving}
onDraftChange={(next) => setDrafts((prev) => ({ ...prev, [activeFormula]: next }))}
onSave={handleSave}
/>
) : null}
</section>
<FormulaAssignmentSection />
</>
);
}
@@ -1545,6 +1756,12 @@ function SubscriptionsTab() {
};
const STATUS_LABEL: Record<string, string> = { active: '有效', expired: '已到期', cancelled: '已取消' };
const autoRenewLabel = (row: AdminSubscription) => {
if (!row.autoRenew) return '未开启';
if (row.status === 'active') return '已开启';
if (row.status === 'expired') return '待补扣';
return '—';
};
return (
<>
@@ -1637,6 +1854,7 @@ function SubscriptionsTab() {
<th></th>
<th></th>
<th></th>
<th></th>
<th style={{ textAlign: 'right' }}>Token </th>
<th style={{ textAlign: 'right' }}></th>
<th></th>
@@ -1657,6 +1875,11 @@ function SubscriptionsTab() {
{STATUS_LABEL[row.status] ?? row.status}
</span>
</td>
<td>
<span className={row.autoRenew && row.status === 'expired' ? 'text-error' : undefined}>
{autoRenewLabel(row)}
</span>
</td>
<td className="billing-num">
{row.periodTokensUsed.toLocaleString()}
{row.periodTokensLimit > 0 && (
+624
View File
@@ -0,0 +1,624 @@
import { useCallback, useEffect, useMemo, useState } from 'react';
import { Link } from 'react-router-dom';
import {
getWechatCursorExecutorConfig,
getWechatCursorExecutorRuntime,
listAdminUsers,
patchWechatCursorExecutorConfig,
} from '../../api/client';
import type {
AdminUserRow,
CursorChannelFeatureKey,
CursorChannelFeatures,
WechatCursorExecutorAdminConfig,
WechatCursorExecutorRuntimeState,
} from '../../types';
import { formatTime } from '../utils/format';
type CursorChannelForm = {
enabled: boolean;
h5Enabled: boolean;
wechatEnabled: boolean;
selectedUserIds: string[];
manualAllowlistEntries: string[];
features: CursorChannelFeatures;
fallbackToDeepseek: boolean;
};
const FEATURE_OPTIONS: Array<{
key: CursorChannelFeatureKey;
label: string;
description: string;
}> = [
{
key: 'pageGenerate',
label: '页面生成',
description: 'H5 / 服务号 page.generate 与页面落盘任务走 Cursor。',
},
{
key: 'pageData',
label: '问卷 Page Data',
description: 'H5 问卷收集、PG 持久化与相关分析页走 Cursor。',
},
{
key: 'excelAnalysis',
label: 'Excel 分析',
description: 'H5 表格分析与结果页走 Cursor。',
},
{
key: 'chatBridge',
label: '普通聊天(Chat Bridge',
description: '白名单用户的 Goose 会话 LLM 走 Cursor Chat Bridge;需服务端 MEMIND_CURSOR_CHAT_BRIDGE_ENABLED=1。',
},
{
key: 'scheduledTasks',
label: '定时任务执行',
description: '到点自动任务(新闻/天气等)走 Cursor 而非 Goose Session。',
},
];
function defaultFeatures(): CursorChannelFeatures {
return {
pageGenerate: { enabled: true },
pageData: { enabled: false },
excelAnalysis: { enabled: false },
chatBridge: { enabled: false },
scheduledTasks: { enabled: false },
};
}
function normalizeFeatures(
config: WechatCursorExecutorAdminConfig | undefined,
): CursorChannelFeatures {
const base = defaultFeatures();
const raw = config?.features;
if (raw) {
for (const option of FEATURE_OPTIONS) {
base[option.key] = {
enabled: Boolean(raw[option.key]?.enabled),
};
}
return base;
}
const intents = Array.isArray(config?.intentAllowlist) ? config.intentAllowlist : ['page.generate'];
base.pageGenerate.enabled = intents.includes('page.generate');
return base;
}
const USER_ID_PATTERN =
/^[0-9a-f]{8}-[0-9a-f]{4}-[1-5][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i;
function userMatchesAllowlistToken(user: AdminUserRow, token: string) {
const normalized = token.trim().toLowerCase();
if (!normalized) return false;
const identities = [user.id, user.username, user.slug, user.publishSlug, user.displayName]
.map((value) => String(value ?? '').trim().toLowerCase())
.filter(Boolean);
return identities.includes(normalized);
}
function resolveAllowlistTokens(tokens: string[], users: AdminUserRow[]) {
const selectedUserIds: string[] = [];
const manualAllowlistEntries: string[] = [];
const seenIds = new Set<string>();
for (const rawToken of tokens) {
const token = String(rawToken ?? '').trim();
if (!token) continue;
const matchedUser = users.find((user) => userMatchesAllowlistToken(user, token));
if (matchedUser) {
if (!seenIds.has(matchedUser.id)) {
seenIds.add(matchedUser.id);
selectedUserIds.push(matchedUser.id);
}
continue;
}
if (USER_ID_PATTERN.test(token)) {
if (!seenIds.has(token)) {
seenIds.add(token);
selectedUserIds.push(token);
}
continue;
}
manualAllowlistEntries.push(token);
}
return { selectedUserIds, manualAllowlistEntries };
}
function configToForm(
config: WechatCursorExecutorAdminConfig | undefined,
users: AdminUserRow[] = [],
): CursorChannelForm {
const tokens = Array.isArray(config?.userAllowlist) ? config.userAllowlist : [];
const channels = Array.isArray(config?.channelAllowlist) ? config.channelAllowlist : ['h5', 'wechat_mp'];
const { selectedUserIds, manualAllowlistEntries } = resolveAllowlistTokens(tokens, users);
return {
enabled: Boolean(config?.enabled),
h5Enabled: channels.includes('h5'),
wechatEnabled: channels.includes('wechat_mp'),
selectedUserIds,
manualAllowlistEntries,
features: normalizeFeatures(config),
fallbackToDeepseek: config?.fallbackToDeepseek !== false,
};
}
function formToAllowlist(form: CursorChannelForm) {
const manual = form.manualAllowlistEntries.map((item) => item.trim()).filter(Boolean);
return [...new Set([...form.selectedUserIds, ...manual])];
}
function formToChannelAllowlist(form: CursorChannelForm) {
const channels: string[] = [];
if (form.h5Enabled) channels.push('h5');
if (form.wechatEnabled) channels.push('wechat_mp');
return channels;
}
function CursorAllowlistPickerModal({
open,
users,
selectedUserIds,
busy,
onClose,
onConfirm,
}: {
open: boolean;
users: AdminUserRow[];
selectedUserIds: string[];
busy?: boolean;
onClose: () => void;
onConfirm: (nextIds: string[]) => void;
}) {
const [search, setSearch] = useState('');
const [draftIds, setDraftIds] = useState<string[]>(selectedUserIds);
const [pickerUsers, setPickerUsers] = useState<AdminUserRow[]>(users);
const [loadingUsers, setLoadingUsers] = useState(false);
useEffect(() => {
if (!open) return;
setDraftIds(selectedUserIds);
setSearch('');
setPickerUsers(users);
}, [open, selectedUserIds, users]);
useEffect(() => {
if (!open) return;
const timer = window.setTimeout(() => {
void (async () => {
setLoadingUsers(true);
try {
const result = await listAdminUsers({
page: 1,
pageSize: 500,
status: 'active',
search: search.trim() || undefined,
});
setPickerUsers(result.items);
} catch {
setPickerUsers(users);
} finally {
setLoadingUsers(false);
}
})();
}, search.trim() ? 250 : 0);
return () => window.clearTimeout(timer);
}, [open, search, users]);
if (!open) return null;
const visibleIds = pickerUsers.map((user) => user.id);
const allVisibleSelected =
visibleIds.length > 0 && visibleIds.every((id) => draftIds.includes(id));
return (
<div
className="modal-backdrop"
role="presentation"
onMouseDown={(event) => {
if (event.target === event.currentTarget) onClose();
}}
>
<div
className="modal-box"
role="dialog"
aria-modal="true"
aria-labelledby="cursor-allowlist-picker-title"
style={{ maxWidth: 720, width: 'min(720px, calc(100vw - 32px))' }}
onMouseDown={(event) => event.stopPropagation()}
>
<div className="modal-head">
<div>
<h3 id="cursor-allowlist-picker-title"></h3>
<p className="muted" style={{ margin: '6px 0 0', fontSize: 13 }}>
TKMind DeepSeek/Goose
</p>
</div>
<button type="button" className="modal-close" onClick={onClose} aria-label="关闭">
×
</button>
</div>
<div className="wechat-toolbar" style={{ marginBottom: 12 }}>
<input
value={search}
onChange={(event) => setSearch(event.target.value)}
placeholder="搜索用户名、昵称或 userId"
style={{ flex: 1, minWidth: 220 }}
autoFocus
/>
<button
type="button"
className="ghost-btn"
disabled={loadingUsers || pickerUsers.length === 0}
onClick={() => {
setDraftIds((current) => {
if (allVisibleSelected) {
return current.filter((id) => !visibleIds.includes(id));
}
return [...new Set([...current, ...visibleIds])];
});
}}
>
{allVisibleSelected ? '取消全选当前列表' : '全选当前列表'}
</button>
</div>
<div className="cursor-allowlist-picker-list">
{loadingUsers ? <p className="muted"></p> : null}
{!loadingUsers && pickerUsers.length === 0 ? (
<p className="muted"></p>
) : null}
{pickerUsers.map((user) => (
<label key={user.id} className="cursor-allowlist-picker-row">
<input
type="checkbox"
checked={draftIds.includes(user.id)}
onChange={() => {
setDraftIds((current) =>
current.includes(user.id)
? current.filter((id) => id !== user.id)
: [...current, user.id],
);
}}
/>
<span>
{user.displayName || user.username}
<span className="muted"> @{user.username}</span>
</span>
</label>
))}
</div>
<div className="modal-actions">
<button type="button" className="ghost-btn" onClick={onClose} disabled={busy}>
</button>
<button
type="button"
className="primary-btn"
disabled={busy}
onClick={() => onConfirm(draftIds)}
>
({draftIds.length})
</button>
</div>
</div>
</div>
);
}
export function CursorChannelPage() {
const [users, setUsers] = useState<AdminUserRow[]>([]);
const [form, setForm] = useState<CursorChannelForm | null>(null);
const [savedForm, setSavedForm] = useState<CursorChannelForm | null>(null);
const [runtime, setRuntime] = useState<WechatCursorExecutorRuntimeState | null>(null);
const [pickerOpen, setPickerOpen] = useState(false);
const [loading, setLoading] = useState(true);
const [saving, setSaving] = useState(false);
const [error, setError] = useState<string | null>(null);
const [notice, setNotice] = useState<string | null>(null);
const dirty = useMemo(() => {
if (!form || !savedForm) return false;
return JSON.stringify(form) !== JSON.stringify(savedForm);
}, [form, savedForm]);
const selectedUsers = useMemo(() => {
if (!form) return [];
const byId = new Map(users.map((user) => [user.id, user]));
return form.selectedUserIds.map((userId) => ({
userId,
user: byId.get(userId) ?? null,
}));
}, [form, users]);
const load = useCallback(async () => {
setLoading(true);
setError(null);
try {
const [nextUsers, nextConfig, nextRuntime] = await Promise.all([
listAdminUsers({ page: 1, pageSize: 200, status: 'active' }),
getWechatCursorExecutorConfig().catch(() => null),
getWechatCursorExecutorRuntime().catch(() => null),
]);
setUsers(nextUsers.items);
const nextForm = configToForm(nextConfig?.config, nextUsers.items);
setForm(nextForm);
setSavedForm(nextForm);
setRuntime(nextRuntime);
} catch (err) {
setError(err instanceof Error ? err.message : '加载失败');
} finally {
setLoading(false);
}
}, []);
useEffect(() => {
void load();
}, [load]);
const handleSave = async () => {
if (!form) return;
setSaving(true);
setError(null);
setNotice(null);
try {
const channelAllowlist = formToChannelAllowlist(form);
if (form.enabled && channelAllowlist.length === 0) {
throw new Error('启用体验通道时,至少选择一个接入渠道(H5 或服务号)');
}
if (form.enabled && formToAllowlist(form).length === 0) {
throw new Error('启用体验通道时,至少选择一名白名单用户');
}
const result = await patchWechatCursorExecutorConfig({
enabled: form.enabled,
userAllowlist: formToAllowlist(form),
channelAllowlist,
features: form.features,
fallbackToDeepseek: form.fallbackToDeepseek,
});
const nextForm = configToForm(result.config, users);
setForm(nextForm);
setSavedForm(nextForm);
setRuntime(await getWechatCursorExecutorRuntime().catch(() => null));
setNotice('TKMind 智趣体验通道已保存。未在白名单内的用户不受影响。');
} catch (err) {
setError(err instanceof Error ? err.message : '保存失败');
} finally {
setSaving(false);
}
};
return (
<div className="admin-page">
<div className="admin-page-head">
<h2>TKMind </h2>
<p className="muted">
DeepSeek/Goose Cursor H5
</p>
</div>
{error && <p className="banner banner-error">{error}</p>}
{notice && <p className="banner banner-info">{notice}</p>}
<section className="admin-card">
<h3></h3>
<ul className="muted" style={{ margin: '8px 0 0', paddingLeft: 20, lineHeight: 1.7 }}>
<li>
<strong></strong>
</li>
<li>
<strong>H5 / </strong> H5 page.generate
</li>
<li>
<strong></strong>Excel Bridge DeepSeek/Goose
</li>
<li> DeepSeek/Goose </li>
</ul>
<p className="muted" style={{ marginTop: 12 }}>
<code>MEMIND_CURSOR_EXECUTOR_ENABLED=1</code> Tool Gateway Memind{' '}
<code>.env.example</code>
</p>
</section>
{form ? (
<section className="admin-card">
<div className="admin-card-head">
<div>
<h2></h2>
<p className="muted" style={{ marginTop: 6 }}>
</p>
</div>
<button
type="button"
className="ghost-btn"
onClick={() => void handleSave()}
disabled={loading || saving || !dirty}
>
{saving ? '保存中…' : dirty ? '保存配置' : '已保存'}
</button>
</div>
<div className="admin-form" style={{ marginTop: 16 }}>
<label>
<input
type="checkbox"
checked={form.enabled}
disabled={loading || saving}
onChange={(event) =>
setForm((current) => current && { ...current, enabled: event.target.checked })
}
/>{' '}
</label>
<fieldset style={{ border: 'none', padding: 0, margin: '12px 0' }}>
<legend className="muted" style={{ marginBottom: 8 }}>
</legend>
<label style={{ display: 'block', marginBottom: 8 }}>
<input
type="checkbox"
checked={form.h5Enabled}
disabled={loading || saving}
onChange={(event) =>
setForm((current) => current && { ...current, h5Enabled: event.target.checked })
}
/>{' '}
H5
</label>
<label style={{ display: 'block' }}>
<input
type="checkbox"
checked={form.wechatEnabled}
disabled={loading || saving}
onChange={(event) =>
setForm((current) => current && { ...current, wechatEnabled: event.target.checked })
}
/>{' '}
</label>
</fieldset>
<label>
<input
type="checkbox"
checked={form.fallbackToDeepseek}
disabled={loading || saving}
onChange={(event) =>
setForm((current) =>
current && { ...current, fallbackToDeepseek: event.target.checked },
)
}
/>{' '}
Cursor 退 DeepSeek / Goose
</label>
<fieldset style={{ border: 'none', padding: 0, margin: '12px 0' }}>
<legend className="muted" style={{ marginBottom: 8 }}>
</legend>
{FEATURE_OPTIONS.map((option) => (
<label key={option.key} style={{ display: 'block', marginBottom: 10 }}>
<input
type="checkbox"
checked={form.features[option.key]?.enabled ?? false}
disabled={loading || saving || !form.enabled}
onChange={(event) =>
setForm((current) =>
current
? {
...current,
features: {
...current.features,
[option.key]: { enabled: event.target.checked },
},
}
: current,
)
}
/>{' '}
<strong>{option.label}</strong>
<span className="muted" style={{ display: 'block', marginLeft: 22, fontSize: 13 }}>
{option.description}
</span>
</label>
))}
</fieldset>
<label>
<div className="wechat-toolbar" style={{ marginTop: 8, marginBottom: 8 }}>
<button
type="button"
className="ghost-btn"
disabled={loading || saving}
onClick={() => setPickerOpen(true)}
>
</button>
{form.selectedUserIds.length > 0 ? (
<button
type="button"
className="ghost-btn"
disabled={loading || saving}
onClick={() =>
setForm((current) => current && { ...current, selectedUserIds: [] })
}
>
</button>
) : null}
</div>
{selectedUsers.length > 0 ? (
<div className="cursor-allowlist-chips">
{selectedUsers.map(({ userId, user }) => (
<span key={userId} className="cursor-allowlist-chip">
<span>
{user ? user.displayName || user.username : userId}
{user ? <span className="muted"> @{user.username}</span> : null}
</span>
<button
type="button"
className="cursor-allowlist-chip-remove"
aria-label="移出白名单"
disabled={loading || saving}
onClick={() =>
setForm((current) =>
current
? {
...current,
selectedUserIds: current.selectedUserIds.filter((id) => id !== userId),
}
: current,
)
}
>
×
</button>
</span>
))}
</div>
) : (
<p className="muted" style={{ margin: 0 }}>
<Link to="/users"></Link>
</p>
)}
</label>
{runtime ? (
<p className="muted" style={{ marginTop: 12 }}>
{runtime.policy.enabled ? '已开启' : '已关闭'}
{' · '}
{runtime.policy.userAllowlist.length}
{' · '}
{(runtime.policy.channelAllowlist ?? []).join('、') || '无'}
{' · '}
{' '}
{FEATURE_OPTIONS.filter((item) => runtime.policy.features?.[item.key]?.enabled)
.map((item) => item.label)
.join('、') || '无'}
{' · '}
{runtime.source}
{runtime.updatedAt ? ` · 更新 ${formatTime(runtime.updatedAt)}` : ''}
</p>
) : null}
</div>
</section>
) : null}
{form ? (
<CursorAllowlistPickerModal
open={pickerOpen}
users={users}
selectedUserIds={form.selectedUserIds}
busy={loading || saving}
onClose={() => setPickerOpen(false)}
onConfirm={(nextIds) => {
setForm((current) => current && { ...current, selectedUserIds: nextIds });
setPickerOpen(false);
}}
/>
) : null}
</div>
);
}
+17 -9
View File
@@ -4,18 +4,18 @@ import type { MindSpaceAdminConfig, MindSpaceSeoGeoConfig } from '../../types';
function defaultSeoGeoConfig(): MindSpaceSeoGeoConfig {
return {
enabled: false,
enabled: true,
seo: {
enabled: false,
enabled: true,
canonical: true,
sitemap: false,
robotsTxt: false,
baiduPush: false,
sitemap: true,
robotsTxt: true,
baiduPush: true,
},
geo: {
enabled: false,
jsonLd: false,
llmsTxt: false,
enabled: true,
jsonLd: true,
llmsTxt: true,
},
};
}
@@ -122,7 +122,7 @@ export function MindSpacePage() {
<section className="admin-card">
<h2>SEO / GEO</h2>
<p className="muted">
access_mode=publicstatus=online noindex sitemap / llms.txt
线 SEO/GEO noindex sitemap / llms.txt
</p>
<div className="admin-form">
<label className="admin-form-row">
@@ -203,6 +203,14 @@ export function MindSpacePage() {
<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>
+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>
+126 -4
View File
@@ -1,12 +1,27 @@
import { useEffect, useState } from 'react';
import { Link, Navigate, useParams } from 'react-router-dom';
import { getAdminUser, rechargeUser, updateAdminUser, fetchUserImageQuota, setUserImageQuota, fetchAdminTemplateCatalog, grantUserPageTemplate } from '../../api/client';
import {
getAdminUser,
listSubscriptionPlans,
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 { AdminTemplateCatalogItem, ImageQuotaView, PortalUser } from '../../types';
import { formatTime, formatYuan } from '../utils/format';
import {
formatTokenCount,
formatTokenQuota,
resolvePlanLabel,
subscriptionStatusLabel,
} from '../utils/subscription';
import type { AdminSubscription, AdminTemplateCatalogItem, ImageQuotaView, PlanDefinition, PortalUser } from '../../types';
function formatBytes(bytes: number) {
if (bytes < 1024) return `${bytes} B`;
@@ -18,6 +33,8 @@ export function UserDetailPage() {
const { userId = '' } = useParams();
const { users, reload, setError } = useAdminUsers();
const [user, setUser] = useState<PortalUser | null>(null);
const [subscription, setSubscription] = useState<AdminSubscription | null>(null);
const [plans, setPlans] = useState<PlanDefinition[]>([]);
const [loading, setLoading] = useState(true);
const [error, setLocalError] = useState<string | null>(null);
const [message, setMessage] = useState<string | null>(null);
@@ -42,9 +59,10 @@ export function UserDetailPage() {
setLoading(true);
setLocalError(null);
void getAdminUser(userId)
.then((nextUser) => {
.then(({ user: nextUser, subscription: nextSubscription }) => {
if (cancelled) return;
setUser(nextUser);
setSubscription(nextSubscription);
})
.catch((err) => {
if (cancelled) return;
@@ -64,6 +82,20 @@ export function UserDetailPage() {
};
}, [userId, users]);
useEffect(() => {
let cancelled = false;
void listSubscriptionPlans()
.then((nextPlans) => {
if (!cancelled) setPlans(nextPlans);
})
.catch(() => {
if (!cancelled) setPlans([]);
});
return () => {
cancelled = true;
};
}, []);
useEffect(() => {
if (!user || user.role !== 'user') {
setImageQuota(null);
@@ -173,6 +205,17 @@ export function UserDetailPage() {
: `剩余 ${imageQuota.remaining ?? 0} / 总计 ${imageQuota.total ?? 0}(套餐 ${imageQuota.limit} + 充值 ${imageQuota.bonus},已用 ${imageQuota.used}`
: '';
const activePlanType = subscription?.planType ?? user?.planType ?? 'free';
const planLabel = resolvePlanLabel(activePlanType, plans);
const tokenLimit = subscription?.periodTokensLimit ?? 0;
const tokenUsed = subscription?.periodTokensUsed ?? 0;
const tokenRemaining = tokenLimit === 0 ? null : Math.max(0, tokenLimit - tokenUsed);
const tokenQuotaSummary = subscription
? formatTokenQuota(tokenLimit, tokenUsed)
: activePlanType !== 'free'
? '无有效订阅记录'
: '免费用户,无套餐额度';
const handleTemplateGrant = async (skillName: string) => {
if (!user) return;
setMessage(null);
@@ -273,6 +316,46 @@ export function UserDetailPage() {
)}
</dd>
</div>
<div>
<dt></dt>
<dd>
<select
className="admin-select"
value={user.billingFormula ?? 'A'}
onChange={(event) => {
const billingFormula = event.target.value as 'A' | 'B';
setLocalError(null);
void updateAdminUser(user.id, { billingFormula }).then((nextUser) => {
setUser(nextUser);
setMessage(`已切换到计量公式 ${billingFormula}`);
void reload();
}).catch((err) => {
setLocalError(err instanceof Error ? err.message : '计量公式更新失败');
});
}}
>
<option value="A"> A</option>
<option value="B"> B</option>
</select>
</dd>
</div>
<div>
<dt></dt>
<dd>
{planLabel}
{subscription && (
<span className="muted">
{' '}
· {subscriptionStatusLabel(subscription.status)}
{subscription.expiresAt ? ` · 到期 ${formatTime(subscription.expiresAt)}` : ''}
</span>
)}
</dd>
</div>
<div>
<dt>Token </dt>
<dd>{tokenQuotaSummary}</dd>
</div>
<div>
<dt></dt>
<dd className="mono">{user.workspaceRoot}</dd>
@@ -282,6 +365,45 @@ export function UserDetailPage() {
{user.role === 'user' && (
<>
<section className="admin-card">
<h2> Token </h2>
<dl className="admin-dl">
<div>
<dt></dt>
<dd>
{planLabel}
<span className="muted mono"> ({activePlanType})</span>
</dd>
</div>
<div>
<dt></dt>
<dd>{subscription ? subscriptionStatusLabel(subscription.status) : '无有效订阅'}</dd>
</div>
<div>
<dt></dt>
<dd>{subscription?.expiresAt ? formatTime(subscription.expiresAt) : '—'}</dd>
</div>
<div>
<dt>Token </dt>
<dd>{tokenLimit === 0 ? '无限' : formatTokenCount(tokenLimit)}</dd>
</div>
<div>
<dt>Token </dt>
<dd>{formatTokenCount(tokenUsed)}</dd>
</div>
<div>
<dt>Token </dt>
<dd>{tokenRemaining == null ? '无限' : formatTokenCount(tokenRemaining)}</dd>
</div>
</dl>
<p className="muted">
{' '}
<Link to="/billing/subscriptions"> · </Link>
</p>
</section>
<section className="admin-card">
<h2></h2>
<form className="admin-form" onSubmit={handleRecharge}>
+16 -2
View File
@@ -1,10 +1,11 @@
import { useCallback, useEffect, useState } from 'react';
import { Link } from 'react-router-dom';
import { createAdminUser, listAdminUsers, updateAdminUser } from '../../api/client';
import { createAdminUser, listAdminUsers, listSubscriptionPlans, updateAdminUser } from '../../api/client';
import type { PagedResult } from '../../api/client';
import type { AdminUserRow } from '../../types';
import type { AdminUserRow, PlanDefinition } from '../../types';
import { Pagination } from '../../components/Pagination';
import { formatYuan } from '../utils/format';
import { resolvePlanLabel, summarizeSubscription } from '../utils/subscription';
const PAGE_SIZE = 20;
@@ -25,6 +26,7 @@ export function UsersPage() {
const [pending, setPending] = useState({ search: '', role: '', status: '' });
const [showCreate, setShowCreate] = useState(false);
const [plans, setPlans] = useState<PlanDefinition[]>([]);
const [newUser, setNewUser] = useState({ username: '', password: '', displayName: '', workspaceRoot: '', balanceCents: 500 });
const [creating, setCreating] = useState(false);
@@ -46,6 +48,12 @@ export function UsersPage() {
// eslint-disable-next-line react-hooks/exhaustive-deps
}, []);
useEffect(() => {
void listSubscriptionPlans()
.then(setPlans)
.catch(() => setPlans([]));
}, []);
const handleQuery = () => {
setCommitted(pending);
void load(1, pending);
@@ -171,6 +179,8 @@ export function UsersPage() {
<th></th>
<th></th>
<th></th>
<th></th>
<th>Token </th>
<th></th>
<th></th>
<th></th>
@@ -190,6 +200,10 @@ export function UsersPage() {
{user.status === 'active' ? '正常' : user.status === 'disabled' ? '禁用' : '封禁'}
</span>
</td>
<td>{resolvePlanLabel(user.subscription?.planType ?? user.planType ?? 'free', plans)}</td>
<td className="billing-num">
{summarizeSubscription(user.subscription, user.planType)}
</td>
<td className="billing-num">¥{formatYuan(user.balanceCents)}</td>
<td className="billing-num">
{user.spaceQuotaBytes
+13
View File
@@ -1,4 +1,5 @@
import { useCallback, useEffect, useMemo, useState } from 'react';
import { Link } from 'react-router-dom';
import {
cancelWechatDigest,
clearWechatRoute,
@@ -559,6 +560,18 @@ export function WechatPage() {
</section>
) : null}
<section className="admin-card">
<h2>TKMind </h2>
<p className="muted" style={{ marginTop: 6 }}>
Cursor H5
</p>
<p style={{ marginTop: 12 }}>
<Link to="/cursor-channel" className="ghost-btn">
</Link>
</p>
</section>
<section className="admin-card">
<div className="admin-card-head">
<h2></h2>
+36
View File
@@ -0,0 +1,36 @@
import type { AdminSubscription, PlanDefinition, UserSubscriptionSummary } from '../../types';
export function formatTokenCount(value: number) {
return value.toLocaleString('zh-CN');
}
export function formatTokenQuota(limit: number, used: number) {
if (limit === 0) {
return `已用 ${formatTokenCount(used)} / 无限`;
}
const remaining = Math.max(0, limit - used);
return `${formatTokenCount(remaining)} / ${formatTokenCount(limit)}(已用 ${formatTokenCount(used)}`;
}
export function resolvePlanLabel(planType: string, plans: PlanDefinition[]) {
return plans.find((plan) => plan.planType === planType)?.name ?? planType;
}
export function subscriptionStatusLabel(status: AdminSubscription['status']) {
if (status === 'active') return '有效';
if (status === 'expired') return '已到期';
if (status === 'cancelled') return '已取消';
return status;
}
export function summarizeSubscription(
subscription: Pick<UserSubscriptionSummary, 'planType' | 'periodTokensLimit' | 'periodTokensUsed'> | null | undefined,
fallbackPlanType?: string,
) {
if (!subscription) {
return fallbackPlanType && fallbackPlanType !== 'free'
? `${fallbackPlanType}(无有效订阅)`
: '免费 / 无订阅';
}
return formatTokenQuota(subscription.periodTokensLimit, subscription.periodTokensUsed);
}
+95 -6
View File
@@ -58,6 +58,9 @@ import type {
WechatIntentRouterAdminConfig,
WechatIntentRouterConfigState,
WechatIntentRouterRuntimeState,
WechatCursorExecutorAdminConfig,
WechatCursorExecutorAdminConfigState,
WechatCursorExecutorRuntimeState,
WechatMessage,
WechatWebNotification,
MindSearchConfig,
@@ -474,6 +477,23 @@ export async function getWechatIntentRouterRuntime(): Promise<WechatIntentRouter
return portalFetch<WechatIntentRouterRuntimeState>('/admin-api/wechat/intent-router/runtime');
}
export async function getWechatCursorExecutorConfig(): Promise<WechatCursorExecutorAdminConfigState> {
return portalFetch<WechatCursorExecutorAdminConfigState>('/admin-api/cursor-executor-channel/config');
}
export async function patchWechatCursorExecutorConfig(
patch: Partial<WechatCursorExecutorAdminConfig>,
): Promise<WechatCursorExecutorAdminConfigState> {
return portalFetch<WechatCursorExecutorAdminConfigState>('/admin-api/cursor-executor-channel/config', {
method: 'PATCH',
body: JSON.stringify({ config: patch }),
});
}
export async function getWechatCursorExecutorRuntime(): Promise<WechatCursorExecutorRuntimeState> {
return portalFetch<WechatCursorExecutorRuntimeState>('/admin-api/cursor-executor-channel/runtime');
}
export async function getAssetGatewayConfig(): Promise<AssetGatewayConfig> {
return portalFetch('/admin-api/asset-gateway/config');
}
@@ -549,6 +569,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> {
@@ -803,9 +877,11 @@ export async function listAdminUsers(params?: {
return { items: result.users ?? [], total, page: result.page ?? 1, pageSize, totalPages: Math.ceil(total / pageSize) };
}
export async function getAdminUser(userId: string): Promise<PortalUser> {
const result = await portalFetch<{ user: PortalUser }>(`/admin-api/users/${userId}`);
return result.user;
export async function getAdminUser(userId: string): Promise<{ user: PortalUser; subscription: AdminSubscription | null }> {
const result = await portalFetch<{ user: PortalUser; subscription?: AdminSubscription | null }>(
`/admin-api/users/${userId}`,
);
return { user: result.user, subscription: result.subscription ?? null };
}
export async function createAdminUser(payload: {
@@ -833,6 +909,7 @@ export async function updateAdminUser(
balanceCents: number;
spaceQuotaBytes: number;
role: 'user' | 'admin';
billingFormula: 'A' | 'B';
}>,
): Promise<PortalUser> {
const result = await portalFetch<{ user: PortalUser }>(`/admin-api/users/${userId}`, {
@@ -1355,16 +1432,28 @@ export async function syncSubscriptionPlansToProduction(): Promise<PlanSyncResul
return result.sync;
}
export async function getBillingConfig(): Promise<BillingAdminConfigResponse> {
return portalFetch('/admin-api/billing/config');
export async function getBillingConfig(formula: 'A' | 'B' = 'A'): Promise<BillingAdminConfigResponse> {
const q = formula === 'B' ? '?formula=B' : '';
return portalFetch(`/admin-api/billing/config${q}`);
}
export async function updateBillingConfig(
config: BillingAdminConfig,
formula: 'A' | 'B' = 'A',
): Promise<BillingAdminConfigResponse> {
return portalFetch('/admin-api/billing/config', {
method: 'PUT',
body: JSON.stringify({ config }),
body: JSON.stringify({ config, formula }),
});
}
export async function assignBillingFormula(
userIds: string[],
formula: 'A' | 'B',
): Promise<{ ok: boolean; updated: number; formula: 'A' | 'B'; message?: string }> {
return portalFetch('/admin-api/billing/formula-assignments', {
method: 'POST',
body: JSON.stringify({ userIds, formula }),
});
}
+31
View File
@@ -879,6 +879,37 @@ body,
padding-top: 4px;
}
.cursor-allowlist-chips {
display: flex;
flex-wrap: wrap;
gap: 8px;
}
.cursor-allowlist-chip {
display: inline-flex;
align-items: center;
gap: 8px;
padding: 6px 10px;
border: 1px solid var(--color-border-input, #e7dfd1);
border-radius: 999px;
background: var(--color-bg-subtle, rgba(0, 0, 0, 0.02));
font-size: 13px;
}
.cursor-allowlist-chip-remove {
border: 0;
background: transparent;
color: var(--color-text-muted, #68716c);
cursor: pointer;
font-size: 16px;
line-height: 1;
padding: 0 2px;
}
.cursor-allowlist-chip-remove:hover {
color: var(--color-text, #1f2421);
}
.global-model-card {
margin-bottom: 16px;
padding: 14px;
+71
View File
@@ -18,6 +18,7 @@ export type PortalUser = {
balanceCents: number;
totalCreditCents?: number;
tokensUsed: number;
billingFormula?: 'A' | 'B';
spaceQuotaBytes?: number;
spaceUsedBytes?: number;
spaceReservedBytes?: number;
@@ -43,7 +44,17 @@ export type AuthStatus = {
unrestricted?: boolean;
};
export type UserSubscriptionSummary = {
planType: string;
status: 'active' | 'expired' | 'cancelled';
periodTokensLimit: number;
periodTokensUsed: number;
expiresAt: number;
};
export type AdminUserRow = PortalUser & {
billingFormula?: 'A' | 'B';
subscription?: UserSubscriptionSummary | null;
createdAt: number;
updatedAt: number;
};
@@ -344,6 +355,53 @@ export type WechatIntentRouterRuntimeState = {
config: WechatIntentRouterAdminConfig;
};
export type CursorChannelFeatureKey =
| 'pageGenerate'
| 'pageData'
| 'excelAnalysis'
| 'chatBridge'
| 'scheduledTasks';
export type CursorChannelFeatures = Record<
CursorChannelFeatureKey,
{ enabled: boolean }
>;
export type WechatCursorExecutorAdminConfig = {
enabled: boolean;
userAllowlist: string[];
channelAllowlist: string[];
intentAllowlist: string[];
features?: CursorChannelFeatures;
fallbackToDeepseek: boolean;
meta?: {
notes?: string;
};
};
export type WechatCursorExecutorAdminConfigState = {
config: WechatCursorExecutorAdminConfig;
updatedAt?: number | null;
updatedBy?: string | null;
source?: string;
};
export type WechatCursorExecutorRuntimeState = {
source: string;
updatedAt: number | null;
updatedBy: string | null;
config: WechatCursorExecutorAdminConfig;
policy: {
enabled: boolean;
userAllowlist: string[];
channelAllowlist: string[];
intentAllowlist: string[];
features?: CursorChannelFeatures;
fallbackToDeepseek: boolean;
source?: string;
};
};
export type MindSpaceSeoGeoConfig = {
enabled: boolean;
seo: {
@@ -676,8 +734,20 @@ export type BillingAdminConfig = {
costEstimateOutputUsdPer1M: number;
};
export type BillingFormulaKey = 'A' | 'B';
export type BillingFormulaMeta = {
updatedAt: number | null;
updatedBy: string | null;
source?: string;
};
export type BillingAdminConfigResponse = {
config: BillingAdminConfig;
formulas?: Record<BillingFormulaKey, BillingAdminConfig>;
formulaMeta?: Record<BillingFormulaKey, BillingFormulaMeta>;
activeFormula?: BillingFormulaKey;
defaultFormula?: BillingFormulaKey;
updatedAt: number | null;
updatedBy: string | null;
source?: string;
@@ -722,6 +792,7 @@ export type AdminSubscription = {
periodImagesLimit: number;
periodImagesUsed: number;
periodImagesBonus?: number;
autoRenew?: boolean;
note: string | null;
createdAt: 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,