Compare commits
9 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 80c2091baf | |||
| f9f8b541dc | |||
| f6bb02254e | |||
| a503fa9cbe | |||
| 66dd288015 | |||
| c79e1b7a35 | |||
| 0da7f04878 | |||
| 6a7d6cec70 | |||
| a67d5a137a |
+4
-4
@@ -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
|
||||
|
||||
@@ -22,7 +22,7 @@ bash scripts/check-release-ready.sh
|
||||
|
||||
## 必读:本仓库是唯一合法的管理后台 UI(5174)
|
||||
|
||||
**所有平台管理后台的前端功能只能在本仓库(memind_adm)开发,本地端口 5174,生产 gadm。禁止在 Memind 仓库的 `ops/`(约 3002)新增任何管理页面、导航或 API 客户端。**
|
||||
**所有平台管理后台的前端功能只能在本仓库(memind_adm)开发,本地端口 5174,生产 [md.tkmind.cn](https://md.tkmind.cn)。禁止在 Memind 仓库的 `ops/`(约 3002)新增任何管理页面、导航或 API 客户端。**
|
||||
|
||||
| 组件 | 端口 | 职责 |
|
||||
|------|------|------|
|
||||
@@ -32,3 +32,11 @@ bash scripts/check-release-ready.sh
|
||||
| Memind 后端 | 8081 / 8082 | 业务逻辑与 Portal;UI 不在此仓库 |
|
||||
|
||||
新增管理功能时:在本仓库添加 `src/admin/pages/*`、更新 `AdminNav.tsx` 与 `App.tsx`;若需新 API,在 `server/app.mjs` 挂载并复用 Memind 模块。Memind 侧仅实现共享业务,不在 `ops/` 做 UI。
|
||||
|
||||
## Analytics / SEO·GEO 看板
|
||||
|
||||
- 页面:`src/admin/pages/AnalyticsConfigPage.tsx`、`SeoGeoAnalyticsPage.tsx`
|
||||
- API:`server/umami-analytics.mjs` → 105 Umami `memind-pages`
|
||||
- 部署与环境变量:[docs/analytics-deploy.md](docs/analytics-deploy.md)
|
||||
- 跨仓库发布顺序:Memind `docs/analytics-release-runbook.md`(105 Umami → 103 Portal → md.tkmind.cn)
|
||||
- Rybbit 已退役,勿恢复相关配置
|
||||
|
||||
+44
-8
@@ -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.cn(105 nginx)
|
||||
```
|
||||
|
||||
105 活动 upstream 当前为 `58.38.22.103`(103 公网 IP);内网 `10.10.0.2` 可达时可改用 LAN 地址。
|
||||
nginx 示例:`scripts/md-nginx-105.conf`(与 105 线上一致)。
|
||||
|
||||
## 硬规则
|
||||
|
||||
`memind_adm` 允许项目独立,**不允许用户体系、权限体系、技能体系、策略体系独立**。
|
||||
@@ -57,19 +74,20 @@
|
||||
`MEMIND_LIB_ROOT=/Users/john/Project/Memind`
|
||||
`H5_USERS_ROOT=/Users/john/Project/Memind/users`
|
||||
|
||||
## gadm 独立登录
|
||||
## md.tkmind.cn 独立登录
|
||||
|
||||
`https://gadm.tkmind.cn` 应部署 **本仓库 memind_adm**,勿再反代到 `Memind/ops` Vite dev(旧版未登录会跳 `localhost:5173`)。
|
||||
`https://md.tkmind.cn` 应部署 **本仓库 memind_adm**,勿再反代到 `Memind/ops` Vite dev(旧版未登录会跳 `localhost:5173`)。
|
||||
|
||||
| 项 | 说明 |
|
||||
|---|---|
|
||||
| 登录 | 本页 `/auth/login`,账号来自共用 MySQL |
|
||||
| 会话 | 设置 `H5_PUBLIC_BASE_URL=https://gadm.tkmind.cn`,**不要**设 `H5_COOKIE_DOMAIN=.tkmind.cn` |
|
||||
| 路径 | `https://gadm.tkmind.cn/` 直达超管后台;Plaza 运营在 `/ops`;`/ops/admin` 已废弃 |
|
||||
| 会话 | 设置 `H5_PUBLIC_BASE_URL=https://md.tkmind.cn`,**不要**设 `H5_COOKIE_DOMAIN=.tkmind.cn` |
|
||||
| 路径 | 公网 `https://md.tkmind.cn/` 直达超管后台;Analytics / SEO·GEO 在 `/analytics`、`/analytics/seo-geo`;Plaza 运营仍在 `/ops` |
|
||||
| API | `ADM_DEV_BACKEND=http://127.0.0.1:8085`(preview 反代目标) |
|
||||
| 初始化 admin | `npm run admin:init` 只更新 `Memind` 主用户表中的 admin 密码,不创建独立后台账号 |
|
||||
|
||||
nginx 示例见 `scripts/gadm-nginx.conf.example`(`/ops/` → preview,`/auth` `/admin-api` `/api` → Admin API `8085`)。
|
||||
nginx 示例见 `scripts/md-nginx.conf.example`(`/` → preview,`/auth` `/admin-api` `/api` → Admin API `8085`)。
|
||||
105 公网入口见 `scripts/md-nginx-105.conf`。
|
||||
|
||||
## 当前规则
|
||||
|
||||
@@ -187,8 +205,8 @@ ssh john@10.10.0.2 'tail -f /Users/john/Project/memind_adm/adm-preview.log'
|
||||
# preview 反代目标(/auth、/admin-api、/api)— 指向本仓库 Admin API
|
||||
ADM_API_PORT=8085
|
||||
ADM_DEV_BACKEND=http://127.0.0.1:8085
|
||||
H5_PUBLIC_BASE_URL=https://gadm.tkmind.cn
|
||||
VITE_BASE_PATH=/ops
|
||||
H5_PUBLIC_BASE_URL=https://md.tkmind.cn
|
||||
VITE_BASE_PATH=/
|
||||
|
||||
# 共享 Memind 主库与主实现
|
||||
DATABASE_URL=mysql://<user>:<password>@<host>:3306/<db>
|
||||
@@ -196,7 +214,7 @@ MEMIND_LIB_ROOT=/Users/john/Project/Memind
|
||||
H5_USERS_ROOT=/Users/john/Project/Memind/users
|
||||
|
||||
# 套餐自动同步到生产后台(可选)
|
||||
PLAN_SYNC_TARGET_BASE_URL=https://gadm.tkmind.cn
|
||||
PLAN_SYNC_TARGET_BASE_URL=https://md.tkmind.cn
|
||||
PLAN_SYNC_USERNAME=admin
|
||||
PLAN_SYNC_PASSWORD=<admin-password>
|
||||
PLAN_SYNC_TIMEOUT_MS=10000
|
||||
@@ -261,6 +279,24 @@ PY'
|
||||
| `scripts/remote_restart.sh` | 仅在远端重启 `vite preview` |
|
||||
| `scripts/.releaseignore-prod` | 生产发布包排除规则 |
|
||||
|
||||
## Analytics / SEO·GEO(Umami)
|
||||
|
||||
管理后台 **Analytics 配置** 与 **SEO / GEO 流量** 看板依赖 105 Umami。
|
||||
|
||||
| 文档 | 内容 |
|
||||
|------|------|
|
||||
| [docs/analytics-deploy.md](analytics-deploy.md) | md.tkmind.cn Analytics / SEO·GEO 配置 |
|
||||
| Memind [docs/analytics-release-runbook.md](../Memind/docs/analytics-release-runbook.md) | 跨仓库发布顺序(105 → 103 Portal → md.tkmind.cn) |
|
||||
|
||||
生产 `.env` 至少配置:
|
||||
|
||||
```env
|
||||
MEMIND_UMAMI_SSO_SECRET=<与 105 Umami .env 一致>
|
||||
UMAMI_SSO_USERNAME=admin
|
||||
```
|
||||
|
||||
可选:`UMAMI_URL`、`UMAMI_WEBSITE_ID`、`UMAMI_SSO_USERNAME`。
|
||||
|
||||
## 规则文档
|
||||
|
||||
- `ENGINEERING_WORKFLOW_RULES.md`
|
||||
|
||||
@@ -0,0 +1,121 @@
|
||||
# Analytics / SEO·GEO 部署说明
|
||||
|
||||
管理后台 **Analytics 配置** 与 **SEO / GEO 流量** 看板的生产配置与发布。
|
||||
|
||||
平台边界与字段口径见 Memind [docs/analytics-platform.md](https://git.tkmind.cn/tkmind/Memind/src/branch/main/docs/analytics-platform.md)。
|
||||
跨仓库发布顺序见 Memind [docs/analytics-release-runbook.md](https://git.tkmind.cn/tkmind/Memind/src/branch/main/docs/analytics-release-runbook.md)。
|
||||
|
||||
## 功能入口(md.tkmind.cn · 103 进程 · 105 反代)
|
||||
|
||||
生产 URL:**https://md.tkmind.cn/analytics/seo-geo**
|
||||
|
||||
| 路由 | 页面 | 说明 |
|
||||
|------|------|------|
|
||||
| `/analytics` | Analytics 配置 | Umami Website ID、域名、SSO 进入 Umami |
|
||||
| `/analytics/seo-geo` | SEO / GEO 流量 | 按页展示搜索引擎 / 生成式引擎来源访问 |
|
||||
|
||||
**Rybbit 已退役** — 相关 SSO 接口返回 410;勿再配置 `MEMIND_RYBBIT_*`。
|
||||
|
||||
## 依赖关系
|
||||
|
||||
```text
|
||||
SeoGeoAnalyticsPage
|
||||
-> GET /admin-api/analytics/seo-geo-pages
|
||||
-> server/umami-analytics.mjs
|
||||
-> 105 Umami API /api/websites/:id/memind-pages?discoveryChannel=seo|geo
|
||||
(memind-analytics overlay)
|
||||
|
||||
Analytics 配置 / Umami SSO
|
||||
-> GET /admin-api/analytics/sso
|
||||
-> Memind 共享 SSO 逻辑 + MEMIND_UMAMI_SSO_SECRET
|
||||
```
|
||||
|
||||
adm **不**直连浏览器到 Umami 写库;读统计通过服务端用 Umami 登录态拉 API。
|
||||
|
||||
## 生产环境变量(103 `.env`)
|
||||
|
||||
在 `/Users/john/Project/memind_adm/.env` 增加或确认:
|
||||
|
||||
```env
|
||||
# 管理后台公网域名(会话 cookie 绑定 md 域名)
|
||||
H5_PUBLIC_BASE_URL=https://md.tkmind.cn
|
||||
|
||||
# SEO/GEO 看板 + Umami SSO(必填,与 105 Umami .env 一致)
|
||||
MEMIND_UMAMI_SSO_SECRET=<与 /opt/memind-analytics/.env 一致>
|
||||
UMAMI_SSO_USERNAME=admin
|
||||
|
||||
# 可选:覆盖默认
|
||||
UMAMI_URL=https://fxi.tkmind.cn
|
||||
UMAMI_WEBSITE_ID=<Memind 公共页面 Website ID>
|
||||
```
|
||||
|
||||
说明:
|
||||
|
||||
- 105 生产 Umami 启用 `MEMIND_UMAMI_SSO_ONLY=1` 时,须配置 `MEMIND_UMAMI_SSO_SECRET`;勿依赖 `UMAMI_ADMIN_PASSWORD`
|
||||
- `websiteId` 也可在 **Analytics 配置** 页面保存(写入 MindSpace admin 配置),服务端优先读库内配置
|
||||
- 103 必须能访问 `UMAMI_URL`(内网或公网,依网络拓扑)
|
||||
|
||||
## 发布
|
||||
|
||||
仅改 adm 前端或 `server/umami-analytics.mjs` 时:
|
||||
|
||||
```bash
|
||||
cd /Users/john/Project/memind_adm
|
||||
bash scripts/release-prod.sh --yes
|
||||
```
|
||||
|
||||
若改了 Umami `memind-pages` API 或 overlay,需 **先** 发 memind-analytics(105),再发 adm。
|
||||
|
||||
若改了 Memind discovery 埋点,需 **先** 发 Memind Portal(103),再有 SEO/GEO 分列数据。
|
||||
|
||||
完整顺序见 Memind `docs/analytics-release-runbook.md`。
|
||||
|
||||
## 发布后验收
|
||||
|
||||
```bash
|
||||
# Admin API 健康
|
||||
ssh john@10.10.0.2 'curl -sf http://127.0.0.1:8085/health'
|
||||
|
||||
# SEO/GEO API(需替换 cookie 或在内网浏览器登录后测)
|
||||
# 期望 200 + pages 数组,而非 500 / 未配置 MEMIND_UMAMI_SSO_SECRET
|
||||
```
|
||||
|
||||
浏览器:
|
||||
|
||||
1. `https://md.tkmind.cn/analytics` → 「打开 Umami 分析后台」跳转成功
|
||||
2. `https://md.tkmind.cn/analytics/seo-geo` → 表格加载;SEO / GEO Tab 切换正常
|
||||
3. 列口径与 Umami「所有生成页面明细 / public page」一致(页面、PV、UV、点击/表单/参与率等)
|
||||
|
||||
## 本地开发
|
||||
|
||||
```bash
|
||||
pnpm dev # 5174
|
||||
```
|
||||
|
||||
本地 `.env` 可指向本机或 105 Umami:
|
||||
|
||||
```env
|
||||
UMAMI_URL=http://127.0.0.1:3100
|
||||
UMAMI_WEBSITE_ID=<local website id>
|
||||
MEMIND_UMAMI_SSO_SECRET=<local secret>
|
||||
```
|
||||
|
||||
Memind 本地 analytics 变量见 [local-analytics.md](https://git.tkmind.cn/tkmind/Memind/src/branch/main/docs/local-analytics.md)。
|
||||
|
||||
## 故障排查
|
||||
|
||||
| 现象 | 检查 |
|
||||
|------|------|
|
||||
| SEO/GEO 看板 500 | 103 `adm-api.log`;`MEMIND_UMAMI_SSO_SECRET`;105 Umami 是否 active |
|
||||
| Umami SSO 失败 | `MEMIND_UMAMI_SSO_SECRET` 与 105 `.env` 是否一致 |
|
||||
| 看板无 SEO/GEO 行 | Memind discovery 是否已发布;Umami 是否有带 `discovery_channel` 的事件 |
|
||||
| Rybbit 链接/报错 | 清理旧书签;代码已移除 Rybbit SSO |
|
||||
|
||||
## 相关代码
|
||||
|
||||
| 路径 | 说明 |
|
||||
|------|------|
|
||||
| `src/admin/pages/SeoGeoAnalyticsPage.tsx` | SEO/GEO 看板 UI |
|
||||
| `src/admin/pages/AnalyticsConfigPage.tsx` | Analytics 配置 |
|
||||
| `server/umami-analytics.mjs` | Umami memind-pages 代理 |
|
||||
| `server/app.mjs` | `/admin-api/analytics/*` 路由 |
|
||||
@@ -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. Memind(103 Portal 8081)— schema + 扣费/续费运行时
|
||||
2. memind_adm(5174 / 8085)— 管理后台 UI 与 API
|
||||
|
||||
发布前分别执行各仓库 `bash scripts/check-release-ready.sh`。
|
||||
@@ -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
|
||||
|
||||
@@ -1,11 +0,0 @@
|
||||
# gadm.tkmind.cn → memind_adm(vite 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
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,12 @@
|
||||
# md.tkmind.cn → memind_adm(vite 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.2(fallback 58.38.22.103)
|
||||
# memind_adm: preview :5174, Admin API :8085(根路径部署,/ 直达后台)
|
||||
# md.tkmind.cn @ 105 nginx → Studio 103(58.38.22.103)
|
||||
# memind_adm 进程在 103;105 仅做 HTTPS 入口与反代,不跑 adm 应用。
|
||||
# preview :5174 / Admin API :8085
|
||||
#
|
||||
# 内网可达时 upstream 可改为 10.10.0.2;105 当前活动配置使用公网 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、/api;nginx 需单独反代到 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;
|
||||
@@ -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 "本地构建 dist(103 不再执行前端构建)"
|
||||
(
|
||||
cd "${ROOT}"
|
||||
VITE_BASE_PATH="${VITE_BASE_PATH:-/ops}" npm run build
|
||||
VITE_BASE_PATH="${VITE_BASE_PATH:-/}" npm run build
|
||||
)
|
||||
fi
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
Executable
+8
@@ -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
|
||||
Executable
+18
@@ -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
|
||||
@@ -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,
|
||||
|
||||
+45
-5
@@ -6,6 +6,7 @@ import { fileURLToPath } from 'node:url';
|
||||
import express from 'express';
|
||||
import { listUsagePaged, listLedgerPaged, getUsageStats, getUsageSummary } from './pagination.mjs';
|
||||
import { fetchMemindDiscoveryPages } from './umami-analytics.mjs';
|
||||
import { importMemind } from './lib-path.mjs';
|
||||
|
||||
const projectRoot = path.join(path.dirname(fileURLToPath(import.meta.url)), '..');
|
||||
|
||||
@@ -252,6 +253,29 @@ export function createAdminApp(services) {
|
||||
}
|
||||
});
|
||||
|
||||
adminApi.get('/analytics/seo-geo-catalog', requireAdmin, async (req, res) => {
|
||||
try {
|
||||
const { listAdminSeoGeoPublicationCatalog } = await importMemind('mindspace-seo-geo-admin-catalog.mjs');
|
||||
const startAt = Number(req.query.startAt);
|
||||
const endAt = Number(req.query.endAt);
|
||||
const result = await listAdminSeoGeoPublicationCatalog(pool, {
|
||||
page: Number(req.query.page) || 1,
|
||||
pageSize: Number(req.query.pageSize) || 20,
|
||||
sortBy: typeof req.query.sortBy === 'string' ? req.query.sortBy : 'publishedAt',
|
||||
sortOrder: req.query.sortOrder === 'asc' ? 'asc' : 'desc',
|
||||
startAt: Number.isFinite(startAt) ? startAt : null,
|
||||
endAt: Number.isFinite(endAt) ? endAt : null,
|
||||
search: typeof req.query.search === 'string' ? req.query.search.trim() : '',
|
||||
publicHost: process.env.H5_PUBLIC_BASE_URL?.replace(/^https?:\/\//, '').replace(/\/$/, '') || 'm.tkmind.cn',
|
||||
});
|
||||
res.json(result);
|
||||
} catch (error) {
|
||||
res.status(503).json({
|
||||
message: error instanceof Error ? error.message : '无法加载 SEO/GEO 页面目录',
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
adminApi.get('/analytics/rybbit-sso', requireAdmin, async (_req, res) => {
|
||||
return res.status(410).json({ message: 'Rybbit 已退役,请使用 Umami 与 SEO/GEO 流量看板' });
|
||||
});
|
||||
@@ -636,11 +660,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 +673,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 +689,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) => {
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -165,6 +165,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,
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
import crypto from 'node:crypto';
|
||||
|
||||
const TOKEN_TTL_MS = 55 * 60 * 1000;
|
||||
|
||||
let cachedToken = '';
|
||||
@@ -14,13 +16,40 @@ function resolveUmamiCredentials(env = process.env, analyticsConfig = {}) {
|
||||
);
|
||||
const username = String(env.UMAMI_SSO_USERNAME || env.UMAMI_ADMIN_USERNAME || 'admin').trim();
|
||||
const password = String(env.UMAMI_ADMIN_PASSWORD || '').trim();
|
||||
const sharedSecret = String(env.MEMIND_UMAMI_SSO_SECRET || '').trim();
|
||||
const websiteId = String(analyticsConfig?.websiteId || env.UMAMI_WEBSITE_ID || '').trim();
|
||||
return { baseUrl, username, password, websiteId };
|
||||
return { baseUrl, username, password, sharedSecret, websiteId };
|
||||
}
|
||||
|
||||
async function loginUmami({ baseUrl, username, password }) {
|
||||
async function loginUmamiViaSso({ baseUrl, username, sharedSecret }) {
|
||||
const encoded = Buffer.from(
|
||||
JSON.stringify({
|
||||
username,
|
||||
exp: Math.floor(Date.now() / 1000) + 60,
|
||||
nonce: crypto.randomUUID(),
|
||||
}),
|
||||
).toString('base64url');
|
||||
const signature = crypto.createHmac('sha256', sharedSecret).update(encoded).digest('base64url');
|
||||
const ticket = `${encoded}.${signature}`;
|
||||
const response = await fetch(
|
||||
`${baseUrl}/api/auth/memind?ticket=${encodeURIComponent(ticket)}`,
|
||||
{ signal: AbortSignal.timeout(10_000) },
|
||||
);
|
||||
const body = await response.json().catch(() => ({}));
|
||||
if (!response.ok) {
|
||||
throw new Error(body?.error?.message || body?.message || `Umami SSO 登录失败 (${response.status})`);
|
||||
}
|
||||
const token = String(body?.token ?? '').trim();
|
||||
if (!token) throw new Error('Umami SSO 登录响应缺少 token');
|
||||
return token;
|
||||
}
|
||||
|
||||
async function loginUmami({ baseUrl, username, password, sharedSecret }) {
|
||||
if (sharedSecret) {
|
||||
return loginUmamiViaSso({ baseUrl, username, sharedSecret });
|
||||
}
|
||||
if (!password) {
|
||||
throw new Error('未配置 UMAMI_ADMIN_PASSWORD,无法拉取 SEO/GEO 页面统计');
|
||||
throw new Error('未配置 UMAMI_ADMIN_PASSWORD 或 MEMIND_UMAMI_SSO_SECRET,无法拉取 SEO/GEO 页面统计');
|
||||
}
|
||||
const response = await fetch(`${baseUrl}/api/auth/login`, {
|
||||
method: 'POST',
|
||||
|
||||
@@ -23,12 +23,12 @@ test('fetchMemindDiscoveryPages rejects missing website id', async () => {
|
||||
);
|
||||
});
|
||||
|
||||
test('fetchMemindDiscoveryPages rejects missing admin password', async () => {
|
||||
test('fetchMemindDiscoveryPages rejects missing auth credentials', async () => {
|
||||
await assert.rejects(
|
||||
() => fetchMemindDiscoveryPages(
|
||||
{ discoveryChannel: 'geo', startAt: '1', endAt: '2' },
|
||||
{ env: {}, analyticsConfig: { websiteId: 'site-1' } },
|
||||
),
|
||||
/未配置 UMAMI_ADMIN_PASSWORD/,
|
||||
/未配置 UMAMI_ADMIN_PASSWORD 或 MEMIND_UMAMI_SSO_SECRET/,
|
||||
);
|
||||
});
|
||||
|
||||
@@ -83,7 +83,7 @@ export function AnalyticsConfigPage() {
|
||||
<label className="admin-form-row"><span>Umami 地址</span><input value={config.analyticsUrl} disabled={loading || busy} onChange={(e) => setConfig({ ...config, analyticsUrl: e.target.value })} placeholder="http://127.0.0.1:3100" /></label>
|
||||
<label className="admin-form-row"><span>统计域名</span><input value={config.domains} disabled={loading || busy} onChange={(e) => setConfig({ ...config, domains: e.target.value })} placeholder="127.0.0.1,localhost" /></label>
|
||||
<label className="admin-form-row"><span>匿名化密钥 {config.idSecretConfigured ? '(已配置,留空保持不变)' : ''}</span><input type="password" value={secret} disabled={loading || busy} onChange={(e) => setSecret(e.target.value)} placeholder={config.idSecretConfigured ? '留空保持当前密钥' : '输入本地随机密钥'} autoComplete="new-password" /></label>
|
||||
<p className="muted">SEO/GEO 看板还需在 memind_adm 环境变量配置 <code>UMAMI_ADMIN_PASSWORD</code>(与 Umami admin 账号匹配)。</p>
|
||||
<p className="muted">SEO/GEO 看板通过 <code>MEMIND_UMAMI_SSO_SECRET</code>(推荐,与 105 Umami 一致)或 <code>UMAMI_ADMIN_PASSWORD</code> 访问 Umami API;生产 Umami 若开启 SSO-only 则只需前者。</p>
|
||||
<div className="admin-actions"><button type="submit" className="send-btn" disabled={busy || loading}>{busy ? '保存中...' : '保存配置'}</button><button type="button" className="ghost-btn" onClick={() => void load()} disabled={busy}>重新加载</button></div>
|
||||
</form>
|
||||
</section>
|
||||
|
||||
+384
-162
@@ -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,303 @@ 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>汇率(USD→CNY)</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}
|
||||
</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 +484,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 +512,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('当前环境已锁定为仅读 env(H5_BILLING_CONFIG_SOURCE=env),无法保存。');
|
||||
return;
|
||||
@@ -228,7 +526,7 @@ function FormulaTab() {
|
||||
if (
|
||||
draft.useBackendCost
|
||||
&& !window.confirm(
|
||||
`确认保存成本模式扣费?\n最终扣费 = 上游成本(USD) × ${draft.usdCnyRate} × ${draft.marginMultiplier}`,
|
||||
`确认保存公式 ${activeFormula} 成本模式扣费?\n最终扣费 = 上游成本(USD) × ${draft.usdCnyRate} × ${draft.marginMultiplier}`,
|
||||
)
|
||||
) {
|
||||
return;
|
||||
@@ -237,13 +535,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 +561,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>汇率(USD→CNY)</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 +1755,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 +1853,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 +1874,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 && (
|
||||
|
||||
@@ -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=public、status=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>
|
||||
|
||||
@@ -1,49 +1,21 @@
|
||||
import { useCallback, useEffect, useMemo, useState } from 'react';
|
||||
import { getSeoGeoAnalyticsPages, getUmamiSsoUrl } from '../../api/client';
|
||||
|
||||
type DiscoveryChannel = 'seo' | 'geo';
|
||||
|
||||
type PageRow = {
|
||||
urlPath: string;
|
||||
pageUrl?: string;
|
||||
hostname?: string;
|
||||
pageTitle: string;
|
||||
views: number;
|
||||
visitors: number;
|
||||
visits: number;
|
||||
clicks: number;
|
||||
engagedVisits: number;
|
||||
forms: number;
|
||||
generatedAt?: string | null;
|
||||
generatedAtInferred?: boolean;
|
||||
firstSeenAt?: string | null;
|
||||
};
|
||||
import { getSeoGeoPublicationCatalog, getUmamiSsoUrl } from '../../api/client';
|
||||
import type { SeoGeoCatalogRow } from '../../api/client';
|
||||
|
||||
type PageSort =
|
||||
| 'generatedAt'
|
||||
| 'views'
|
||||
| 'visitors'
|
||||
| 'visits'
|
||||
| 'clicks'
|
||||
| 'engagedVisits'
|
||||
| 'forms'
|
||||
| 'publishedAt'
|
||||
| 'pageTitle'
|
||||
| 'firstSeenAt';
|
||||
| 'ownerLabel'
|
||||
| 'totalViews'
|
||||
| 'seoViews'
|
||||
| 'geoViews'
|
||||
| 'crawlerViews'
|
||||
| 'lifetimeViewCount';
|
||||
|
||||
function formatNumber(value: number) {
|
||||
return new Intl.NumberFormat('zh-CN').format(Number(value) || 0);
|
||||
}
|
||||
|
||||
function resolvePageUrl(row: PageRow) {
|
||||
const exact = String(row.pageUrl ?? '').trim();
|
||||
if (/^https?:\/\//i.test(exact)) return exact;
|
||||
const hostname = String(row.hostname ?? '').trim();
|
||||
if (hostname === '127.0.0.1' || hostname === 'localhost') {
|
||||
return `http://${hostname}:8081${row.urlPath}`;
|
||||
}
|
||||
return hostname ? `https://${hostname}${row.urlPath}` : row.urlPath;
|
||||
}
|
||||
|
||||
function defaultRange() {
|
||||
const end = new Date();
|
||||
const start = new Date(end);
|
||||
@@ -60,67 +32,76 @@ function toRangeMs(startDate: string, endDate: string) {
|
||||
return { startAt, endAt };
|
||||
}
|
||||
|
||||
function accessModeLabel(accessMode: string) {
|
||||
switch (accessMode) {
|
||||
case 'public':
|
||||
return '公开';
|
||||
case 'password':
|
||||
return '密码';
|
||||
case 'login_required':
|
||||
return '登录可见';
|
||||
case 'owner_only':
|
||||
return '仅本人';
|
||||
case 'private_link':
|
||||
return '私密链接';
|
||||
default:
|
||||
return accessMode || '—';
|
||||
}
|
||||
}
|
||||
|
||||
export function SeoGeoAnalyticsPage() {
|
||||
const initialRange = useMemo(() => defaultRange(), []);
|
||||
const [channel, setChannel] = useState<DiscoveryChannel>('seo');
|
||||
const [startDate, setStartDate] = useState(initialRange.startDate);
|
||||
const [endDate, setEndDate] = useState(initialRange.endDate);
|
||||
const [generatedStartDate, setGeneratedStartDate] = useState('');
|
||||
const [generatedEndDate, setGeneratedEndDate] = useState('');
|
||||
const [sortBy, setSortBy] = useState<PageSort>('views');
|
||||
const [search, setSearch] = useState('');
|
||||
const [sortBy, setSortBy] = useState<PageSort>('totalViews');
|
||||
const [sortOrder, setSortOrder] = useState<'asc' | 'desc'>('desc');
|
||||
const [page, setPage] = useState(1);
|
||||
const [rows, setRows] = useState<PageRow[]>([]);
|
||||
const [rows, setRows] = useState<SeoGeoCatalogRow[]>([]);
|
||||
const [total, setTotal] = useState(0);
|
||||
const [totalViews, setTotalViews] = useState(0);
|
||||
const [totals, setTotals] = useState({
|
||||
totalViews: 0,
|
||||
seoViews: 0,
|
||||
geoViews: 0,
|
||||
crawlerViews: 0,
|
||||
});
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [openingUmami, setOpeningUmami] = useState(false);
|
||||
|
||||
const pageCount = Math.max(1, Math.ceil(total / 10));
|
||||
const pageCount = Math.max(1, Math.ceil(total / 20));
|
||||
|
||||
const load = useCallback(async () => {
|
||||
setLoading(true);
|
||||
setError(null);
|
||||
try {
|
||||
const { startAt, endAt } = toRangeMs(startDate, endDate);
|
||||
const result = await getSeoGeoAnalyticsPages({
|
||||
discoveryChannel: channel,
|
||||
const result = await getSeoGeoPublicationCatalog({
|
||||
startAt,
|
||||
endAt,
|
||||
timezone: 'Asia/Shanghai',
|
||||
page,
|
||||
pageSize: 10,
|
||||
pageSize: 20,
|
||||
sortBy,
|
||||
sortOrder,
|
||||
generatedStartAt: generatedStartDate
|
||||
? new Date(`${generatedStartDate}T00:00:00`).getTime()
|
||||
: undefined,
|
||||
generatedEndAt: generatedEndDate
|
||||
? new Date(`${generatedEndDate}T23:59:59.999`).getTime()
|
||||
: undefined,
|
||||
search: search.trim() || undefined,
|
||||
});
|
||||
setRows(Array.isArray(result.data) ? result.data : []);
|
||||
setTotal(Number(result.total) || 0);
|
||||
setTotalViews(Number(result.totalViews) || 0);
|
||||
setTotals(result.totals ?? {
|
||||
totalViews: 0,
|
||||
seoViews: 0,
|
||||
geoViews: 0,
|
||||
crawlerViews: 0,
|
||||
});
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : '加载 SEO/GEO 页面统计失败');
|
||||
setError(err instanceof Error ? err.message : '加载 SEO/GEO 页面目录失败');
|
||||
setRows([]);
|
||||
setTotal(0);
|
||||
setTotalViews(0);
|
||||
setTotals({ totalViews: 0, seoViews: 0, geoViews: 0, crawlerViews: 0 });
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}, [
|
||||
channel,
|
||||
endDate,
|
||||
generatedEndDate,
|
||||
generatedStartDate,
|
||||
page,
|
||||
sortBy,
|
||||
sortOrder,
|
||||
startDate,
|
||||
]);
|
||||
}, [endDate, page, search, sortBy, sortOrder, startDate]);
|
||||
|
||||
useEffect(() => {
|
||||
void load();
|
||||
@@ -128,7 +109,7 @@ export function SeoGeoAnalyticsPage() {
|
||||
|
||||
useEffect(() => {
|
||||
setPage(1);
|
||||
}, [channel, startDate, endDate, generatedStartDate, generatedEndDate, sortBy, sortOrder]);
|
||||
}, [startDate, endDate, search, sortBy, sortOrder]);
|
||||
|
||||
const openUmami = async () => {
|
||||
setOpeningUmami(true);
|
||||
@@ -141,22 +122,12 @@ export function SeoGeoAnalyticsPage() {
|
||||
}
|
||||
};
|
||||
|
||||
const overview = useMemo(() => {
|
||||
const pages = rows.length;
|
||||
const views = rows.reduce((sum, row) => sum + Number(row.views || 0), 0);
|
||||
const visitors = rows.reduce((sum, row) => sum + Number(row.visitors || 0), 0);
|
||||
const clicks = rows.reduce((sum, row) => sum + Number(row.clicks || 0), 0);
|
||||
const engaged = rows.reduce((sum, row) => sum + Number(row.engagedVisits || 0), 0);
|
||||
const forms = rows.reduce((sum, row) => sum + Number(row.forms || 0), 0);
|
||||
return { pages, views, visitors, clicks, engaged, forms };
|
||||
}, [rows]);
|
||||
|
||||
return (
|
||||
<div className="admin-page">
|
||||
<div className="admin-page-head">
|
||||
<h2>SEO / GEO 流量</h2>
|
||||
<p className="muted">
|
||||
按页面查看来自搜索引擎(SEO)与生成式引擎(GEO)的真实用户访问,表格口径与 Umami「所有生成页面明细」一致。
|
||||
列出全部在线页(含零流量页面),仅展示统计与 URL,不提供跳转访问。可收录 = 公开且未过期。SEO / GEO 来源按 referrer 域名归类;爬虫按 User-Agent 识别。
|
||||
</p>
|
||||
</div>
|
||||
|
||||
@@ -164,20 +135,6 @@ export function SeoGeoAnalyticsPage() {
|
||||
|
||||
<section className="admin-card">
|
||||
<div className="admin-actions" style={{ marginBottom: 16, flexWrap: 'wrap', gap: 8 }}>
|
||||
<button
|
||||
type="button"
|
||||
className={channel === 'seo' ? 'send-btn' : 'ghost-btn'}
|
||||
onClick={() => setChannel('seo')}
|
||||
>
|
||||
SEO 流量
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className={channel === 'geo' ? 'send-btn' : 'ghost-btn'}
|
||||
onClick={() => setChannel('geo')}
|
||||
>
|
||||
GEO 流量
|
||||
</button>
|
||||
<button type="button" className="ghost-btn" onClick={() => void load()} disabled={loading}>
|
||||
{loading ? '刷新中…' : '刷新数据'}
|
||||
</button>
|
||||
@@ -196,35 +153,25 @@ export function SeoGeoAnalyticsPage() {
|
||||
<input type="date" value={endDate} min={startDate} onChange={(e) => setEndDate(e.target.value)} />
|
||||
</label>
|
||||
<label className="admin-form-row">
|
||||
<span>生成日期从</span>
|
||||
<span>搜索标题 / 用户 / URL</span>
|
||||
<input
|
||||
type="date"
|
||||
value={generatedStartDate}
|
||||
max={generatedEndDate || undefined}
|
||||
onChange={(e) => setGeneratedStartDate(e.target.value)}
|
||||
/>
|
||||
</label>
|
||||
<label className="admin-form-row">
|
||||
<span>生成日期到</span>
|
||||
<input
|
||||
type="date"
|
||||
value={generatedEndDate}
|
||||
min={generatedStartDate || undefined}
|
||||
onChange={(e) => setGeneratedEndDate(e.target.value)}
|
||||
type="search"
|
||||
value={search}
|
||||
placeholder="例如 john、sitemap、问卷"
|
||||
onChange={(e) => setSearch(e.target.value)}
|
||||
/>
|
||||
</label>
|
||||
<label className="admin-form-row">
|
||||
<span>排序字段</span>
|
||||
<select value={sortBy} onChange={(e) => setSortBy(e.target.value as PageSort)}>
|
||||
<option value="views">浏览量</option>
|
||||
<option value="visitors">访客</option>
|
||||
<option value="visits">访问</option>
|
||||
<option value="clicks">点击</option>
|
||||
<option value="engagedVisits">参与</option>
|
||||
<option value="forms">表单</option>
|
||||
<option value="generatedAt">生成日期</option>
|
||||
<option value="pageTitle">Title</option>
|
||||
<option value="firstSeenAt">首次访问</option>
|
||||
<option value="totalViews">筛选期总访问</option>
|
||||
<option value="seoViews">SEO 搜索来源</option>
|
||||
<option value="geoViews">GEO 来源</option>
|
||||
<option value="crawlerViews">爬虫访问</option>
|
||||
<option value="lifetimeViewCount">累计访问</option>
|
||||
<option value="publishedAt">发布时间</option>
|
||||
<option value="pageTitle">标题</option>
|
||||
<option value="ownerLabel">创建用户</option>
|
||||
</select>
|
||||
</label>
|
||||
<label className="admin-form-row">
|
||||
@@ -238,20 +185,24 @@ export function SeoGeoAnalyticsPage() {
|
||||
|
||||
<div className="admin-metrics-grid" style={{ display: 'flex', gap: 12, flexWrap: 'wrap', marginBottom: 16 }}>
|
||||
<div className="admin-card metric-card" style={{ minWidth: 140, padding: 16 }}>
|
||||
<div className="muted">当前页合计浏览量</div>
|
||||
<strong>{formatNumber(overview.views)}</strong>
|
||||
</div>
|
||||
<div className="admin-card metric-card" style={{ minWidth: 140, padding: 16 }}>
|
||||
<div className="muted">筛选期总浏览量</div>
|
||||
<strong>{formatNumber(totalViews)}</strong>
|
||||
</div>
|
||||
<div className="admin-card metric-card" style={{ minWidth: 140, padding: 16 }}>
|
||||
<div className="muted">有 {channel.toUpperCase()} 流量的页面</div>
|
||||
<div className="muted">在线页面总数</div>
|
||||
<strong>{formatNumber(total)}</strong>
|
||||
</div>
|
||||
<div className="admin-card metric-card" style={{ minWidth: 140, padding: 16 }}>
|
||||
<div className="muted">点击 / 参与 / 表单</div>
|
||||
<strong>{formatNumber(overview.clicks)} / {formatNumber(overview.engaged)} / {formatNumber(overview.forms)}</strong>
|
||||
<div className="muted">筛选期总访问</div>
|
||||
<strong>{formatNumber(totals.totalViews)}</strong>
|
||||
</div>
|
||||
<div className="admin-card metric-card" style={{ minWidth: 140, padding: 16 }}>
|
||||
<div className="muted">SEO 搜索来源</div>
|
||||
<strong>{formatNumber(totals.seoViews)}</strong>
|
||||
</div>
|
||||
<div className="admin-card metric-card" style={{ minWidth: 140, padding: 16 }}>
|
||||
<div className="muted">GEO 来源</div>
|
||||
<strong>{formatNumber(totals.geoViews)}</strong>
|
||||
</div>
|
||||
<div className="admin-card metric-card" style={{ minWidth: 140, padding: 16 }}>
|
||||
<div className="muted">爬虫访问</div>
|
||||
<strong>{formatNumber(totals.crawlerViews)}</strong>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -259,17 +210,17 @@ export function SeoGeoAnalyticsPage() {
|
||||
<table className="admin-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Title</th>
|
||||
<th>页面 URL</th>
|
||||
<th>浏览量</th>
|
||||
<th>访客</th>
|
||||
<th>访问</th>
|
||||
<th>点击</th>
|
||||
<th>参与</th>
|
||||
<th>表单</th>
|
||||
<th>贡献值</th>
|
||||
<th>生成日期</th>
|
||||
<th>筛选期首次访问</th>
|
||||
<th>标题</th>
|
||||
<th>创建用户</th>
|
||||
<th>页面 URL(仅展示)</th>
|
||||
<th>访问模式</th>
|
||||
<th>可收录</th>
|
||||
<th>SEO</th>
|
||||
<th>GEO</th>
|
||||
<th>总访问</th>
|
||||
<th>爬虫</th>
|
||||
<th>累计访问</th>
|
||||
<th>发布时间</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
@@ -279,42 +230,30 @@ export function SeoGeoAnalyticsPage() {
|
||||
</tr>
|
||||
) : rows.length === 0 ? (
|
||||
<tr>
|
||||
<td colSpan={11}>当前日期范围内暂无 {channel.toUpperCase()} 来源的页面访问数据</td>
|
||||
<td colSpan={11}>暂无在线页面</td>
|
||||
</tr>
|
||||
) : (
|
||||
rows.map((row) => {
|
||||
const pageUrl = resolvePageUrl(row);
|
||||
const contribution = totalViews
|
||||
? `${((Number(row.views) / totalViews) * 100).toFixed(2)}%`
|
||||
: '0.00%';
|
||||
return (
|
||||
<tr key={`${row.urlPath}-${row.firstSeenAt ?? ''}`}>
|
||||
<td className="admin-table-path" title={row.pageTitle}>{row.pageTitle}</td>
|
||||
<td className="admin-table-path">
|
||||
<a href={pageUrl} target="_blank" rel="noreferrer" title={pageUrl}>
|
||||
{pageUrl}
|
||||
</a>
|
||||
</td>
|
||||
<td>{formatNumber(row.views)}</td>
|
||||
<td>{formatNumber(row.visitors)}</td>
|
||||
<td>{formatNumber(row.visits)}</td>
|
||||
<td>{formatNumber(row.clicks)}</td>
|
||||
<td>{formatNumber(row.engagedVisits)}</td>
|
||||
<td>{formatNumber(row.forms)}</td>
|
||||
<td>{contribution}</td>
|
||||
<td>
|
||||
{row.generatedAt
|
||||
? `${new Date(row.generatedAt).toLocaleDateString('zh-CN')}${row.generatedAtInferred ? '(首访回推)' : ''}`
|
||||
: '—'}
|
||||
</td>
|
||||
<td>
|
||||
{row.firstSeenAt
|
||||
? new Date(row.firstSeenAt).toLocaleString('zh-CN')
|
||||
: '—'}
|
||||
</td>
|
||||
</tr>
|
||||
);
|
||||
})
|
||||
rows.map((row) => (
|
||||
<tr key={row.publicationId}>
|
||||
<td className="admin-table-path" title={row.pageTitle}>{row.pageTitle}</td>
|
||||
<td>{row.ownerLabel}</td>
|
||||
<td className="admin-table-path">
|
||||
<span title={row.publicUrl}>{row.publicUrl}</span>
|
||||
</td>
|
||||
<td>{accessModeLabel(row.accessMode)}</td>
|
||||
<td>{row.indexable ? '是' : '否'}</td>
|
||||
<td>{formatNumber(row.seoViews)}</td>
|
||||
<td>{formatNumber(row.geoViews)}</td>
|
||||
<td>{formatNumber(row.totalViews)}</td>
|
||||
<td>{formatNumber(row.crawlerViews)}</td>
|
||||
<td>{formatNumber(row.lifetimeViewCount)}</td>
|
||||
<td>
|
||||
{row.publishedAt
|
||||
? new Date(row.publishedAt).toLocaleString('zh-CN')
|
||||
: '—'}
|
||||
</td>
|
||||
</tr>
|
||||
))
|
||||
)}
|
||||
</tbody>
|
||||
</table>
|
||||
|
||||
@@ -273,6 +273,29 @@ 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 className="mono">{user.workspaceRoot}</dd>
|
||||
|
||||
+70
-3
@@ -549,6 +549,60 @@ export type SeoGeoAnalyticsPagesResult = {
|
||||
pageSize: number;
|
||||
};
|
||||
|
||||
export type SeoGeoCatalogQuery = {
|
||||
startAt: number;
|
||||
endAt: number;
|
||||
page?: number;
|
||||
pageSize?: number;
|
||||
sortBy?: string;
|
||||
sortOrder?: 'asc' | 'desc';
|
||||
search?: string;
|
||||
};
|
||||
|
||||
export type SeoGeoCatalogRow = {
|
||||
publicationId: string;
|
||||
pageId: string;
|
||||
userId: string;
|
||||
ownerLabel: string;
|
||||
username: string;
|
||||
pageTitle: string;
|
||||
pageSummary: string;
|
||||
publicUrl: string;
|
||||
accessMode: string;
|
||||
indexable: boolean;
|
||||
publishedAt: number | null;
|
||||
lifetimeViewCount: number;
|
||||
totalViews: number;
|
||||
seoViews: number;
|
||||
geoViews: number;
|
||||
crawlerViews: number;
|
||||
};
|
||||
|
||||
export type SeoGeoCatalogResult = {
|
||||
data: SeoGeoCatalogRow[];
|
||||
total: number;
|
||||
page: number;
|
||||
pageSize: number;
|
||||
totals: {
|
||||
totalViews: number;
|
||||
seoViews: number;
|
||||
geoViews: number;
|
||||
crawlerViews: number;
|
||||
};
|
||||
};
|
||||
|
||||
export async function getSeoGeoPublicationCatalog(
|
||||
query: SeoGeoCatalogQuery,
|
||||
): Promise<SeoGeoCatalogResult> {
|
||||
const params = new URLSearchParams();
|
||||
Object.entries(query).forEach(([key, value]) => {
|
||||
if (value !== undefined && value !== null && value !== '') {
|
||||
params.set(key, String(value));
|
||||
}
|
||||
});
|
||||
return portalFetch(`/admin-api/analytics/seo-geo-catalog?${params.toString()}`);
|
||||
}
|
||||
|
||||
export async function getSeoGeoAnalyticsPages(
|
||||
query: SeoGeoAnalyticsQuery,
|
||||
): Promise<SeoGeoAnalyticsPagesResult> {
|
||||
@@ -833,6 +887,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 +1410,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 }),
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
@@ -18,6 +18,7 @@ export type PortalUser = {
|
||||
balanceCents: number;
|
||||
totalCreditCents?: number;
|
||||
tokensUsed: number;
|
||||
billingFormula?: 'A' | 'B';
|
||||
spaceQuotaBytes?: number;
|
||||
spaceUsedBytes?: number;
|
||||
spaceReservedBytes?: number;
|
||||
@@ -44,6 +45,7 @@ export type AuthStatus = {
|
||||
};
|
||||
|
||||
export type AdminUserRow = PortalUser & {
|
||||
billingFormula?: 'A' | 'B';
|
||||
createdAt: number;
|
||||
updatedAt: number;
|
||||
};
|
||||
@@ -676,8 +678,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 +736,7 @@ export type AdminSubscription = {
|
||||
periodImagesLimit: number;
|
||||
periodImagesUsed: number;
|
||||
periodImagesBonus?: number;
|
||||
autoRenew?: boolean;
|
||||
note: string | null;
|
||||
createdAt: number;
|
||||
};
|
||||
|
||||
+2
-2
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user