Compare commits
16 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| c79e1b7a35 | |||
| 0da7f04878 | |||
| 6a7d6cec70 | |||
| a67d5a137a | |||
| 0e26db1857 | |||
| ed8bc6d1b1 | |||
| 7120d4b5ea | |||
| b45fcabbf3 | |||
| 363f9169ba | |||
| d0183cb636 | |||
| eca1aa635e | |||
| f8317e8312 | |||
| a4d46f2b1e | |||
| 4c33f210da | |||
| 1af5b1dd82 | |||
| 9933bfb89c |
+7
-8
@@ -2,10 +2,10 @@
|
||||
ADM_API_PORT=8085
|
||||
ADM_DEV_BACKEND=http://127.0.0.1:8085
|
||||
|
||||
# gadm 独立登录(会话 cookie 绑定 gadm 域名;勿设 H5_COOKIE_DOMAIN=.tkmind.cn)
|
||||
H5_PUBLIC_BASE_URL=https://gadm.tkmind.cn
|
||||
# md.tkmind.cn 独立登录(会话 cookie 绑定 md 域名;勿设 H5_COOKIE_DOMAIN=.tkmind.cn)
|
||||
H5_PUBLIC_BASE_URL=https://md.tkmind.cn
|
||||
|
||||
# 前端根路径部署(https://gadm.tkmind.cn/ 直达后台;Plaza 运营在 /ops)
|
||||
# 前端根路径部署(https://md.tkmind.cn/ 直达后台;SEO/GEO 看板 /analytics/seo-geo)
|
||||
# ── MySQL(与 Memind 共用远程库,配置在本项目 .env)──
|
||||
DATABASE_URL=mysql://boot:password@host:3306/goose
|
||||
# 或分别设置:
|
||||
@@ -27,7 +27,7 @@ H5_USERS_ROOT=/Users/john/Project/memind_adm/data/users
|
||||
# TKMIND_SERVER__SECRET_KEY=local-dev-secret
|
||||
|
||||
# 套餐同步到生产后台(可选;配置后套餐增删改会自动镜像到目标后台)
|
||||
# PLAN_SYNC_TARGET_BASE_URL=https://gadm.tkmind.cn
|
||||
# PLAN_SYNC_TARGET_BASE_URL=https://md.tkmind.cn
|
||||
# PLAN_SYNC_USERNAME=admin
|
||||
# PLAN_SYNC_PASSWORD=change-me-admin
|
||||
# PLAN_SYNC_TIMEOUT_MS=10000
|
||||
@@ -41,11 +41,10 @@ VITE_MAIN_APP_URL=https://h5.tkmind.cn
|
||||
# Umami 仅允许从 MemindAdm 单点登录进入(必须与 memind-analytics 一致)
|
||||
MEMIND_UMAMI_SSO_SECRET=replace-with-the-same-random-secret-used-by-umami
|
||||
UMAMI_SSO_USERNAME=admin
|
||||
# SEO/GEO 看板拉取 Umami memind-pages API 时使用(勿提交到 Git)
|
||||
# UMAMI_ADMIN_PASSWORD=change-me-umami-admin
|
||||
|
||||
# Rybbit 仅允许从 MemindAdm 单点登录进入(必须与 105 Rybbit 一致)
|
||||
MEMIND_RYBBIT_SSO_SECRET=replace-with-the-same-random-secret-used-by-rybbit
|
||||
RYBBIT_SSO_EMAIL=admin@tkmind.cn
|
||||
RYBBIT_URL=https://rybbit.tkmind.cn
|
||||
# Rybbit 已退役(2026-08),勿再配置 MEMIND_RYBBIT_* / RYBBIT_*
|
||||
|
||||
# Plaza 帖子预览链接
|
||||
# VITE_PLAZA_BASE=https://plaza.tkmind.cn
|
||||
|
||||
@@ -19,3 +19,24 @@ bash scripts/check-release-ready.sh
|
||||
- `memind_adm` 可以独立开发,但共享用户、权限、策略、技能、计费体系必须继续复用 `Memind` 主实现。
|
||||
- 发版须 Git commit,禁止本机直 `rsync` 到 `103/105`。
|
||||
- 共享用户、计费、空间额度、策略同步相关改动必须保留业务验收记录。
|
||||
|
||||
## 必读:本仓库是唯一合法的管理后台 UI(5174)
|
||||
|
||||
**所有平台管理后台的前端功能只能在本仓库(memind_adm)开发,本地端口 5174,生产 [md.tkmind.cn](https://md.tkmind.cn)。禁止在 Memind 仓库的 `ops/`(约 3002)新增任何管理页面、导航或 API 客户端。**
|
||||
|
||||
| 组件 | 端口 | 职责 |
|
||||
|------|------|------|
|
||||
| **memind_adm 前端(本仓库 `src/`)** | **5174** | 管理后台 UI:用户、计费、图片额度、策略、模型中心等 |
|
||||
| memind_adm API(`server/`) | 8085 | 挂载 `/admin-api/*`,复用 Memind 共享模块 |
|
||||
| Memind `ops/` | ~3002 | Plaza 运营 + 遗留 admin;**只读维护,禁止扩展** |
|
||||
| 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/*` 路由 |
|
||||
@@ -12,7 +12,9 @@
|
||||
"local_restart": "bash scripts/local_restart.sh",
|
||||
"pro_restart": "bash scripts/pro_restart.sh",
|
||||
"build": "vite build",
|
||||
"test:umami-analytics": "node --test server/umami-analytics.test.mjs",
|
||||
"test:orchestrator": "node --test server/orchestrator-routes.test.mjs",
|
||||
"verify:template-catalog-local": "node scripts/verify-template-catalog-local.mjs",
|
||||
"preview": "node scripts/preview.mjs",
|
||||
"dev:preview": "vite preview"
|
||||
},
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -0,0 +1,122 @@
|
||||
#!/usr/bin/env node
|
||||
/**
|
||||
* Local smoke test for image-quota admin API (memind_adm 8085).
|
||||
* Usage: node scripts/verify-image-quota-local.mjs
|
||||
*/
|
||||
import fs from 'node:fs';
|
||||
import path from 'node:path';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
|
||||
const root = path.join(path.dirname(fileURLToPath(import.meta.url)), '..');
|
||||
const base = process.env.ADM_DEV_BACKEND ?? 'http://127.0.0.1:8085';
|
||||
|
||||
function loadEnvFile(filePath) {
|
||||
if (!fs.existsSync(filePath)) return;
|
||||
for (const line of fs.readFileSync(filePath, 'utf8').split('\n')) {
|
||||
const trimmed = line.trim();
|
||||
if (!trimmed || trimmed.startsWith('#')) continue;
|
||||
const eq = trimmed.indexOf('=');
|
||||
if (eq <= 0) continue;
|
||||
const key = trimmed.slice(0, eq).trim();
|
||||
let value = trimmed.slice(eq + 1).trim();
|
||||
if ((value.startsWith('"') && value.endsWith('"')) || (value.startsWith("'") && value.endsWith("'"))) {
|
||||
value = value.slice(1, -1);
|
||||
}
|
||||
if (!(key in process.env)) process.env[key] = value;
|
||||
}
|
||||
}
|
||||
|
||||
loadEnvFile(path.join(root, '.env'));
|
||||
|
||||
const username = process.env.H5_ADMIN_USERNAME?.trim() || 'admin';
|
||||
const password = process.env.H5_ADMIN_PASSWORD?.trim() || process.env.ADMIN_PASSWORD?.trim() || '';
|
||||
|
||||
async function request(method, urlPath, { body, cookie } = {}) {
|
||||
const res = await fetch(`${base}${urlPath}`, {
|
||||
method,
|
||||
headers: {
|
||||
...(body ? { 'Content-Type': 'application/json' } : {}),
|
||||
...(cookie ? { Cookie: cookie } : {}),
|
||||
},
|
||||
body: body ? JSON.stringify(body) : undefined,
|
||||
});
|
||||
const text = await res.text();
|
||||
let json = null;
|
||||
try {
|
||||
json = text ? JSON.parse(text) : null;
|
||||
} catch {
|
||||
json = { raw: text.slice(0, 200) };
|
||||
}
|
||||
return { status: res.status, json, setCookie: res.headers.getSetCookie?.() ?? [] };
|
||||
}
|
||||
|
||||
function cookieFromSetCookie(setCookie) {
|
||||
return setCookie.map((c) => c.split(';')[0]).join('; ');
|
||||
}
|
||||
|
||||
function assertOk(cond, msg) {
|
||||
if (!cond) throw new Error(msg);
|
||||
}
|
||||
|
||||
async function main() {
|
||||
console.log(`==> Admin API base: ${base}`);
|
||||
|
||||
if (!password) {
|
||||
console.error('缺少 H5_ADMIN_PASSWORD(请在 memind_adm/.env 配置)');
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
const login = await request('POST', '/auth/login', {
|
||||
body: { username, password },
|
||||
});
|
||||
assertOk(login.status === 200, `登录失败 HTTP ${login.status}: ${JSON.stringify(login.json)}`);
|
||||
const cookie = cookieFromSetCookie(login.setCookie);
|
||||
assertOk(cookie, '登录未返回 session cookie');
|
||||
console.log(`✓ 管理员登录 (${username})`);
|
||||
|
||||
const config = await request('GET', '/admin-api/image-quota/config', { cookie });
|
||||
assertOk(config.status === 200, `config HTTP ${config.status}`);
|
||||
assertOk(Array.isArray(config.json?.plans), 'config.plans 应为数组');
|
||||
assertOk(config.json.plans.length > 0, 'config.plans 不应为空');
|
||||
console.log(`✓ GET /admin-api/image-quota/config (${config.json.plans.length} 套餐)`);
|
||||
|
||||
const users = await request('GET', '/admin-api/users?page=1&pageSize=5&role=user', { cookie });
|
||||
assertOk(users.status === 200, `users HTTP ${users.status}`);
|
||||
const sampleUser = users.json?.users?.[0];
|
||||
assertOk(sampleUser?.id, '需要至少一个 user 账号做用户级测试');
|
||||
console.log(`✓ 样本用户: ${sampleUser.username} (${sampleUser.id})`);
|
||||
|
||||
const userQuota = await request('GET', `/admin-api/users/${sampleUser.id}/image-quota`, { cookie });
|
||||
assertOk(userQuota.status === 200, `user image-quota HTTP ${userQuota.status}`);
|
||||
assertOk(userQuota.json?.quota, '应返回 quota 对象');
|
||||
console.log(
|
||||
`✓ GET user image-quota: remaining=${userQuota.json.quota.remaining ?? '∞'} unlimited=${userQuota.json.quota.unlimited}`,
|
||||
);
|
||||
|
||||
const ledger = await request('GET', '/admin-api/image-quota/ledger?page=1&pageSize=5', { cookie });
|
||||
assertOk(ledger.status === 200, `ledger HTTP ${ledger.status}`);
|
||||
assertOk(Array.isArray(ledger.json?.entries), 'ledger.entries 应为数组');
|
||||
console.log(`✓ GET /admin-api/image-quota/ledger (${ledger.json.total} 条)`);
|
||||
|
||||
const grant = await request('POST', `/admin-api/users/${sampleUser.id}/image-quota/grant`, {
|
||||
cookie,
|
||||
body: { delta: 1, note: 'local-verify-image-quota' },
|
||||
});
|
||||
assertOk(grant.status === 200, `grant HTTP ${grant.status}: ${JSON.stringify(grant.json)}`);
|
||||
assertOk(grant.json?.quota, 'grant 应返回更新后的 quota');
|
||||
console.log(`✓ POST grant +1 → remaining=${grant.json.quota.remaining ?? '∞'}`);
|
||||
|
||||
const revoke = await request('POST', `/admin-api/users/${sampleUser.id}/image-quota/grant`, {
|
||||
cookie,
|
||||
body: { delta: -1, note: 'local-verify-image-quota-rollback' },
|
||||
});
|
||||
assertOk(revoke.status === 200, `rollback grant HTTP ${revoke.status}`);
|
||||
console.log('✓ POST grant -1 回滚测试额度');
|
||||
|
||||
console.log('\n全部 image-quota Admin API 联调通过。');
|
||||
}
|
||||
|
||||
main().catch((err) => {
|
||||
console.error('\n联调失败:', err.message);
|
||||
process.exit(1);
|
||||
});
|
||||
@@ -0,0 +1,92 @@
|
||||
#!/usr/bin/env node
|
||||
/** Portal smoke: /auth/me subscription includes image quota fields */
|
||||
import fs from 'node:fs';
|
||||
import path from 'node:path';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
|
||||
const memindRoot = path.join(path.dirname(fileURLToPath(import.meta.url)), '../..', 'Memind');
|
||||
const portalBase = process.env.PORTAL_BASE ?? 'http://127.0.0.1:8081';
|
||||
|
||||
function loadEnvFile(filePath) {
|
||||
if (!fs.existsSync(filePath)) return;
|
||||
for (const line of fs.readFileSync(filePath, 'utf8').split('\n')) {
|
||||
const trimmed = line.trim();
|
||||
if (!trimmed || trimmed.startsWith('#')) continue;
|
||||
const eq = trimmed.indexOf('=');
|
||||
if (eq <= 0) continue;
|
||||
const key = trimmed.slice(0, eq).trim();
|
||||
let value = trimmed.slice(eq + 1).trim();
|
||||
if ((value.startsWith('"') && value.endsWith('"')) || (value.startsWith("'") && value.endsWith("'"))) {
|
||||
value = value.slice(1, -1);
|
||||
}
|
||||
if (!(key in process.env)) process.env[key] = value;
|
||||
}
|
||||
}
|
||||
|
||||
loadEnvFile(path.join(memindRoot, '.env'));
|
||||
|
||||
const username = process.env.H5_TEST_USERNAME?.trim() || process.env.H5_ADMIN_USERNAME?.trim() || 'admin';
|
||||
const password = process.env.H5_TEST_PASSWORD?.trim() || process.env.H5_ADMIN_PASSWORD?.trim() || '';
|
||||
|
||||
async function request(method, urlPath, { body, cookie } = {}) {
|
||||
const res = await fetch(`${portalBase}${urlPath}`, {
|
||||
method,
|
||||
headers: {
|
||||
...(body ? { 'Content-Type': 'application/json' } : {}),
|
||||
...(cookie ? { Cookie: cookie } : {}),
|
||||
},
|
||||
body: body ? JSON.stringify(body) : undefined,
|
||||
});
|
||||
const text = await res.text();
|
||||
let json = null;
|
||||
try { json = text ? JSON.parse(text) : null; } catch { json = { raw: text.slice(0, 200) }; }
|
||||
return { status: res.status, json, setCookie: res.headers.getSetCookie?.() ?? [] };
|
||||
}
|
||||
|
||||
function cookieFromSetCookie(setCookie) {
|
||||
return setCookie.map((c) => c.split(';')[0]).join('; ');
|
||||
}
|
||||
|
||||
async function main() {
|
||||
console.log(`==> Portal base: ${portalBase}`);
|
||||
if (!password) {
|
||||
console.error('缺少登录密码(Memind/.env 中 H5_ADMIN_PASSWORD 或 H5_TEST_PASSWORD)');
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
const login = await request('POST', '/auth/login', { body: { username, password } });
|
||||
if (login.status !== 200) {
|
||||
console.error(`登录失败 HTTP ${login.status}`);
|
||||
process.exit(1);
|
||||
}
|
||||
const cookie = cookieFromSetCookie(login.setCookie);
|
||||
console.log(`✓ Portal 登录 (${username})`);
|
||||
|
||||
const me = await request('GET', '/auth/me', { cookie });
|
||||
if (me.status !== 200) {
|
||||
console.error(`/auth/me HTTP ${me.status}`);
|
||||
process.exit(1);
|
||||
}
|
||||
const sub = me.json?.user?.subscription;
|
||||
if (!sub) {
|
||||
console.log('⚠ 当前用户无 active subscription(免费用户可能无订阅记录)');
|
||||
process.exit(0);
|
||||
}
|
||||
|
||||
const fields = ['periodImagesLimit', 'periodImagesUsed', 'periodImagesBonus'];
|
||||
for (const f of fields) {
|
||||
if (!(f in sub)) {
|
||||
console.error(`subscription 缺少字段: ${f}`);
|
||||
process.exit(1);
|
||||
}
|
||||
}
|
||||
console.log(
|
||||
`✓ /auth/me subscription 图片额度: limit=${sub.periodImagesLimit} bonus=${sub.periodImagesBonus ?? 0} used=${sub.periodImagesUsed}`,
|
||||
);
|
||||
console.log('\nPortal 用户侧 subscription 字段联调通过。');
|
||||
}
|
||||
|
||||
main().catch((err) => {
|
||||
console.error('联调失败:', err.message);
|
||||
process.exit(1);
|
||||
});
|
||||
@@ -0,0 +1,128 @@
|
||||
#!/usr/bin/env node
|
||||
/**
|
||||
* Local smoke test for page template catalog admin API (memind_adm 8085).
|
||||
* Usage: node scripts/verify-template-catalog-local.mjs
|
||||
*/
|
||||
import fs from 'node:fs';
|
||||
import path from 'node:path';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
|
||||
const root = path.join(path.dirname(fileURLToPath(import.meta.url)), '..');
|
||||
const base = process.env.ADM_DEV_BACKEND ?? 'http://127.0.0.1:8085';
|
||||
|
||||
function loadEnvFile(filePath) {
|
||||
if (!fs.existsSync(filePath)) return;
|
||||
for (const line of fs.readFileSync(filePath, 'utf8').split('\n')) {
|
||||
const trimmed = line.trim();
|
||||
if (!trimmed || trimmed.startsWith('#')) continue;
|
||||
const eq = trimmed.indexOf('=');
|
||||
if (eq <= 0) continue;
|
||||
const key = trimmed.slice(0, eq).trim();
|
||||
let value = trimmed.slice(eq + 1).trim();
|
||||
if ((value.startsWith('"') && value.endsWith('"')) || (value.startsWith("'") && value.endsWith("'"))) {
|
||||
value = value.slice(1, -1);
|
||||
}
|
||||
if (!(key in process.env)) process.env[key] = value;
|
||||
}
|
||||
}
|
||||
|
||||
loadEnvFile(path.join(root, '.env'));
|
||||
|
||||
const username = process.env.H5_ADMIN_USERNAME?.trim() || 'admin';
|
||||
const password = process.env.H5_ADMIN_PASSWORD?.trim() || process.env.ADMIN_PASSWORD?.trim() || '';
|
||||
|
||||
async function request(method, urlPath, { body, cookie } = {}) {
|
||||
const res = await fetch(`${base}${urlPath}`, {
|
||||
method,
|
||||
headers: {
|
||||
...(body ? { 'Content-Type': 'application/json' } : {}),
|
||||
...(cookie ? { Cookie: cookie } : {}),
|
||||
},
|
||||
body: body ? JSON.stringify(body) : undefined,
|
||||
});
|
||||
const text = await res.text();
|
||||
let json = null;
|
||||
try {
|
||||
json = text ? JSON.parse(text) : null;
|
||||
} catch {
|
||||
json = { raw: text.slice(0, 300) };
|
||||
}
|
||||
return { status: res.status, json, setCookie: res.headers.getSetCookie?.() ?? [] };
|
||||
}
|
||||
|
||||
function cookieFromSetCookie(setCookie) {
|
||||
return setCookie.map((c) => c.split(';')[0]).join('; ');
|
||||
}
|
||||
|
||||
function assertOk(cond, msg) {
|
||||
if (!cond) throw new Error(msg);
|
||||
}
|
||||
|
||||
async function main() {
|
||||
console.log(`==> Admin API base: ${base}`);
|
||||
|
||||
if (!password) {
|
||||
console.error('缺少 H5_ADMIN_PASSWORD(请在 memind_adm/.env 配置)');
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
const login = await request('POST', '/auth/login', {
|
||||
body: { username, password },
|
||||
});
|
||||
assertOk(login.status === 200, `登录失败 HTTP ${login.status}: ${JSON.stringify(login.json)}`);
|
||||
const cookie = cookieFromSetCookie(login.setCookie);
|
||||
assertOk(cookie, '登录未返回 session cookie');
|
||||
console.log(`✓ 管理员登录 (${username})`);
|
||||
|
||||
const catalog = await request('GET', '/admin-api/template-catalog', { cookie });
|
||||
assertOk(catalog.status === 200, `template-catalog HTTP ${catalog.status}: ${JSON.stringify(catalog.json)}`);
|
||||
assertOk(Array.isArray(catalog.json?.items), 'items 应为数组');
|
||||
assertOk(catalog.json.items.length >= 3, `应至少 seed 3 个 page-template skill,实际 ${catalog.json.items.length}`);
|
||||
const travel = catalog.json.items.find((item) => item.skillName === 'page-template-travel');
|
||||
assertOk(travel, '缺少 page-template-travel');
|
||||
console.log(
|
||||
`✓ GET /admin-api/template-catalog (${catalog.json.items.length} 项,travel=${travel.priceCents} 分)`,
|
||||
);
|
||||
|
||||
const originalSort = travel.sortOrder ?? 10;
|
||||
const patchedSort = originalSort === 11 ? 12 : 11;
|
||||
const update = await request('PUT', '/admin-api/template-catalog/page-template-travel', {
|
||||
cookie,
|
||||
body: { sortOrder: patchedSort },
|
||||
});
|
||||
assertOk(update.status === 200, `update HTTP ${update.status}: ${JSON.stringify(update.json)}`);
|
||||
assertOk(update.json?.item?.sortOrder === patchedSort, 'sortOrder 未更新');
|
||||
console.log(`✓ PUT sortOrder ${originalSort} → ${patchedSort}`);
|
||||
|
||||
const restore = await request('PUT', '/admin-api/template-catalog/page-template-travel', {
|
||||
cookie,
|
||||
body: { sortOrder: originalSort },
|
||||
});
|
||||
assertOk(restore.status === 200, `restore HTTP ${restore.status}`);
|
||||
console.log(`✓ PUT sortOrder 恢复为 ${originalSort}`);
|
||||
|
||||
const users = await request('GET', '/admin-api/users?page=1&pageSize=5&role=user', { cookie });
|
||||
assertOk(users.status === 200, `users HTTP ${users.status}`);
|
||||
const sampleUser = users.json?.users?.[0];
|
||||
assertOk(sampleUser?.id, '需要至少一个 user 账号做授权测试');
|
||||
|
||||
const grant = await request(
|
||||
'POST',
|
||||
`/admin-api/users/${sampleUser.id}/template-grants/page-template-travel`,
|
||||
{ cookie },
|
||||
);
|
||||
assertOk(grant.status === 200, `grant HTTP ${grant.status}: ${JSON.stringify(grant.json)}`);
|
||||
assertOk(grant.json?.ok === true, 'grant 应返回 ok:true');
|
||||
assertOk(
|
||||
Array.isArray(grant.json?.grantedSkills) && grant.json.grantedSkills.includes('page-template-travel'),
|
||||
'grant 应包含 page-template-travel',
|
||||
);
|
||||
console.log(`✓ POST template-grants → user ${sampleUser.username}`);
|
||||
|
||||
console.log('\n全部 template-catalog Admin API 联调通过。');
|
||||
}
|
||||
|
||||
main().catch((err) => {
|
||||
console.error('\n联调失败:', err.message);
|
||||
process.exit(1);
|
||||
});
|
||||
@@ -3,7 +3,7 @@ import { defineConfig, loadEnv } from 'vite';
|
||||
export default defineConfig(({ mode }) => {
|
||||
const env = loadEnv(mode, process.cwd(), '');
|
||||
const backend = env.ADM_DEV_BACKEND ?? 'http://127.0.0.1:8085';
|
||||
const basePath = env.VITE_BASE_PATH?.trim() || '/ops';
|
||||
const basePath = env.VITE_BASE_PATH?.trim() || '/';
|
||||
const base = basePath.endsWith('/') ? basePath : `${basePath}/`;
|
||||
|
||||
return {
|
||||
@@ -12,7 +12,7 @@ export default defineConfig(({ mode }) => {
|
||||
host: env.ADM_WEB_HOST ?? env.ADM_API_HOST ?? '127.0.0.1',
|
||||
port: Number(env.ADM_PORT ?? 5174),
|
||||
strictPort: true,
|
||||
allowedHosts: ['gadm.tkmind.cn', 'localhost', '127.0.0.1', '10.10.0.2', '58.38.22.103'],
|
||||
allowedHosts: ['md.tkmind.cn', 'gadm.tkmind.cn', 'localhost', '127.0.0.1', '10.10.0.2', '58.38.22.103'],
|
||||
proxy: {
|
||||
'/api': backend,
|
||||
'/auth': backend,
|
||||
|
||||
+204
-12
@@ -5,6 +5,8 @@ import { spawn } from 'node:child_process';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
import express from 'express';
|
||||
import { listUsagePaged, listLedgerPaged, getUsageStats, getUsageSummary } from './pagination.mjs';
|
||||
import { fetchMemindDiscoveryPages } from './umami-analytics.mjs';
|
||||
import { importMemind } from './lib-path.mjs';
|
||||
|
||||
const projectRoot = path.join(path.dirname(fileURLToPath(import.meta.url)), '..');
|
||||
|
||||
@@ -104,12 +106,14 @@ export function createAdminApp(services) {
|
||||
orchestratorObservabilityService,
|
||||
personalMemoryCandidateStore,
|
||||
skillRuntimeConfigService,
|
||||
billingConfigService,
|
||||
wechatScheduleLlmConfigService,
|
||||
adminSystemTestService,
|
||||
systemTestAccountService,
|
||||
wordFilterService,
|
||||
planCatalogService,
|
||||
subscriptionService,
|
||||
templateCatalogService,
|
||||
planSyncService,
|
||||
} = services;
|
||||
const app = express();
|
||||
@@ -234,19 +238,46 @@ export function createAdminApp(services) {
|
||||
res.json({ url: `${baseUrl}/auth/memind?ticket=${encoded}.${signature}` });
|
||||
});
|
||||
|
||||
adminApi.get('/analytics/seo-geo-pages', requireAdmin, async (req, res) => {
|
||||
try {
|
||||
const config = loadMindSpaceConfig ? await loadMindSpaceConfig(pool) : null;
|
||||
const result = await fetchMemindDiscoveryPages(req.query, {
|
||||
env: process.env,
|
||||
analyticsConfig: config?.analytics ?? {},
|
||||
});
|
||||
res.json(result);
|
||||
} catch (error) {
|
||||
res.status(503).json({
|
||||
message: error instanceof Error ? error.message : '无法加载 SEO/GEO 页面统计',
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
adminApi.get('/analytics/seo-geo-catalog', requireAdmin, async (req, res) => {
|
||||
try {
|
||||
const { listAdminSeoGeoPublicationCatalog } = await importMemind('mindspace-seo-geo-admin-catalog.mjs');
|
||||
const startAt = Number(req.query.startAt);
|
||||
const endAt = Number(req.query.endAt);
|
||||
const result = await listAdminSeoGeoPublicationCatalog(pool, {
|
||||
page: Number(req.query.page) || 1,
|
||||
pageSize: Number(req.query.pageSize) || 20,
|
||||
sortBy: typeof req.query.sortBy === 'string' ? req.query.sortBy : 'publishedAt',
|
||||
sortOrder: req.query.sortOrder === 'asc' ? 'asc' : 'desc',
|
||||
startAt: Number.isFinite(startAt) ? startAt : null,
|
||||
endAt: Number.isFinite(endAt) ? endAt : null,
|
||||
search: typeof req.query.search === 'string' ? req.query.search.trim() : '',
|
||||
publicHost: process.env.H5_PUBLIC_BASE_URL?.replace(/^https?:\/\//, '').replace(/\/$/, '') || 'm.tkmind.cn',
|
||||
});
|
||||
res.json(result);
|
||||
} catch (error) {
|
||||
res.status(503).json({
|
||||
message: error instanceof Error ? error.message : '无法加载 SEO/GEO 页面目录',
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
adminApi.get('/analytics/rybbit-sso', requireAdmin, async (_req, res) => {
|
||||
const sharedSecret = process.env.MEMIND_RYBBIT_SSO_SECRET?.trim();
|
||||
if (!sharedSecret) return res.status(503).json({ message: '未配置 Rybbit 单点登录密钥' });
|
||||
const email = process.env.RYBBIT_SSO_EMAIL?.trim().toLowerCase();
|
||||
if (!email) return res.status(503).json({ message: '未配置 Rybbit 单点登录账号' });
|
||||
const baseUrl = String(process.env.RYBBIT_URL || 'https://rybbit.tkmind.cn').replace(/\/$/, '');
|
||||
const encoded = Buffer.from(JSON.stringify({
|
||||
email,
|
||||
exp: Math.floor(Date.now() / 1000) + 60,
|
||||
nonce: crypto.randomUUID(),
|
||||
})).toString('base64url');
|
||||
const signature = crypto.createHmac('sha256', sharedSecret).update(encoded).digest('base64url');
|
||||
res.json({ url: `${baseUrl}/api/auth/memind?ticket=${encoded}.${signature}` });
|
||||
return res.status(410).json({ message: 'Rybbit 已退役,请使用 Umami 与 SEO/GEO 流量看板' });
|
||||
});
|
||||
|
||||
adminApi.get('/users', requireAdmin, async (req, res) => {
|
||||
@@ -376,6 +407,7 @@ export function createAdminApp(services) {
|
||||
if (!updateMindSpaceConfig) return res.status(503).json({ message: 'MindSpace 配置未启用' });
|
||||
const result = await updateMindSpaceConfig(pool, {
|
||||
publicPageLimit: req.body?.publicPageLimit,
|
||||
seoGeo: req.body?.seoGeo,
|
||||
analytics: req.body?.analytics,
|
||||
});
|
||||
res.json({ config: result });
|
||||
@@ -628,6 +660,40 @@ export function createAdminApp(services) {
|
||||
res.json(await skillRuntimeConfigService.getPublicRuntimeConfig());
|
||||
});
|
||||
|
||||
adminApi.get('/billing/config', requireAdmin, async (_req, res) => {
|
||||
if (!billingConfigService?.getAdminConfig) {
|
||||
return res.status(503).json({ message: '计费公式配置服务未启用' });
|
||||
}
|
||||
return res.json(await billingConfigService.getAdminConfig());
|
||||
});
|
||||
|
||||
const updateBillingConfig = async (req, res) => {
|
||||
if (!billingConfigService?.updateAdminConfig) {
|
||||
return res.status(503).json({ message: '计费公式配置服务未启用' });
|
||||
}
|
||||
try {
|
||||
return res.json(await billingConfigService.updateAdminConfig(
|
||||
req.body?.config ?? req.body ?? {},
|
||||
{ updatedBy: req.currentUser.id },
|
||||
));
|
||||
} catch (error) {
|
||||
if (error?.code === 'BILLING_CONFIG_ENV_LOCKED') {
|
||||
return res.status(409).json({ message: error.message, code: error.code });
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
};
|
||||
|
||||
adminApi.put('/billing/config', requireAdmin, updateBillingConfig);
|
||||
adminApi.patch('/billing/config', requireAdmin, updateBillingConfig);
|
||||
|
||||
adminApi.get('/billing/runtime', requireAdmin, async (_req, res) => {
|
||||
if (!billingConfigService?.getRuntimeState) {
|
||||
return res.status(503).json({ message: '计费公式配置服务未启用' });
|
||||
}
|
||||
return res.json(await billingConfigService.getRuntimeState());
|
||||
});
|
||||
|
||||
adminApi.get('/system-tests/accounts', requireAdmin, async (_req, res) => {
|
||||
if (!systemTestAccountService) {
|
||||
return res.status(503).json({ message: '系统测试账号服务未启用' });
|
||||
@@ -1183,6 +1249,132 @@ export function createAdminApp(services) {
|
||||
res.json(result);
|
||||
});
|
||||
|
||||
// ── Image generation quota ────────────────────────────────────────────────
|
||||
|
||||
adminApi.get('/image-quota/config', requireAdmin, async (_req, res) => {
|
||||
const catalogService = subscriptionService?._planCatalogService ?? planCatalogService;
|
||||
if (!catalogService?.listPlans) {
|
||||
return res.status(503).json({ message: '套餐目录服务未启用' });
|
||||
}
|
||||
const plans = await catalogService.listPlans();
|
||||
res.json({ plans });
|
||||
});
|
||||
|
||||
adminApi.patch('/image-quota/config/:planType', requireAdmin, async (req, res) => {
|
||||
const catalogService = subscriptionService?._planCatalogService ?? planCatalogService;
|
||||
if (!catalogService?.upsertPlan) {
|
||||
return res.status(503).json({ message: '套餐目录服务未启用' });
|
||||
}
|
||||
const periodImages = Number(req.body?.periodImages);
|
||||
if (!Number.isFinite(periodImages) || periodImages < 0) {
|
||||
return res.status(400).json({ message: 'periodImages 必须是非负整数;0 表示无限' });
|
||||
}
|
||||
const current = await catalogService.getPlan(req.params.planType);
|
||||
if (!current) return res.status(404).json({ message: '套餐不存在' });
|
||||
const result = await catalogService.upsertPlan(req.params.planType, {
|
||||
...current,
|
||||
periodImages: Math.floor(periodImages),
|
||||
});
|
||||
if (!result.ok) return res.status(400).json({ message: result.message });
|
||||
res.json(result);
|
||||
});
|
||||
|
||||
adminApi.get('/users/:userId/image-quota', requireAdmin, async (req, res) => {
|
||||
if (!subscriptionService?.getImageQuota) {
|
||||
return res.status(503).json({ message: '图片额度服务未启用' });
|
||||
}
|
||||
const quotaResult = await subscriptionService.getImageQuota(req.params.userId);
|
||||
if (!quotaResult.ok) return res.status(404).json({ message: quotaResult.message });
|
||||
const ledger = subscriptionService.listImageQuotaLedger
|
||||
? await subscriptionService.listImageQuotaLedger({
|
||||
userId: req.params.userId,
|
||||
page: 1,
|
||||
pageSize: 20,
|
||||
})
|
||||
: { entries: [] };
|
||||
res.json({
|
||||
subscription: quotaResult.subscription,
|
||||
quota: quotaResult.quota,
|
||||
ledger: ledger.entries,
|
||||
});
|
||||
});
|
||||
|
||||
adminApi.put('/users/:userId/image-quota', requireAdmin, async (req, res) => {
|
||||
if (!subscriptionService?.setImageQuota) {
|
||||
return res.status(503).json({ message: '图片额度服务未启用' });
|
||||
}
|
||||
const remaining = req.body?.remaining;
|
||||
const total = req.body?.total;
|
||||
const note = String(req.body?.note ?? '').trim();
|
||||
const result = await subscriptionService.setImageQuota(
|
||||
req.params.userId,
|
||||
{
|
||||
remaining: remaining === undefined || remaining === null ? null : Math.floor(Number(remaining)),
|
||||
total: total === undefined || total === null ? null : Math.floor(Number(total)),
|
||||
},
|
||||
{ operatorId: req.currentUser.id, note },
|
||||
);
|
||||
if (!result.ok) return res.status(400).json({ message: result.message });
|
||||
res.json(result);
|
||||
});
|
||||
|
||||
adminApi.post('/users/:userId/image-quota/grant', requireAdmin, async (req, res) => {
|
||||
if (!subscriptionService?.grantImageQuota) {
|
||||
return res.status(503).json({ message: '图片额度服务未启用' });
|
||||
}
|
||||
const delta = Number(req.body?.delta);
|
||||
if (!Number.isFinite(delta) || delta === 0) {
|
||||
return res.status(400).json({ message: 'delta 必须是非零整数' });
|
||||
}
|
||||
const note = String(req.body?.note ?? '').trim();
|
||||
const result = await subscriptionService.grantImageQuota(
|
||||
req.params.userId,
|
||||
Math.floor(delta),
|
||||
{ operatorId: req.currentUser.id, note },
|
||||
);
|
||||
if (!result.ok) return res.status(400).json({ message: result.message });
|
||||
res.json(result);
|
||||
});
|
||||
|
||||
adminApi.get('/image-quota/ledger', requireAdmin, async (req, res) => {
|
||||
if (!subscriptionService?.listImageQuotaLedger) {
|
||||
return res.status(503).json({ message: '图片额度服务未启用' });
|
||||
}
|
||||
const result = await subscriptionService.listImageQuotaLedger({
|
||||
userId: req.query.userId ? String(req.query.userId) : null,
|
||||
page: req.query.page,
|
||||
pageSize: req.query.pageSize,
|
||||
});
|
||||
res.json(result);
|
||||
});
|
||||
|
||||
// ── Page template catalog ─────────────────────────────────────────────────
|
||||
|
||||
adminApi.get('/template-catalog', requireAdmin, async (_req, res) => {
|
||||
if (!templateCatalogService?.adminListCatalog) {
|
||||
return res.status(503).json({ message: '模板商城未启用' });
|
||||
}
|
||||
res.json({ items: await templateCatalogService.adminListCatalog() });
|
||||
});
|
||||
|
||||
adminApi.put('/template-catalog/:skillName', requireAdmin, async (req, res) => {
|
||||
if (!templateCatalogService?.adminUpsertCatalog) {
|
||||
return res.status(503).json({ message: '模板商城未启用' });
|
||||
}
|
||||
const result = await templateCatalogService.adminUpsertCatalog(req.params.skillName, req.body ?? {});
|
||||
if (!result.ok) return res.status(400).json({ message: result.message });
|
||||
res.json(result);
|
||||
});
|
||||
|
||||
adminApi.post('/users/:userId/template-grants/:skillName', requireAdmin, async (req, res) => {
|
||||
if (!templateCatalogService?.adminGrantTemplate) {
|
||||
return res.status(503).json({ message: '模板商城未启用' });
|
||||
}
|
||||
const result = await templateCatalogService.adminGrantTemplate(req.params.userId, req.params.skillName);
|
||||
if (!result.ok) return res.status(400).json({ message: result.message });
|
||||
res.json(result);
|
||||
});
|
||||
|
||||
app.use('/admin-api', adminApi);
|
||||
|
||||
if (services.createOpsApi) {
|
||||
|
||||
@@ -0,0 +1,111 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import { once } from 'node:events';
|
||||
import test from 'node:test';
|
||||
import { createAdminApp } from './app.mjs';
|
||||
|
||||
function createServices({ role = 'admin' } = {}) {
|
||||
let stored = {
|
||||
useBackendCost: true,
|
||||
usdCnyRate: 7.2,
|
||||
marginMultiplier: 1.2,
|
||||
inputCentsPer1k: 2,
|
||||
outputCentsPer1k: 6,
|
||||
minBillCents: 1,
|
||||
costEstimateFromTokens: true,
|
||||
costEstimateInputUsdPer1M: 0.27,
|
||||
costEstimateOutputUsdPer1M: 1.1,
|
||||
};
|
||||
|
||||
return {
|
||||
services: {
|
||||
ready: Promise.resolve(),
|
||||
parseCookies: () => ({ test_session: 'token' }),
|
||||
USER_COOKIE: 'test_session',
|
||||
userLoginCookies: () => [],
|
||||
clearUserSessionCookie: () => {},
|
||||
resolveCookieDomainForRequest: () => undefined,
|
||||
userAuth: {
|
||||
getMe: async () => ({ id: 'admin-id', username: 'admin', role }),
|
||||
},
|
||||
billingConfigService: {
|
||||
getAdminConfig: async () => ({
|
||||
config: stored,
|
||||
source: 'env',
|
||||
updatedAt: null,
|
||||
updatedBy: null,
|
||||
formula: '最终扣费 = 上游成本(USD) × 汇率 × 毛利倍数',
|
||||
}),
|
||||
updateAdminConfig: async (patch, context) => {
|
||||
stored = { ...stored, ...(patch.config ?? patch) };
|
||||
return {
|
||||
config: stored,
|
||||
source: 'admin-db',
|
||||
updatedAt: Date.now(),
|
||||
updatedBy: context?.updatedBy ?? null,
|
||||
formula: '最终扣费 = 上游成本(USD) × 汇率 × 毛利倍数',
|
||||
};
|
||||
},
|
||||
getRuntimeState: async () => ({
|
||||
source: 'admin-db',
|
||||
config: stored,
|
||||
compute: stored,
|
||||
}),
|
||||
},
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
async function startApp(options) {
|
||||
const harness = createServices(options);
|
||||
const server = createAdminApp(harness.services).listen(0, '127.0.0.1');
|
||||
await once(server, 'listening');
|
||||
const address = server.address();
|
||||
assert.ok(address && typeof address === 'object');
|
||||
return {
|
||||
...harness,
|
||||
request: (path, init = {}) => fetch(`http://127.0.0.1:${address.port}${path}`, {
|
||||
...init,
|
||||
headers: {
|
||||
Cookie: 'test_session=token',
|
||||
'Content-Type': 'application/json',
|
||||
...init.headers,
|
||||
},
|
||||
}),
|
||||
async close() {
|
||||
server.close();
|
||||
await once(server, 'close');
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
test('admin can read and update billing formula config', async (t) => {
|
||||
const app = await startApp();
|
||||
t.after(() => app.close());
|
||||
|
||||
const read = await app.request('/admin-api/billing/config');
|
||||
assert.equal(read.status, 200);
|
||||
const body = await read.json();
|
||||
assert.equal(body.config.marginMultiplier, 1.2);
|
||||
|
||||
const update = await app.request('/admin-api/billing/config', {
|
||||
method: 'PUT',
|
||||
body: JSON.stringify({
|
||||
config: {
|
||||
...body.config,
|
||||
marginMultiplier: 1.5,
|
||||
},
|
||||
}),
|
||||
});
|
||||
assert.equal(update.status, 200);
|
||||
const updated = await update.json();
|
||||
assert.equal(updated.config.marginMultiplier, 1.5);
|
||||
assert.equal(updated.source, 'admin-db');
|
||||
});
|
||||
|
||||
test('ordinary users cannot access billing formula config', async (t) => {
|
||||
const app = await startApp({ role: 'user' });
|
||||
t.after(() => app.close());
|
||||
|
||||
const read = await app.request('/admin-api/billing/config');
|
||||
assert.equal(read.status, 403);
|
||||
});
|
||||
@@ -39,6 +39,7 @@ export async function bootstrapAdminServices() {
|
||||
);
|
||||
const { createPersonalMemoryCandidateStore } = await importMemind('memory-v2-personal-store.mjs');
|
||||
const { createSkillRuntimeAdminConfigService } = await importMemind('skill-runtime-admin-config.mjs');
|
||||
const { createBillingAdminConfigService } = await importMemind('billing-admin-config.mjs');
|
||||
const { createAssetGatewayConfigService } = await importMemind('asset-gateway.mjs');
|
||||
const { ensureAssetGatewaySchema } = await importMemind('db.mjs');
|
||||
const { createWechatScheduleLlmConfigService } = await importMemind('wechat-schedule-llm-config.mjs');
|
||||
@@ -118,6 +119,10 @@ export async function bootstrapAdminServices() {
|
||||
env: process.env,
|
||||
h5Root,
|
||||
});
|
||||
const billingConfigService = createBillingAdminConfigService(pool, {
|
||||
env: process.env,
|
||||
});
|
||||
await billingConfigService.ensureSchema();
|
||||
const wechatScheduleLlmConfigService = createWechatScheduleLlmConfigService(pool, {
|
||||
env: process.env,
|
||||
});
|
||||
@@ -159,6 +164,13 @@ export async function bootstrapAdminServices() {
|
||||
const subscriptionService = createSubscriptionService(pool, {
|
||||
getPlanAsync: (planType) => planCatalogService.getPlan(planType),
|
||||
});
|
||||
subscriptionService._planCatalogService = planCatalogService;
|
||||
const { createPageTemplateCatalogService } = await importMemind('page-template-catalog.mjs');
|
||||
const templateCatalogService = createPageTemplateCatalogService(pool, {
|
||||
userAuth,
|
||||
h5Root,
|
||||
});
|
||||
await templateCatalogService.ensureReady();
|
||||
await ensureSystemTestAccountSchema(pool);
|
||||
const systemTestAccountService = createSystemTestAccountService(pool);
|
||||
|
||||
@@ -183,12 +195,14 @@ export async function bootstrapAdminServices() {
|
||||
orchestratorObservabilityService,
|
||||
personalMemoryCandidateStore,
|
||||
skillRuntimeConfigService,
|
||||
billingConfigService,
|
||||
wechatScheduleLlmConfigService,
|
||||
adminSystemTestService,
|
||||
systemTestAccountService,
|
||||
wordFilterService,
|
||||
planCatalogService,
|
||||
subscriptionService,
|
||||
templateCatalogService,
|
||||
USER_COOKIE,
|
||||
userLoginCookies,
|
||||
clearUserSessionCookie,
|
||||
|
||||
@@ -110,12 +110,14 @@ ready
|
||||
orchestratorObservabilityService,
|
||||
personalMemoryCandidateStore,
|
||||
skillRuntimeConfigService,
|
||||
billingConfigService,
|
||||
wechatScheduleLlmConfigService,
|
||||
adminSystemTestService,
|
||||
systemTestAccountService,
|
||||
wordFilterService,
|
||||
planCatalogService,
|
||||
subscriptionService,
|
||||
templateCatalogService,
|
||||
planSyncService,
|
||||
USER_COOKIE,
|
||||
userLoginCookies,
|
||||
@@ -138,12 +140,14 @@ ready
|
||||
orchestratorObservabilityService,
|
||||
personalMemoryCandidateStore,
|
||||
skillRuntimeConfigService,
|
||||
billingConfigService,
|
||||
wechatScheduleLlmConfigService,
|
||||
adminSystemTestService,
|
||||
systemTestAccountService,
|
||||
wordFilterService,
|
||||
planCatalogService,
|
||||
subscriptionService,
|
||||
templateCatalogService,
|
||||
planSyncService,
|
||||
USER_COOKIE,
|
||||
userLoginCookies,
|
||||
|
||||
@@ -156,7 +156,7 @@ export async function listUsagePaged(pool, query) {
|
||||
);
|
||||
const [rows] = await pool.query(
|
||||
`SELECT r.id, r.user_id, u.username, u.display_name, r.agent_session_id, r.request_id,
|
||||
r.input_tokens, r.output_tokens, r.cost_cents, r.balance_after_cents, r.created_at
|
||||
r.input_tokens, r.output_tokens, r.cost_cents, r.balance_after_cents, r.billing_source, r.created_at
|
||||
FROM h5_usage_records r
|
||||
JOIN h5_users u ON u.id = r.user_id
|
||||
${where}
|
||||
@@ -176,6 +176,7 @@ export async function listUsagePaged(pool, query) {
|
||||
outputTokens: Number(row.output_tokens),
|
||||
costCents: Number(row.cost_cents),
|
||||
balanceAfterCents: Number(row.balance_after_cents),
|
||||
billingSource: row.billing_source ?? 'wallet',
|
||||
createdAt: Number(row.created_at),
|
||||
})),
|
||||
total: Number(total),
|
||||
@@ -200,7 +201,7 @@ export async function listLedgerPaged(pool, query) {
|
||||
params,
|
||||
);
|
||||
const [rows] = await pool.query(
|
||||
`SELECT l.id, l.user_id, u.username, l.type, l.amount_cents, l.tokens,
|
||||
`SELECT l.id, l.user_id, u.username, u.display_name, l.type, l.amount_cents, l.tokens,
|
||||
l.session_id, l.note, l.created_at
|
||||
FROM h5_billing_ledger l
|
||||
JOIN h5_users u ON u.id = l.user_id
|
||||
@@ -214,6 +215,7 @@ export async function listLedgerPaged(pool, query) {
|
||||
id: Number(row.id),
|
||||
userId: row.user_id,
|
||||
username: row.username,
|
||||
displayName: row.display_name,
|
||||
type: row.type,
|
||||
amountCents: Number(row.amount_cents),
|
||||
tokens: Number(row.tokens),
|
||||
|
||||
@@ -0,0 +1,139 @@
|
||||
import crypto from 'node:crypto';
|
||||
|
||||
const TOKEN_TTL_MS = 55 * 60 * 1000;
|
||||
|
||||
let cachedToken = '';
|
||||
let cachedTokenAt = 0;
|
||||
let cachedBaseUrl = '';
|
||||
|
||||
function normalizeBaseUrl(value = '') {
|
||||
return String(value ?? '').trim().replace(/\/$/, '');
|
||||
}
|
||||
|
||||
function resolveUmamiCredentials(env = process.env, analyticsConfig = {}) {
|
||||
const baseUrl = normalizeBaseUrl(
|
||||
analyticsConfig?.analyticsUrl || env.UMAMI_URL || 'http://127.0.0.1:3100',
|
||||
);
|
||||
const username = String(env.UMAMI_SSO_USERNAME || env.UMAMI_ADMIN_USERNAME || 'admin').trim();
|
||||
const password = String(env.UMAMI_ADMIN_PASSWORD || '').trim();
|
||||
const sharedSecret = String(env.MEMIND_UMAMI_SSO_SECRET || '').trim();
|
||||
const websiteId = String(analyticsConfig?.websiteId || env.UMAMI_WEBSITE_ID || '').trim();
|
||||
return { baseUrl, username, password, sharedSecret, websiteId };
|
||||
}
|
||||
|
||||
async function loginUmamiViaSso({ baseUrl, username, sharedSecret }) {
|
||||
const encoded = Buffer.from(
|
||||
JSON.stringify({
|
||||
username,
|
||||
exp: Math.floor(Date.now() / 1000) + 60,
|
||||
nonce: crypto.randomUUID(),
|
||||
}),
|
||||
).toString('base64url');
|
||||
const signature = crypto.createHmac('sha256', sharedSecret).update(encoded).digest('base64url');
|
||||
const ticket = `${encoded}.${signature}`;
|
||||
const response = await fetch(
|
||||
`${baseUrl}/api/auth/memind?ticket=${encodeURIComponent(ticket)}`,
|
||||
{ signal: AbortSignal.timeout(10_000) },
|
||||
);
|
||||
const body = await response.json().catch(() => ({}));
|
||||
if (!response.ok) {
|
||||
throw new Error(body?.error?.message || body?.message || `Umami SSO 登录失败 (${response.status})`);
|
||||
}
|
||||
const token = String(body?.token ?? '').trim();
|
||||
if (!token) throw new Error('Umami SSO 登录响应缺少 token');
|
||||
return token;
|
||||
}
|
||||
|
||||
async function loginUmami({ baseUrl, username, password, sharedSecret }) {
|
||||
if (sharedSecret) {
|
||||
return loginUmamiViaSso({ baseUrl, username, sharedSecret });
|
||||
}
|
||||
if (!password) {
|
||||
throw new Error('未配置 UMAMI_ADMIN_PASSWORD 或 MEMIND_UMAMI_SSO_SECRET,无法拉取 SEO/GEO 页面统计');
|
||||
}
|
||||
const response = await fetch(`${baseUrl}/api/auth/login`, {
|
||||
method: 'POST',
|
||||
headers: { 'content-type': 'application/json' },
|
||||
body: JSON.stringify({ username, password }),
|
||||
signal: AbortSignal.timeout(10_000),
|
||||
});
|
||||
const body = await response.json().catch(() => ({}));
|
||||
if (!response.ok) {
|
||||
throw new Error(body?.message || `Umami 登录失败 (${response.status})`);
|
||||
}
|
||||
const token = String(body?.token ?? '').trim();
|
||||
if (!token) throw new Error('Umami 登录响应缺少 token');
|
||||
return token;
|
||||
}
|
||||
|
||||
async function getUmamiAuthToken(credentials) {
|
||||
const now = Date.now();
|
||||
if (
|
||||
cachedToken &&
|
||||
cachedBaseUrl === credentials.baseUrl &&
|
||||
now - cachedTokenAt < TOKEN_TTL_MS
|
||||
) {
|
||||
return cachedToken;
|
||||
}
|
||||
cachedToken = await loginUmami(credentials);
|
||||
cachedTokenAt = now;
|
||||
cachedBaseUrl = credentials.baseUrl;
|
||||
return cachedToken;
|
||||
}
|
||||
|
||||
export async function fetchMemindDiscoveryPages(
|
||||
query = {},
|
||||
{ env = process.env, analyticsConfig = {} } = {},
|
||||
) {
|
||||
const credentials = resolveUmamiCredentials(env, analyticsConfig);
|
||||
if (!credentials.websiteId) {
|
||||
throw new Error('未配置 Umami Website ID');
|
||||
}
|
||||
const channel = String(query.discoveryChannel ?? query.channel ?? '').trim().toLowerCase();
|
||||
if (channel !== 'seo' && channel !== 'geo') {
|
||||
throw new Error('discoveryChannel 必须是 seo 或 geo');
|
||||
}
|
||||
|
||||
const params = new URLSearchParams();
|
||||
params.set('discoveryChannel', channel);
|
||||
params.set('page', String(Math.max(Number(query.page) || 1, 1)));
|
||||
params.set('pageSize', String(Math.min(Math.max(Number(query.pageSize) || 10, 1), 100)));
|
||||
params.set('sortBy', String(query.sortBy || 'views'));
|
||||
params.set('sortOrder', String(query.sortOrder || 'desc'));
|
||||
if (query.startAt) params.set('startAt', String(query.startAt));
|
||||
if (query.endAt) params.set('endAt', String(query.endAt));
|
||||
if (query.timezone) params.set('timezone', String(query.timezone));
|
||||
if (query.generatedStartAt) params.set('generatedStartAt', String(query.generatedStartAt));
|
||||
if (query.generatedEndAt) params.set('generatedEndAt', String(query.generatedEndAt));
|
||||
|
||||
const token = await getUmamiAuthToken(credentials);
|
||||
const response = await fetch(
|
||||
`${credentials.baseUrl}/api/websites/${encodeURIComponent(credentials.websiteId)}/memind-pages?${params.toString()}`,
|
||||
{
|
||||
headers: {
|
||||
authorization: `Bearer ${token}`,
|
||||
'content-type': 'application/json',
|
||||
},
|
||||
signal: AbortSignal.timeout(15_000),
|
||||
},
|
||||
);
|
||||
const body = await response.json().catch(() => ({}));
|
||||
if (!response.ok) {
|
||||
throw new Error(body?.message || `Umami memind-pages 请求失败 (${response.status})`);
|
||||
}
|
||||
return {
|
||||
discoveryChannel: channel,
|
||||
...body,
|
||||
};
|
||||
}
|
||||
|
||||
export function resolvePublicPageUrl(row = {}, fallbackHost = 'm.tkmind.cn') {
|
||||
const exact = String(row.pageUrl ?? '').trim();
|
||||
if (/^https?:\/\//i.test(exact)) return exact.split('#')[0];
|
||||
const hostname = String(row.hostname ?? '').trim();
|
||||
if (hostname === '127.0.0.1' || hostname === 'localhost') {
|
||||
return `http://${hostname}:8081${row.urlPath}`;
|
||||
}
|
||||
const host = hostname || fallbackHost;
|
||||
return `https://${host}${row.urlPath}`;
|
||||
}
|
||||
@@ -0,0 +1,34 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import test from 'node:test';
|
||||
|
||||
import { fetchMemindDiscoveryPages } from './umami-analytics.mjs';
|
||||
|
||||
test('fetchMemindDiscoveryPages rejects invalid discoveryChannel', async () => {
|
||||
await assert.rejects(
|
||||
() => fetchMemindDiscoveryPages(
|
||||
{ discoveryChannel: 'direct' },
|
||||
{ env: { UMAMI_ADMIN_PASSWORD: 'secret' }, analyticsConfig: { websiteId: 'site-1' } },
|
||||
),
|
||||
/discoveryChannel 必须是 seo 或 geo/,
|
||||
);
|
||||
});
|
||||
|
||||
test('fetchMemindDiscoveryPages rejects missing website id', async () => {
|
||||
await assert.rejects(
|
||||
() => fetchMemindDiscoveryPages(
|
||||
{ discoveryChannel: 'seo', startAt: '1', endAt: '2' },
|
||||
{ env: { UMAMI_ADMIN_PASSWORD: 'secret' }, analyticsConfig: {} },
|
||||
),
|
||||
/未配置 Umami Website ID/,
|
||||
);
|
||||
});
|
||||
|
||||
test('fetchMemindDiscoveryPages rejects missing auth credentials', async () => {
|
||||
await assert.rejects(
|
||||
() => fetchMemindDiscoveryPages(
|
||||
{ discoveryChannel: 'geo', startAt: '1', endAt: '2' },
|
||||
{ env: {}, analyticsConfig: { websiteId: 'site-1' } },
|
||||
),
|
||||
/未配置 UMAMI_ADMIN_PASSWORD 或 MEMIND_UMAMI_SSO_SECRET/,
|
||||
);
|
||||
});
|
||||
@@ -5,6 +5,7 @@ import { AdminLayout } from './admin/AdminLayout';
|
||||
import { BillingPage } from './admin/pages/BillingPage';
|
||||
import { CapabilitiesPage } from './admin/pages/CapabilitiesPage';
|
||||
import { DashboardPage } from './admin/pages/DashboardPage';
|
||||
import { ImageQuotaPage } from './admin/pages/ImageQuotaPage';
|
||||
import { PoliciesPage } from './admin/pages/PoliciesPage';
|
||||
import { MindSpacePage } from './admin/pages/MindSpacePage';
|
||||
import { MemoryV2Page } from './admin/pages/MemoryV2Page';
|
||||
@@ -12,12 +13,14 @@ import { MindSearchPage } from './admin/pages/MindSearchPage';
|
||||
import { ProvidersPage } from './admin/pages/ProvidersPage';
|
||||
import { SkillsPage } from './admin/pages/SkillsPage';
|
||||
import { SystemTestsPage } from './admin/pages/SystemTestsPage';
|
||||
import { TemplateCatalogPage } from './admin/pages/TemplateCatalogPage';
|
||||
import { UserDetailPage } from './admin/pages/UserDetailPage';
|
||||
import { UsersPage } from './admin/pages/UsersPage';
|
||||
import { WechatPage } from './admin/pages/WechatPage';
|
||||
import { AssetGatewayPage } from './admin/pages/AssetGatewayPage';
|
||||
import { BlockedWordsPage } from './admin/pages/BlockedWordsPage';
|
||||
import { AnalyticsConfigPage } from './admin/pages/AnalyticsConfigPage';
|
||||
import { SeoGeoAnalyticsPage } from './admin/pages/SeoGeoAnalyticsPage';
|
||||
import { SkillRuntimePage } from './admin/pages/SkillRuntimePage';
|
||||
import { OrchestratorPage } from './admin/pages/OrchestratorPage';
|
||||
import { defaultHomePath } from './lib/routes';
|
||||
@@ -119,12 +122,15 @@ function AdminApp({ user, onLogout }: { user: PortalUser; onLogout: () => void }
|
||||
<Route path="users" element={<UsersPage />} />
|
||||
<Route path="users/:userId" element={<UserDetailPage />} />
|
||||
<Route path="billing/*" element={<BillingPage />} />
|
||||
<Route path="image-quota" element={<ImageQuotaPage />} />
|
||||
<Route path="template-catalog" element={<TemplateCatalogPage />} />
|
||||
<Route path="capabilities" element={<CapabilitiesPage />} />
|
||||
<Route path="skills" element={<SkillsPage />} />
|
||||
<Route path="system-tests" element={<SystemTestsPage />} />
|
||||
<Route path="policies" element={<PoliciesPage />} />
|
||||
<Route path="mindspace" element={<MindSpacePage />} />
|
||||
<Route path="analytics" element={<AnalyticsConfigPage />} />
|
||||
<Route path="analytics/seo-geo" element={<SeoGeoAnalyticsPage />} />
|
||||
<Route path="memory-v2" element={<MemoryV2Page />} />
|
||||
<Route path="mindsearch" element={<MindSearchPage />} />
|
||||
<Route path="skill-runtime" element={<SkillRuntimePage />} />
|
||||
@@ -170,11 +176,14 @@ function loginRedirectPath(pathname: string, role: string | undefined) {
|
||||
if (
|
||||
pathname.startsWith('/users')
|
||||
|| pathname.startsWith('/billing')
|
||||
|| pathname.startsWith('/image-quota')
|
||||
|| pathname.startsWith('/template-catalog')
|
||||
|| pathname.startsWith('/capabilities')
|
||||
|| pathname.startsWith('/skills')
|
||||
|| pathname.startsWith('/system-tests')
|
||||
|| pathname.startsWith('/policies')
|
||||
|| pathname.startsWith('/mindspace')
|
||||
|| pathname.startsWith('/analytics')
|
||||
|| pathname.startsWith('/memory-v2')
|
||||
|| pathname.startsWith('/skill-runtime')
|
||||
|| pathname.startsWith('/orchestrator')
|
||||
|
||||
@@ -21,7 +21,11 @@ const NAV_SECTIONS: NavSection[] = [
|
||||
},
|
||||
{
|
||||
label: '计费',
|
||||
items: [{ to: '/billing', label: '计费中心', end: false }],
|
||||
items: [
|
||||
{ to: '/billing', label: '计费中心', end: false },
|
||||
{ to: '/image-quota', label: '图片额度' },
|
||||
{ to: '/template-catalog', label: '页面模板' },
|
||||
],
|
||||
},
|
||||
{
|
||||
label: '平台配置',
|
||||
@@ -29,6 +33,7 @@ const NAV_SECTIONS: NavSection[] = [
|
||||
{ to: '/wechat', label: '服务号' },
|
||||
{ to: '/mindspace', label: 'MindSpace 配置' },
|
||||
{ to: '/analytics', label: 'Analytics 配置' },
|
||||
{ to: '/analytics/seo-geo', label: 'SEO / GEO 流量' },
|
||||
{ to: '/memory-v2', label: 'Memory V2' },
|
||||
{ to: '/mindsearch', label: 'MindSearch' },
|
||||
{ to: '/skill-runtime', label: 'Skill Runtime' },
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { useCallback, useEffect, useState, type FormEvent } from 'react';
|
||||
import { getMindSpaceAdminConfig, getRybbitSsoUrl, getUmamiSsoUrl, updateMindSpaceAdminConfig } from '../../api/client';
|
||||
import { Link } from 'react-router-dom';
|
||||
import { getMindSpaceAdminConfig, getUmamiSsoUrl, updateMindSpaceAdminConfig } from '../../api/client';
|
||||
|
||||
const initial = {
|
||||
enabled: false,
|
||||
@@ -17,7 +18,6 @@ export function AnalyticsConfigPage() {
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [message, setMessage] = useState<string | null>(null);
|
||||
const [openingUmami, setOpeningUmami] = useState(false);
|
||||
const [openingRybbit, setOpeningRybbit] = useState(false);
|
||||
|
||||
const load = useCallback(async () => {
|
||||
setLoading(true);
|
||||
@@ -63,28 +63,15 @@ export function AnalyticsConfigPage() {
|
||||
}
|
||||
};
|
||||
|
||||
const openRybbit = async () => {
|
||||
setOpeningRybbit(true);
|
||||
setError(null);
|
||||
try {
|
||||
window.location.assign(await getRybbitSsoUrl());
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : '无法打开 Rybbit');
|
||||
setOpeningRybbit(false);
|
||||
}
|
||||
};
|
||||
|
||||
return <div className="admin-page">
|
||||
<div className="admin-page-head"><h2>Analytics 配置</h2><p className="muted">进入统计后台,或配置 Memind 生成页面的 Umami 统计。</p></div>
|
||||
<div className="admin-page-head"><h2>Analytics 配置</h2><p className="muted">配置 Umami 并进入统计后台。Rybbit 已退役,见 Memind 文档 analytics-platform.md。</p></div>
|
||||
{error && <p className="banner banner-error">{error}</p>}
|
||||
{message && <p className="banner banner-info">{message}</p>}
|
||||
<section className="admin-card">
|
||||
<h2>Rybbit 行为分析</h2>
|
||||
<p className="muted">查看页面访问、用户会话、停留时间、点击和会话回放等行为明细。</p>
|
||||
<h2>SEO / GEO 页面流量</h2>
|
||||
<p className="muted">按页面查看搜索引擎与生成式引擎来源访问,表格口径与 Umami「所有生成页面明细」一致。</p>
|
||||
<div className="admin-actions">
|
||||
<button type="button" className="send-btn" onClick={() => void openRybbit()} disabled={openingRybbit}>
|
||||
{openingRybbit ? '正在进入 Rybbit...' : '打开 Rybbit 分析后台'}
|
||||
</button>
|
||||
<Link to="/analytics/seo-geo" className="send-btn">打开 SEO / GEO 流量看板</Link>
|
||||
</div>
|
||||
</section>
|
||||
<section className="admin-card">
|
||||
@@ -96,6 +83,7 @@ export function AnalyticsConfigPage() {
|
||||
<label className="admin-form-row"><span>Umami 地址</span><input value={config.analyticsUrl} disabled={loading || busy} onChange={(e) => setConfig({ ...config, analyticsUrl: e.target.value })} placeholder="http://127.0.0.1:3100" /></label>
|
||||
<label className="admin-form-row"><span>统计域名</span><input value={config.domains} disabled={loading || busy} onChange={(e) => setConfig({ ...config, domains: e.target.value })} placeholder="127.0.0.1,localhost" /></label>
|
||||
<label className="admin-form-row"><span>匿名化密钥 {config.idSecretConfigured ? '(已配置,留空保持不变)' : ''}</span><input type="password" value={secret} disabled={loading || busy} onChange={(e) => setSecret(e.target.value)} placeholder={config.idSecretConfigured ? '留空保持当前密钥' : '输入本地随机密钥'} autoComplete="new-password" /></label>
|
||||
<p className="muted">SEO/GEO 看板通过 <code>MEMIND_UMAMI_SSO_SECRET</code>(推荐,与 105 Umami 一致)或 <code>UMAMI_ADMIN_PASSWORD</code> 访问 Umami API;生产 Umami 若开启 SSO-only 则只需前者。</p>
|
||||
<div className="admin-actions"><button type="submit" className="send-btn" disabled={busy || loading}>{busy ? '保存中...' : '保存配置'}</button><button type="button" className="ghost-btn" onClick={() => void load()} disabled={busy}>重新加载</button></div>
|
||||
</form>
|
||||
</section>
|
||||
|
||||
@@ -6,6 +6,7 @@ import {
|
||||
deleteSubscriptionPlan,
|
||||
getAdminUsageStats,
|
||||
getAdminUsageSummary,
|
||||
getBillingConfig,
|
||||
grantUserSubscription,
|
||||
listAdminLedger,
|
||||
listAdminSubscriptions,
|
||||
@@ -13,10 +14,21 @@ import {
|
||||
listSubscriptionPlans,
|
||||
rechargeUser,
|
||||
syncSubscriptionPlansToProduction,
|
||||
updateBillingConfig,
|
||||
updateSubscriptionPlan,
|
||||
} from '../../api/client';
|
||||
import type { PagedResult } from '../../api/client';
|
||||
import type { AdminSubscription, AdminUserRow, LedgerEntry, PlanDefinition, UsageRecord, UsageStatsResult, UsageSummaryResult, UsageTotals } from '../../types';
|
||||
import type {
|
||||
AdminSubscription,
|
||||
AdminUserRow,
|
||||
BillingAdminConfig,
|
||||
LedgerEntry,
|
||||
PlanDefinition,
|
||||
UsageRecord,
|
||||
UsageStatsResult,
|
||||
UsageSummaryResult,
|
||||
UsageTotals,
|
||||
} from '../../types';
|
||||
import { Pagination } from '../../components/Pagination';
|
||||
import {
|
||||
dateRangeToUnix,
|
||||
@@ -116,13 +128,14 @@ function UserCombobox({
|
||||
);
|
||||
}
|
||||
|
||||
type TabKey = 'recharge' | 'usage' | 'ledger' | 'subscriptions';
|
||||
type TabKey = 'recharge' | 'usage' | 'ledger' | 'subscriptions' | 'formula';
|
||||
|
||||
const TABS: { key: TabKey; label: string }[] = [
|
||||
{ key: 'subscriptions', label: '订阅记录' },
|
||||
{ key: 'recharge', label: '充值' },
|
||||
{ key: 'usage', label: '用量记录' },
|
||||
{ key: 'ledger', label: '资金流水' },
|
||||
{ key: 'formula', label: '计量公式' },
|
||||
];
|
||||
|
||||
const TAB_PATHS: Record<TabKey, string> = {
|
||||
@@ -130,6 +143,14 @@ const TAB_PATHS: Record<TabKey, string> = {
|
||||
recharge: '/billing/recharge',
|
||||
usage: '/billing/usage',
|
||||
ledger: '/billing/ledger',
|
||||
formula: '/billing/formula',
|
||||
};
|
||||
|
||||
const SOURCE_LABELS: Record<string, string> = {
|
||||
'admin-db': '后台配置',
|
||||
env: '环境变量',
|
||||
'env-override': '环境变量锁定',
|
||||
default: '默认值',
|
||||
};
|
||||
|
||||
function tabFromPath(pathname: string): TabKey {
|
||||
@@ -137,10 +158,241 @@ function tabFromPath(pathname: string): TabKey {
|
||||
if (suffix === 'usage' || suffix.startsWith('usage/')) return 'usage';
|
||||
if (suffix === 'recharge') return 'recharge';
|
||||
if (suffix === 'ledger') return 'ledger';
|
||||
if (suffix === 'formula') return 'formula';
|
||||
if (suffix === 'subscriptions') return 'subscriptions';
|
||||
return 'subscriptions';
|
||||
}
|
||||
|
||||
const DEFAULT_BILLING_FORMULA: BillingAdminConfig = {
|
||||
useBackendCost: true,
|
||||
usdCnyRate: 7.2,
|
||||
marginMultiplier: 1.2,
|
||||
inputCentsPer1k: 2,
|
||||
outputCentsPer1k: 6,
|
||||
minBillCents: 1,
|
||||
costEstimateFromTokens: true,
|
||||
costEstimateInputUsdPer1M: 0.27,
|
||||
costEstimateOutputUsdPer1M: 1.1,
|
||||
};
|
||||
|
||||
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 [envLocked, setEnvLocked] = useState(false);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [saving, setSaving] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [message, setMessage] = useState<string | null>(null);
|
||||
|
||||
const load = useCallback(async () => {
|
||||
setLoading(true);
|
||||
setError(null);
|
||||
try {
|
||||
const result = await getBillingConfig();
|
||||
setDraft(result.config);
|
||||
setSource(result.source ?? 'default');
|
||||
setUpdatedAt(result.updatedAt ?? null);
|
||||
setFormula(result.formula ?? '');
|
||||
setEnvLocked(Boolean(result.envOverrideActive));
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : '加载计量公式失败');
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
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();
|
||||
if (envLocked) {
|
||||
setError('当前环境已锁定为仅读 env(H5_BILLING_CONFIG_SOURCE=env),无法保存。');
|
||||
return;
|
||||
}
|
||||
if (draft.marginMultiplier <= 0 || draft.usdCnyRate <= 0) {
|
||||
setError('汇率与毛利倍数必须大于 0。');
|
||||
return;
|
||||
}
|
||||
if (
|
||||
draft.useBackendCost
|
||||
&& !window.confirm(
|
||||
`确认保存成本模式扣费?\n最终扣费 = 上游成本(USD) × ${draft.usdCnyRate} × ${draft.marginMultiplier}`,
|
||||
)
|
||||
) {
|
||||
return;
|
||||
}
|
||||
setSaving(true);
|
||||
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 ?? '');
|
||||
setEnvLocked(Boolean(result.envOverrideActive));
|
||||
setMessage('计量公式已保存。Portal 扣费会在数秒内读取新配置,无需重启。');
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : '保存失败');
|
||||
} finally {
|
||||
setSaving(false);
|
||||
}
|
||||
};
|
||||
|
||||
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>
|
||||
);
|
||||
}
|
||||
|
||||
const PAGE_SIZE = 20;
|
||||
|
||||
function RechargeTab() {
|
||||
@@ -198,6 +450,11 @@ function UsageRecordsTable({
|
||||
result: PagedResult<UsageRecord>;
|
||||
onPage: (page: number) => void;
|
||||
}) {
|
||||
const formatBillingSource = (row: UsageRecord) => {
|
||||
if (row.billingSource === 'subscription') return '套餐额度';
|
||||
return `¥${formatYuan(row.costCents)}`;
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className="admin-table-wrap">
|
||||
@@ -222,8 +479,10 @@ function UsageRecordsTable({
|
||||
</td>
|
||||
<td className="billing-num">{row.inputTokens.toLocaleString()}</td>
|
||||
<td className="billing-num">{row.outputTokens.toLocaleString()}</td>
|
||||
<td className="billing-num">¥{formatYuan(row.costCents)}</td>
|
||||
<td className="billing-num">¥{formatYuan(row.balanceAfterCents)}</td>
|
||||
<td className="billing-num">{formatBillingSource(row)}</td>
|
||||
<td className="billing-num">
|
||||
{row.billingSource === 'subscription' ? '—' : `¥${formatYuan(row.balanceAfterCents)}`}
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
@@ -839,7 +1098,10 @@ function LedgerTab() {
|
||||
{result.items.map((row) => (
|
||||
<tr key={row.id}>
|
||||
<td className="billing-time">{formatTime(row.createdAt)}</td>
|
||||
<td>@{row.username}</td>
|
||||
<td>
|
||||
<span>{row.displayName || row.username}</span>
|
||||
<span className="muted"> @{row.username}</span>
|
||||
</td>
|
||||
<td><span className={`ledger-type-tag ledger-type-${row.type}`}>{TYPE_LABEL[row.type] ?? row.type}</span></td>
|
||||
<td className={`billing-num ${row.amountCents < 0 ? 'text-error' : 'text-income'}`}>
|
||||
{row.amountCents >= 0 ? '+' : ''}¥{formatYuan(Math.abs(row.amountCents))}
|
||||
@@ -1450,7 +1712,7 @@ export function BillingPage() {
|
||||
<div className="admin-page">
|
||||
<div className="admin-page-head">
|
||||
<h2>计费中心</h2>
|
||||
<p className="muted">充值、用量与资金流水</p>
|
||||
<p className="muted">充值、用量、资金流水与计量公式</p>
|
||||
</div>
|
||||
<div className="admin-tabs" role="tablist">
|
||||
{TABS.map((tab) => (
|
||||
@@ -1466,6 +1728,7 @@ export function BillingPage() {
|
||||
{activeTab === 'usage' && <UsageTab />}
|
||||
{activeTab === 'ledger' && <LedgerTab />}
|
||||
{activeTab === 'subscriptions' && <SubscriptionsTab />}
|
||||
{activeTab === 'formula' && <FormulaTab />}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -238,7 +238,9 @@ export function DashboardPage() {
|
||||
<td>
|
||||
in {row.inputTokens} / out {row.outputTokens}
|
||||
</td>
|
||||
<td>¥{formatYuan(row.costCents)}</td>
|
||||
<td>
|
||||
{row.billingSource === 'subscription' ? '套餐额度' : `¥${formatYuan(row.costCents)}`}
|
||||
</td>
|
||||
</tr>
|
||||
))
|
||||
)}
|
||||
|
||||
@@ -0,0 +1,285 @@
|
||||
import { useEffect, useState } from 'react';
|
||||
import {
|
||||
fetchImageQuotaConfig,
|
||||
fetchImageQuotaLedger,
|
||||
patchImageQuotaPlan,
|
||||
} from '../../api/client';
|
||||
import type { ImageQuotaLedgerEntry, PlanDefinition } from '../../types';
|
||||
import { Pagination } from '../../components/Pagination';
|
||||
import { formatTime } from '../utils/format';
|
||||
|
||||
type Tab = 'plans' | 'ledger';
|
||||
|
||||
function fmtQuota(value: number | null | undefined, unlimited = false) {
|
||||
if (unlimited || value == null) return '无限';
|
||||
return String(value);
|
||||
}
|
||||
|
||||
const REASON_LABELS: Record<string, string> = {
|
||||
admin_grant: '管理员充值',
|
||||
admin_adjust: '管理员调整',
|
||||
consume: '生图消费',
|
||||
period_reset: '周期重置',
|
||||
plan_change: '套餐变更',
|
||||
};
|
||||
|
||||
export function ImageQuotaPage() {
|
||||
const [tab, setTab] = useState<Tab>('plans');
|
||||
|
||||
return (
|
||||
<div className="admin-page">
|
||||
<div className="admin-page-head">
|
||||
<h2>图片生成额度</h2>
|
||||
<p className="muted">
|
||||
用户有剩余额度时才能调用 image_make 生图;套餐默认额度中 0 表示无限。用户级充值请在「用户管理 → 用户详情」中操作。
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="admin-card" style={{ display: 'flex', gap: 8, flexWrap: 'wrap' }}>
|
||||
<button
|
||||
type="button"
|
||||
className={tab === 'plans' ? 'send-btn' : 'ghost-btn'}
|
||||
onClick={() => setTab('plans')}
|
||||
>
|
||||
套餐默认额度
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className={tab === 'ledger' ? 'send-btn' : 'ghost-btn'}
|
||||
onClick={() => setTab('ledger')}
|
||||
>
|
||||
额度流水
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{tab === 'plans' ? <PlansTab /> : <LedgerTab />}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function PlansTab() {
|
||||
const [plans, setPlans] = useState<PlanDefinition[]>([]);
|
||||
const [drafts, setDrafts] = useState<Record<string, string>>({});
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [busyPlan, setBusyPlan] = useState<string | null>(null);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [message, setMessage] = useState<string | null>(null);
|
||||
|
||||
const load = async () => {
|
||||
setLoading(true);
|
||||
setError(null);
|
||||
try {
|
||||
const result = await fetchImageQuotaConfig();
|
||||
setPlans(result.plans);
|
||||
setDrafts(Object.fromEntries(result.plans.map((plan) => [plan.planType, String(plan.periodImages)])));
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : '加载失败');
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
void load();
|
||||
}, []);
|
||||
|
||||
const savePlan = async (planType: string) => {
|
||||
const raw = drafts[planType];
|
||||
const periodImages = Math.floor(Number(raw));
|
||||
if (!Number.isFinite(periodImages) || periodImages < 0) {
|
||||
setError('额度必须是非负整数');
|
||||
return;
|
||||
}
|
||||
setBusyPlan(planType);
|
||||
setError(null);
|
||||
setMessage(null);
|
||||
try {
|
||||
await patchImageQuotaPlan(planType, periodImages);
|
||||
setMessage(`已更新 ${planType} 默认图片额度`);
|
||||
await load();
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : '保存失败');
|
||||
} finally {
|
||||
setBusyPlan(null);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
{error && <p className="banner banner-error">{error}</p>}
|
||||
{message && <p className="banner banner-info">{message}</p>}
|
||||
|
||||
<section className="admin-card">
|
||||
{loading && plans.length === 0 ? (
|
||||
<p className="muted">加载中…</p>
|
||||
) : (
|
||||
<div className="admin-table-wrap">
|
||||
<table className="admin-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>套餐</th>
|
||||
<th>标识</th>
|
||||
<th>月图片额度</th>
|
||||
<th>月 Token</th>
|
||||
<th>操作</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{plans.map((plan) => (
|
||||
<tr key={plan.planType}>
|
||||
<td>{plan.name}</td>
|
||||
<td>
|
||||
<code className="mono">{plan.planType}</code>
|
||||
</td>
|
||||
<td>
|
||||
<input
|
||||
type="number"
|
||||
min="0"
|
||||
step="1"
|
||||
value={drafts[plan.planType] ?? String(plan.periodImages)}
|
||||
onChange={(e) => setDrafts((prev) => ({ ...prev, [plan.planType]: e.target.value }))}
|
||||
style={{ width: 120 }}
|
||||
/>
|
||||
</td>
|
||||
<td className="muted">
|
||||
{plan.periodTokens === 0 ? '无限' : plan.periodTokens.toLocaleString('zh-CN')}
|
||||
</td>
|
||||
<td>
|
||||
<button
|
||||
type="button"
|
||||
className="ghost-btn"
|
||||
disabled={loading || busyPlan === plan.planType}
|
||||
onClick={() => void savePlan(plan.planType)}
|
||||
>
|
||||
{busyPlan === plan.planType ? '保存中…' : '保存'}
|
||||
</button>
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
{plans.length === 0 && !loading ? (
|
||||
<tr>
|
||||
<td colSpan={5} className="muted" style={{ textAlign: 'center', padding: 24 }}>
|
||||
暂无套餐配置
|
||||
</td>
|
||||
</tr>
|
||||
) : null}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
)}
|
||||
</section>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
function LedgerTab() {
|
||||
const [entries, setEntries] = useState<ImageQuotaLedgerEntry[]>([]);
|
||||
const [page, setPage] = useState(1);
|
||||
const [totalPages, setTotalPages] = useState(1);
|
||||
const [total, setTotal] = useState(0);
|
||||
const [userId, setUserId] = useState('');
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [loading, setLoading] = useState(false);
|
||||
|
||||
const load = async (p = 1) => {
|
||||
setLoading(true);
|
||||
setError(null);
|
||||
try {
|
||||
const result = await fetchImageQuotaLedger({
|
||||
page: p,
|
||||
pageSize: 30,
|
||||
userId: userId.trim() || undefined,
|
||||
});
|
||||
setEntries(result.entries);
|
||||
setTotal(result.total);
|
||||
setTotalPages(result.totalPages);
|
||||
setPage(p);
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : '加载失败');
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
void load(1);
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<>
|
||||
<section className="admin-card">
|
||||
<form
|
||||
className="admin-form"
|
||||
onSubmit={(e) => {
|
||||
e.preventDefault();
|
||||
void load(1);
|
||||
}}
|
||||
>
|
||||
<input
|
||||
type="search"
|
||||
placeholder="按 userId 过滤"
|
||||
value={userId}
|
||||
onChange={(e) => setUserId(e.target.value)}
|
||||
/>
|
||||
<button type="submit" className="send-btn" disabled={loading}>
|
||||
搜索
|
||||
</button>
|
||||
</form>
|
||||
</section>
|
||||
|
||||
{error && <p className="banner banner-error">{error}</p>}
|
||||
|
||||
<section className="admin-card">
|
||||
{loading && entries.length === 0 ? (
|
||||
<p className="muted">加载中…</p>
|
||||
) : (
|
||||
<div className="admin-table-wrap">
|
||||
<table className="admin-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>时间</th>
|
||||
<th>用户</th>
|
||||
<th>变动</th>
|
||||
<th>剩余</th>
|
||||
<th>原因</th>
|
||||
<th>备注</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{entries.map((entry) => (
|
||||
<tr key={entry.id}>
|
||||
<td className="muted">{formatTime(entry.createdAt)}</td>
|
||||
<td>
|
||||
<div>{entry.displayName || entry.username || '—'}</div>
|
||||
<code className="mono muted">{entry.userId}</code>
|
||||
</td>
|
||||
<td style={{ color: entry.delta >= 0 ? 'var(--ok)' : 'var(--danger)' }}>
|
||||
{entry.delta >= 0 ? `+${entry.delta}` : entry.delta}
|
||||
</td>
|
||||
<td>{fmtQuota(entry.balanceAfter)}</td>
|
||||
<td>{REASON_LABELS[entry.reason] ?? entry.reason}</td>
|
||||
<td className="muted">{entry.note || entry.refId || '—'}</td>
|
||||
</tr>
|
||||
))}
|
||||
{entries.length === 0 && !loading ? (
|
||||
<tr>
|
||||
<td colSpan={6} className="muted" style={{ textAlign: 'center', padding: 24 }}>
|
||||
暂无流水
|
||||
</td>
|
||||
</tr>
|
||||
) : null}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
)}
|
||||
<Pagination
|
||||
page={page}
|
||||
totalPages={totalPages}
|
||||
total={total}
|
||||
pageSize={30}
|
||||
onChange={(p) => void load(p)}
|
||||
/>
|
||||
</section>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -16,6 +16,7 @@ import type {
|
||||
MemoryV2RuntimeStatusResponse,
|
||||
PersonalMemoryCandidateListResponse,
|
||||
} from '../../types';
|
||||
import { isMemoryV2SectionEnabled } from './memory-v2-config';
|
||||
|
||||
type BackendKey =
|
||||
| 'pgvector'
|
||||
@@ -923,7 +924,8 @@ export function MemoryV2Page() {
|
||||
{CAPABILITIES.map((capability, index) => {
|
||||
const section = draft[capability.key];
|
||||
const currentSection = current[capability.key];
|
||||
const enabled = Boolean(section.enabled);
|
||||
const enabled = isMemoryV2SectionEnabled(capability.key, section);
|
||||
const savedEnabled = isMemoryV2SectionEnabled(capability.key, currentSection);
|
||||
const accent = BACKEND_ACCENTS[index % BACKEND_ACCENTS.length];
|
||||
return (
|
||||
<section
|
||||
@@ -1015,8 +1017,8 @@ export function MemoryV2Page() {
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
<span className={`asset-status ${Boolean(currentSection.enabled) ? 'is-on' : ''}`}>
|
||||
当前保存:{Boolean(currentSection.enabled) ? '启用' : '关闭'}
|
||||
<span className={`asset-status ${savedEnabled ? 'is-on' : ''}`}>
|
||||
当前保存:{savedEnabled ? '启用' : '关闭'}
|
||||
</span>
|
||||
<button
|
||||
type="button"
|
||||
|
||||
@@ -1,14 +1,37 @@
|
||||
import { useCallback, useEffect, useState, type FormEvent } from 'react';
|
||||
import { getMindSpaceAdminConfig, updateMindSpaceAdminConfig } from '../../api/client';
|
||||
import type { MindSpaceAdminConfig } from '../../types';
|
||||
import type { MindSpaceAdminConfig, MindSpaceSeoGeoConfig } from '../../types';
|
||||
|
||||
function defaultSeoGeoConfig(): MindSpaceSeoGeoConfig {
|
||||
return {
|
||||
enabled: false,
|
||||
seo: {
|
||||
enabled: false,
|
||||
canonical: true,
|
||||
sitemap: false,
|
||||
robotsTxt: false,
|
||||
baiduPush: false,
|
||||
},
|
||||
geo: {
|
||||
enabled: false,
|
||||
jsonLd: false,
|
||||
llmsTxt: false,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
function safeConfig(config: MindSpaceAdminConfig | null): MindSpaceAdminConfig {
|
||||
return config ?? { publicPageLimit: 10, analytics: { enabled: false, websiteId: '', analyticsUrl: 'http://127.0.0.1:3100', domains: '127.0.0.1,localhost', idSecretConfigured: false } };
|
||||
return config ?? {
|
||||
publicPageLimit: 10,
|
||||
seoGeo: defaultSeoGeoConfig(),
|
||||
analytics: { enabled: false, websiteId: '', analyticsUrl: 'http://127.0.0.1:3100', domains: '127.0.0.1,localhost', idSecretConfigured: false },
|
||||
};
|
||||
}
|
||||
|
||||
export function MindSpacePage() {
|
||||
const [config, setConfig] = useState<MindSpaceAdminConfig | null>(null);
|
||||
const [limit, setLimit] = useState('10');
|
||||
const [seoGeo, setSeoGeo] = useState<MindSpaceSeoGeoConfig>(defaultSeoGeoConfig());
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [busy, setBusy] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
@@ -22,6 +45,7 @@ export function MindSpacePage() {
|
||||
const nextConfig = await getMindSpaceAdminConfig();
|
||||
setConfig(nextConfig);
|
||||
setLimit(String(nextConfig.publicPageLimit));
|
||||
setSeoGeo(nextConfig.seoGeo ?? defaultSeoGeoConfig());
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : '加载 MindSpace 配置失败');
|
||||
} finally {
|
||||
@@ -39,10 +63,14 @@ export function MindSpacePage() {
|
||||
setError(null);
|
||||
setMessage(null);
|
||||
try {
|
||||
const nextConfig = await updateMindSpaceAdminConfig({ publicPageLimit: Number(limit) });
|
||||
const nextConfig = await updateMindSpaceAdminConfig({
|
||||
publicPageLimit: Number(limit),
|
||||
seoGeo,
|
||||
});
|
||||
setConfig(nextConfig);
|
||||
setLimit(String(nextConfig.publicPageLimit));
|
||||
setMessage(`已更新为 ${nextConfig.publicPageLimit},主站会立即按新值校验。`);
|
||||
setSeoGeo(nextConfig.seoGeo ?? defaultSeoGeoConfig());
|
||||
setMessage(`已更新 MindSpace 配置,SEO/GEO 总开关:${nextConfig.seoGeo?.enabled ? '开启' : '关闭'}。`);
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : '保存 MindSpace 配置失败');
|
||||
} finally {
|
||||
@@ -52,6 +80,15 @@ export function MindSpacePage() {
|
||||
|
||||
const current = safeConfig(config);
|
||||
|
||||
const updateSeoGeo = (patch: Partial<MindSpaceSeoGeoConfig>) => {
|
||||
setSeoGeo((currentSeoGeo) => ({
|
||||
...currentSeoGeo,
|
||||
...patch,
|
||||
seo: { ...currentSeoGeo.seo, ...(patch.seo ?? {}) },
|
||||
geo: { ...currentSeoGeo.geo, ...(patch.geo ?? {}) },
|
||||
}));
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="admin-page">
|
||||
<div className="admin-page-head">
|
||||
@@ -62,12 +99,12 @@ export function MindSpacePage() {
|
||||
{error && <p className="banner banner-error">{error}</p>}
|
||||
{message && <p className="banner banner-info">{message}</p>}
|
||||
|
||||
<form className="admin-form" onSubmit={handleSave}>
|
||||
<section className="admin-card">
|
||||
<h2>公开页面上限</h2>
|
||||
<p className="muted">
|
||||
当前值:{loading ? '—' : current.publicPageLimit}。建议在修改后同步检查主站发布流程。
|
||||
</p>
|
||||
<form className="admin-form" onSubmit={handleSave}>
|
||||
<label className="admin-form-row">
|
||||
<span>每个用户最多可在线的公开页面数</span>
|
||||
<input
|
||||
@@ -80,16 +117,97 @@ export function MindSpacePage() {
|
||||
required
|
||||
/>
|
||||
</label>
|
||||
<div className="admin-actions">
|
||||
<button type="submit" className="send-btn" disabled={busy || loading}>
|
||||
{busy ? '保存中...' : '保存配置'}
|
||||
</button>
|
||||
<button type="button" className="ghost-btn" onClick={() => void load()} disabled={busy}>
|
||||
重新加载
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</section>
|
||||
|
||||
<section className="admin-card">
|
||||
<h2>SEO / GEO</h2>
|
||||
<p className="muted">
|
||||
仅对「已确认公开、access_mode=public、status=online」的发布页生效。私有、密码、登录可见或未确认页面会强制 noindex,不会进入 sitemap / llms.txt。
|
||||
</p>
|
||||
<div className="admin-form">
|
||||
<label className="admin-form-row">
|
||||
<span>启用 SEO / GEO 总开关</span>
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={seoGeo.enabled}
|
||||
onChange={(e) => updateSeoGeo({ enabled: e.target.checked })}
|
||||
disabled={loading || busy}
|
||||
/>
|
||||
</label>
|
||||
<label className="admin-form-row">
|
||||
<span>SEO:页面 meta + canonical 注入</span>
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={seoGeo.seo.enabled}
|
||||
onChange={(e) => updateSeoGeo({ seo: { ...seoGeo.seo, enabled: e.target.checked } })}
|
||||
disabled={loading || busy || !seoGeo.enabled}
|
||||
/>
|
||||
</label>
|
||||
<label className="admin-form-row">
|
||||
<span>SEO:sitemap.xml</span>
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={seoGeo.seo.sitemap}
|
||||
onChange={(e) => updateSeoGeo({ seo: { ...seoGeo.seo, sitemap: e.target.checked } })}
|
||||
disabled={loading || busy || !seoGeo.enabled || !seoGeo.seo.enabled}
|
||||
/>
|
||||
</label>
|
||||
<label className="admin-form-row">
|
||||
<span>SEO:robots.txt</span>
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={seoGeo.seo.robotsTxt}
|
||||
onChange={(e) => updateSeoGeo({ seo: { ...seoGeo.seo, robotsTxt: e.target.checked } })}
|
||||
disabled={loading || busy || !seoGeo.enabled || !seoGeo.seo.enabled}
|
||||
/>
|
||||
</label>
|
||||
<label className="admin-form-row">
|
||||
<span>SEO:百度主动推送</span>
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={seoGeo.seo.baiduPush}
|
||||
onChange={(e) => updateSeoGeo({ seo: { ...seoGeo.seo, baiduPush: e.target.checked } })}
|
||||
disabled={loading || busy || !seoGeo.enabled || !seoGeo.seo.enabled}
|
||||
/>
|
||||
</label>
|
||||
<label className="admin-form-row">
|
||||
<span>GEO:JSON-LD 结构化数据</span>
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={seoGeo.geo.jsonLd}
|
||||
onChange={(e) => updateSeoGeo({ geo: { ...seoGeo.geo, jsonLd: e.target.checked, enabled: e.target.checked || seoGeo.geo.llmsTxt } })}
|
||||
disabled={loading || busy || !seoGeo.enabled}
|
||||
/>
|
||||
</label>
|
||||
<label className="admin-form-row">
|
||||
<span>GEO:llms.txt(AI 搜索发现)</span>
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={seoGeo.geo.llmsTxt}
|
||||
onChange={(e) => updateSeoGeo({ geo: { ...seoGeo.geo, llmsTxt: e.target.checked, enabled: seoGeo.geo.jsonLd || e.target.checked } })}
|
||||
disabled={loading || busy || !seoGeo.enabled}
|
||||
/>
|
||||
</label>
|
||||
<label className="admin-form-row">
|
||||
<span>GEO 子开关(结构化 + 发现层)</span>
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={seoGeo.geo.enabled}
|
||||
onChange={(e) => updateSeoGeo({ geo: { ...seoGeo.geo, enabled: e.target.checked } })}
|
||||
disabled={loading || busy || !seoGeo.enabled}
|
||||
/>
|
||||
</label>
|
||||
</div>
|
||||
</section>
|
||||
<div className="admin-actions">
|
||||
<button type="submit" className="send-btn" disabled={busy || loading}>
|
||||
{busy ? '保存中...' : '保存配置'}
|
||||
</button>
|
||||
<button type="button" className="ghost-btn" onClick={() => void load()} disabled={busy}>
|
||||
重新加载
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,281 @@
|
||||
import { useCallback, useEffect, useMemo, useState } from 'react';
|
||||
import { getSeoGeoPublicationCatalog, getUmamiSsoUrl } from '../../api/client';
|
||||
import type { SeoGeoCatalogRow } from '../../api/client';
|
||||
|
||||
type PageSort =
|
||||
| 'publishedAt'
|
||||
| 'pageTitle'
|
||||
| 'ownerLabel'
|
||||
| 'totalViews'
|
||||
| 'seoViews'
|
||||
| 'geoViews'
|
||||
| 'crawlerViews'
|
||||
| 'lifetimeViewCount';
|
||||
|
||||
function formatNumber(value: number) {
|
||||
return new Intl.NumberFormat('zh-CN').format(Number(value) || 0);
|
||||
}
|
||||
|
||||
function defaultRange() {
|
||||
const end = new Date();
|
||||
const start = new Date(end);
|
||||
start.setDate(start.getDate() - 29);
|
||||
return {
|
||||
startDate: start.toISOString().slice(0, 10),
|
||||
endDate: end.toISOString().slice(0, 10),
|
||||
};
|
||||
}
|
||||
|
||||
function toRangeMs(startDate: string, endDate: string) {
|
||||
const startAt = new Date(`${startDate}T00:00:00`).getTime();
|
||||
const endAt = new Date(`${endDate}T23:59:59.999`).getTime();
|
||||
return { startAt, endAt };
|
||||
}
|
||||
|
||||
function accessModeLabel(accessMode: string) {
|
||||
switch (accessMode) {
|
||||
case 'public':
|
||||
return '公开';
|
||||
case 'password':
|
||||
return '密码';
|
||||
case 'login_required':
|
||||
return '登录可见';
|
||||
case 'owner_only':
|
||||
return '仅本人';
|
||||
case 'private_link':
|
||||
return '私密链接';
|
||||
default:
|
||||
return accessMode || '—';
|
||||
}
|
||||
}
|
||||
|
||||
export function SeoGeoAnalyticsPage() {
|
||||
const initialRange = useMemo(() => defaultRange(), []);
|
||||
const [startDate, setStartDate] = useState(initialRange.startDate);
|
||||
const [endDate, setEndDate] = useState(initialRange.endDate);
|
||||
const [search, setSearch] = useState('');
|
||||
const [sortBy, setSortBy] = useState<PageSort>('totalViews');
|
||||
const [sortOrder, setSortOrder] = useState<'asc' | 'desc'>('desc');
|
||||
const [page, setPage] = useState(1);
|
||||
const [rows, setRows] = useState<SeoGeoCatalogRow[]>([]);
|
||||
const [total, setTotal] = useState(0);
|
||||
const [totals, setTotals] = useState({
|
||||
totalViews: 0,
|
||||
seoViews: 0,
|
||||
geoViews: 0,
|
||||
crawlerViews: 0,
|
||||
});
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [openingUmami, setOpeningUmami] = useState(false);
|
||||
|
||||
const pageCount = Math.max(1, Math.ceil(total / 20));
|
||||
|
||||
const load = useCallback(async () => {
|
||||
setLoading(true);
|
||||
setError(null);
|
||||
try {
|
||||
const { startAt, endAt } = toRangeMs(startDate, endDate);
|
||||
const result = await getSeoGeoPublicationCatalog({
|
||||
startAt,
|
||||
endAt,
|
||||
page,
|
||||
pageSize: 20,
|
||||
sortBy,
|
||||
sortOrder,
|
||||
search: search.trim() || undefined,
|
||||
});
|
||||
setRows(Array.isArray(result.data) ? result.data : []);
|
||||
setTotal(Number(result.total) || 0);
|
||||
setTotals(result.totals ?? {
|
||||
totalViews: 0,
|
||||
seoViews: 0,
|
||||
geoViews: 0,
|
||||
crawlerViews: 0,
|
||||
});
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : '加载 SEO/GEO 页面目录失败');
|
||||
setRows([]);
|
||||
setTotal(0);
|
||||
setTotals({ totalViews: 0, seoViews: 0, geoViews: 0, crawlerViews: 0 });
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}, [endDate, page, search, sortBy, sortOrder, startDate]);
|
||||
|
||||
useEffect(() => {
|
||||
void load();
|
||||
}, [load]);
|
||||
|
||||
useEffect(() => {
|
||||
setPage(1);
|
||||
}, [startDate, endDate, search, sortBy, sortOrder]);
|
||||
|
||||
const openUmami = async () => {
|
||||
setOpeningUmami(true);
|
||||
setError(null);
|
||||
try {
|
||||
window.location.assign(await getUmamiSsoUrl());
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : '无法打开 Umami');
|
||||
setOpeningUmami(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="admin-page">
|
||||
<div className="admin-page-head">
|
||||
<h2>SEO / GEO 流量</h2>
|
||||
<p className="muted">
|
||||
列出全部在线公开页(含零流量页面),仅展示统计与 URL,不提供跳转访问。SEO / GEO 来源按 referrer 域名归类;爬虫按 User-Agent 识别。
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{error && <p className="banner banner-error">{error}</p>}
|
||||
|
||||
<section className="admin-card">
|
||||
<div className="admin-actions" style={{ marginBottom: 16, flexWrap: 'wrap', gap: 8 }}>
|
||||
<button type="button" className="ghost-btn" onClick={() => void load()} disabled={loading}>
|
||||
{loading ? '刷新中…' : '刷新数据'}
|
||||
</button>
|
||||
<button type="button" className="ghost-btn" onClick={() => void openUmami()} disabled={openingUmami}>
|
||||
{openingUmami ? '正在进入 Umami…' : '打开 Umami 完整看板'}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="admin-form" style={{ marginBottom: 16 }}>
|
||||
<label className="admin-form-row">
|
||||
<span>统计日期从</span>
|
||||
<input type="date" value={startDate} max={endDate} onChange={(e) => setStartDate(e.target.value)} />
|
||||
</label>
|
||||
<label className="admin-form-row">
|
||||
<span>统计日期到</span>
|
||||
<input type="date" value={endDate} min={startDate} onChange={(e) => setEndDate(e.target.value)} />
|
||||
</label>
|
||||
<label className="admin-form-row">
|
||||
<span>搜索标题 / 用户 / URL</span>
|
||||
<input
|
||||
type="search"
|
||||
value={search}
|
||||
placeholder="例如 john、sitemap、问卷"
|
||||
onChange={(e) => setSearch(e.target.value)}
|
||||
/>
|
||||
</label>
|
||||
<label className="admin-form-row">
|
||||
<span>排序字段</span>
|
||||
<select value={sortBy} onChange={(e) => setSortBy(e.target.value as PageSort)}>
|
||||
<option value="totalViews">筛选期总访问</option>
|
||||
<option value="seoViews">SEO 搜索来源</option>
|
||||
<option value="geoViews">GEO 来源</option>
|
||||
<option value="crawlerViews">爬虫访问</option>
|
||||
<option value="lifetimeViewCount">累计访问</option>
|
||||
<option value="publishedAt">发布时间</option>
|
||||
<option value="pageTitle">标题</option>
|
||||
<option value="ownerLabel">创建用户</option>
|
||||
</select>
|
||||
</label>
|
||||
<label className="admin-form-row">
|
||||
<span>顺序</span>
|
||||
<select value={sortOrder} onChange={(e) => setSortOrder(e.target.value as 'asc' | 'desc')}>
|
||||
<option value="desc">降序</option>
|
||||
<option value="asc">升序</option>
|
||||
</select>
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<div className="admin-metrics-grid" style={{ display: 'flex', gap: 12, flexWrap: 'wrap', marginBottom: 16 }}>
|
||||
<div className="admin-card metric-card" style={{ minWidth: 140, padding: 16 }}>
|
||||
<div className="muted">在线页面总数</div>
|
||||
<strong>{formatNumber(total)}</strong>
|
||||
</div>
|
||||
<div className="admin-card metric-card" style={{ minWidth: 140, padding: 16 }}>
|
||||
<div className="muted">筛选期总访问</div>
|
||||
<strong>{formatNumber(totals.totalViews)}</strong>
|
||||
</div>
|
||||
<div className="admin-card metric-card" style={{ minWidth: 140, padding: 16 }}>
|
||||
<div className="muted">SEO 搜索来源</div>
|
||||
<strong>{formatNumber(totals.seoViews)}</strong>
|
||||
</div>
|
||||
<div className="admin-card metric-card" style={{ minWidth: 140, padding: 16 }}>
|
||||
<div className="muted">GEO 来源</div>
|
||||
<strong>{formatNumber(totals.geoViews)}</strong>
|
||||
</div>
|
||||
<div className="admin-card metric-card" style={{ minWidth: 140, padding: 16 }}>
|
||||
<div className="muted">爬虫访问</div>
|
||||
<strong>{formatNumber(totals.crawlerViews)}</strong>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="admin-table-wrap">
|
||||
<table className="admin-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>标题</th>
|
||||
<th>创建用户</th>
|
||||
<th>页面 URL(仅展示)</th>
|
||||
<th>访问模式</th>
|
||||
<th>可收录</th>
|
||||
<th>SEO</th>
|
||||
<th>GEO</th>
|
||||
<th>总访问</th>
|
||||
<th>爬虫</th>
|
||||
<th>累计访问</th>
|
||||
<th>发布时间</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{loading ? (
|
||||
<tr>
|
||||
<td colSpan={11}>正在加载…</td>
|
||||
</tr>
|
||||
) : rows.length === 0 ? (
|
||||
<tr>
|
||||
<td colSpan={11}>暂无在线页面</td>
|
||||
</tr>
|
||||
) : (
|
||||
rows.map((row) => (
|
||||
<tr key={row.publicationId}>
|
||||
<td className="admin-table-path" title={row.pageTitle}>{row.pageTitle}</td>
|
||||
<td>{row.ownerLabel}</td>
|
||||
<td className="admin-table-path">
|
||||
<span title={row.publicUrl}>{row.publicUrl}</span>
|
||||
</td>
|
||||
<td>{accessModeLabel(row.accessMode)}</td>
|
||||
<td>{row.indexable ? '是' : '否'}</td>
|
||||
<td>{formatNumber(row.seoViews)}</td>
|
||||
<td>{formatNumber(row.geoViews)}</td>
|
||||
<td>{formatNumber(row.totalViews)}</td>
|
||||
<td>{formatNumber(row.crawlerViews)}</td>
|
||||
<td>{formatNumber(row.lifetimeViewCount)}</td>
|
||||
<td>
|
||||
{row.publishedAt
|
||||
? new Date(row.publishedAt).toLocaleString('zh-CN')
|
||||
: '—'}
|
||||
</td>
|
||||
</tr>
|
||||
))
|
||||
)}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
<div className="admin-actions" style={{ marginTop: 16 }}>
|
||||
<button type="button" className="ghost-btn" disabled={page <= 1 || loading} onClick={() => setPage((p) => p - 1)}>
|
||||
上一页
|
||||
</button>
|
||||
<span className="muted">
|
||||
第 {page} / {pageCount} 页,共 {formatNumber(total)} 个页面
|
||||
</span>
|
||||
<button
|
||||
type="button"
|
||||
className="ghost-btn"
|
||||
disabled={page >= pageCount || loading}
|
||||
onClick={() => setPage((p) => p + 1)}
|
||||
>
|
||||
下一页
|
||||
</button>
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,267 @@
|
||||
import { useEffect, useMemo, useState } from 'react';
|
||||
import { fetchAdminTemplateCatalog, updateAdminTemplateCatalogItem } from '../../api/client';
|
||||
import type { AdminTemplateCatalogItem } from '../../types';
|
||||
|
||||
type DraftRow = {
|
||||
label: string;
|
||||
description: string;
|
||||
previewUrl: string;
|
||||
priceYuan: string;
|
||||
billingMode: AdminTemplateCatalogItem['billingMode'];
|
||||
status: AdminTemplateCatalogItem['status'];
|
||||
sortOrder: string;
|
||||
};
|
||||
|
||||
const STATUS_LABELS: Record<AdminTemplateCatalogItem['status'], string> = {
|
||||
draft: '草稿',
|
||||
active: '上架',
|
||||
archived: '下架',
|
||||
};
|
||||
|
||||
const BILLING_LABELS: Record<AdminTemplateCatalogItem['billingMode'], string> = {
|
||||
free: '免费',
|
||||
one_time: '一次性',
|
||||
subscription: '订阅',
|
||||
};
|
||||
|
||||
function toDraft(item: AdminTemplateCatalogItem): DraftRow {
|
||||
return {
|
||||
label: item.label,
|
||||
description: item.description ?? '',
|
||||
previewUrl: item.previewUrl ?? '',
|
||||
priceYuan: item.priceCents > 0 ? (item.priceCents / 100).toFixed(item.priceCents % 100 === 0 ? 0 : 2) : '0',
|
||||
billingMode: item.billingMode,
|
||||
status: item.status,
|
||||
sortOrder: String(item.sortOrder ?? 0),
|
||||
};
|
||||
}
|
||||
|
||||
function resolvePreviewSrc(previewUrl: string | null | undefined) {
|
||||
if (!previewUrl) return null;
|
||||
if (/^https?:\/\//i.test(previewUrl)) return previewUrl;
|
||||
const portalOrigin = import.meta.env.VITE_PORTAL_ORIGIN?.trim() || 'http://127.0.0.1:5173';
|
||||
return `${portalOrigin.replace(/\/$/, '')}${previewUrl.startsWith('/') ? previewUrl : `/${previewUrl}`}`;
|
||||
}
|
||||
|
||||
export function TemplateCatalogPage() {
|
||||
const [items, setItems] = useState<AdminTemplateCatalogItem[]>([]);
|
||||
const [drafts, setDrafts] = useState<Record<string, DraftRow>>({});
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [busySkill, setBusySkill] = useState<string | null>(null);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [message, setMessage] = useState<string | null>(null);
|
||||
|
||||
const portalPreviewBase =
|
||||
import.meta.env.VITE_PORTAL_ORIGIN?.trim()?.replace(/\/$/, '') || 'http://127.0.0.1:5173';
|
||||
|
||||
const load = async () => {
|
||||
setLoading(true);
|
||||
setError(null);
|
||||
try {
|
||||
const catalog = await fetchAdminTemplateCatalog();
|
||||
setItems(catalog);
|
||||
setDrafts(Object.fromEntries(catalog.map((item) => [item.skillName, toDraft(item)])));
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : '加载模板目录失败');
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
void load();
|
||||
}, []);
|
||||
|
||||
const sortedItems = useMemo(
|
||||
() => [...items].sort((a, b) => a.sortOrder - b.sortOrder || a.label.localeCompare(b.label, 'zh-CN')),
|
||||
[items],
|
||||
);
|
||||
|
||||
const updateDraft = (skillName: string, patch: Partial<DraftRow>) => {
|
||||
setDrafts((current) => ({
|
||||
...current,
|
||||
[skillName]: { ...(current[skillName] ?? toDraft(items.find((item) => item.skillName === skillName)!)), ...patch },
|
||||
}));
|
||||
};
|
||||
|
||||
const saveItem = async (skillName: string) => {
|
||||
const draft = drafts[skillName];
|
||||
if (!draft) return;
|
||||
const priceYuan = Number(draft.priceYuan);
|
||||
const sortOrder = Number(draft.sortOrder);
|
||||
if (!Number.isFinite(priceYuan) || priceYuan < 0) {
|
||||
setError('价格必须是非负数');
|
||||
return;
|
||||
}
|
||||
if (!Number.isFinite(sortOrder)) {
|
||||
setError('排序必须是数字');
|
||||
return;
|
||||
}
|
||||
setBusySkill(skillName);
|
||||
setError(null);
|
||||
setMessage(null);
|
||||
try {
|
||||
const result = await updateAdminTemplateCatalogItem(skillName, {
|
||||
label: draft.label.trim(),
|
||||
description: draft.description.trim(),
|
||||
previewUrl: draft.previewUrl.trim() || null,
|
||||
priceCents: Math.round(priceYuan * 100),
|
||||
billingMode: draft.billingMode,
|
||||
status: draft.status,
|
||||
sortOrder: Math.floor(sortOrder),
|
||||
});
|
||||
setMessage(`已保存「${result.item?.label ?? skillName}」`);
|
||||
await load();
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : '保存失败');
|
||||
} finally {
|
||||
setBusySkill(null);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="admin-page">
|
||||
<div className="admin-page-head">
|
||||
<h2>页面模板商城</h2>
|
||||
<p className="muted">
|
||||
管理 Portal 聊天页「模板商城」中的可定价 page-template skill:改价、上下架、封面与排序。用户授权请在「用户管理 →
|
||||
用户详情」中操作。
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{error && <p className="banner banner-error">{error}</p>}
|
||||
{message && <p className="banner banner-info">{message}</p>}
|
||||
|
||||
<section className="admin-card">
|
||||
{loading && items.length === 0 ? (
|
||||
<p className="muted">加载中…</p>
|
||||
) : sortedItems.length === 0 ? (
|
||||
<p className="muted">暂无模板条目。请确认 Memind 已 seed page-template-* skill。</p>
|
||||
) : (
|
||||
<div className="admin-table-wrap">
|
||||
<table className="admin-table template-catalog-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>封面</th>
|
||||
<th>Skill / 名称</th>
|
||||
<th>描述</th>
|
||||
<th>价格(元)</th>
|
||||
<th>计费</th>
|
||||
<th>状态</th>
|
||||
<th>排序</th>
|
||||
<th>预览 URL</th>
|
||||
<th />
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{sortedItems.map((item) => {
|
||||
const draft = drafts[item.skillName] ?? toDraft(item);
|
||||
const previewSrc = resolvePreviewSrc(draft.previewUrl || item.previewUrl);
|
||||
const livePreviewUrl = `${portalPreviewBase}/api/mindspace/v1/template-catalog/${encodeURIComponent(item.skillName)}/preview`;
|
||||
return (
|
||||
<tr key={item.skillName}>
|
||||
<td>
|
||||
{previewSrc ? (
|
||||
<img src={previewSrc} alt="" className="template-catalog-thumb" loading="lazy" />
|
||||
) : (
|
||||
<div className="template-catalog-thumb template-catalog-thumb-fallback" aria-hidden="true" />
|
||||
)}
|
||||
</td>
|
||||
<td>
|
||||
<div className="mono">{item.skillName}</div>
|
||||
<input
|
||||
className="admin-inline-input"
|
||||
value={draft.label}
|
||||
onChange={(event) => updateDraft(item.skillName, { label: event.target.value })}
|
||||
/>
|
||||
</td>
|
||||
<td>
|
||||
<textarea
|
||||
className="admin-inline-textarea"
|
||||
rows={3}
|
||||
value={draft.description}
|
||||
onChange={(event) => updateDraft(item.skillName, { description: event.target.value })}
|
||||
/>
|
||||
</td>
|
||||
<td>
|
||||
<input
|
||||
className="admin-inline-input admin-inline-input-narrow"
|
||||
inputMode="decimal"
|
||||
value={draft.priceYuan}
|
||||
onChange={(event) => updateDraft(item.skillName, { priceYuan: event.target.value })}
|
||||
/>
|
||||
</td>
|
||||
<td>
|
||||
<select
|
||||
className="admin-inline-input"
|
||||
value={draft.billingMode}
|
||||
onChange={(event) =>
|
||||
updateDraft(item.skillName, {
|
||||
billingMode: event.target.value as AdminTemplateCatalogItem['billingMode'],
|
||||
})
|
||||
}
|
||||
>
|
||||
{Object.entries(BILLING_LABELS).map(([value, label]) => (
|
||||
<option key={value} value={value}>
|
||||
{label}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</td>
|
||||
<td>
|
||||
<select
|
||||
className="admin-inline-input"
|
||||
value={draft.status}
|
||||
onChange={(event) =>
|
||||
updateDraft(item.skillName, {
|
||||
status: event.target.value as AdminTemplateCatalogItem['status'],
|
||||
})
|
||||
}
|
||||
>
|
||||
{Object.entries(STATUS_LABELS).map(([value, label]) => (
|
||||
<option key={value} value={value}>
|
||||
{label}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</td>
|
||||
<td>
|
||||
<input
|
||||
className="admin-inline-input admin-inline-input-narrow"
|
||||
inputMode="numeric"
|
||||
value={draft.sortOrder}
|
||||
onChange={(event) => updateDraft(item.skillName, { sortOrder: event.target.value })}
|
||||
/>
|
||||
</td>
|
||||
<td>
|
||||
<input
|
||||
className="admin-inline-input"
|
||||
placeholder="/assets/template-previews/..."
|
||||
value={draft.previewUrl}
|
||||
onChange={(event) => updateDraft(item.skillName, { previewUrl: event.target.value })}
|
||||
/>
|
||||
<a href={livePreviewUrl} target="_blank" rel="noreferrer" className="admin-inline-link">
|
||||
在线预览
|
||||
</a>
|
||||
</td>
|
||||
<td>
|
||||
<button
|
||||
type="button"
|
||||
className="send-btn"
|
||||
disabled={busySkill === item.skillName}
|
||||
onClick={() => void saveItem(item.skillName)}
|
||||
>
|
||||
{busySkill === item.skillName ? '保存中…' : '保存'}
|
||||
</button>
|
||||
</td>
|
||||
</tr>
|
||||
);
|
||||
})}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
)}
|
||||
</section>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,12 +1,12 @@
|
||||
import { useEffect, useState } from 'react';
|
||||
import { Link, Navigate, useParams } from 'react-router-dom';
|
||||
import { getAdminUser, rechargeUser, updateAdminUser } from '../../api/client';
|
||||
import { getAdminUser, rechargeUser, updateAdminUser, fetchUserImageQuota, setUserImageQuota, fetchAdminTemplateCatalog, grantUserPageTemplate } from '../../api/client';
|
||||
import { CapabilitySettings } from '../../components/CapabilitySettings';
|
||||
import { PolicySettings } from '../../components/PolicySettings';
|
||||
import { SkillSettings } from '../../components/SkillSettings';
|
||||
import { useAdminUsers } from '../hooks/useAdminUsers';
|
||||
import { formatYuan } from '../utils/format';
|
||||
import type { PortalUser } from '../../types';
|
||||
import type { AdminTemplateCatalogItem, ImageQuotaView, PortalUser } from '../../types';
|
||||
|
||||
function formatBytes(bytes: number) {
|
||||
if (bytes < 1024) return `${bytes} B`;
|
||||
@@ -23,6 +23,13 @@ export function UserDetailPage() {
|
||||
const [message, setMessage] = useState<string | null>(null);
|
||||
const [recharge, setRecharge] = useState({ amountYuan: '10', note: '' });
|
||||
const [spaceQuotaMb, setSpaceQuotaMb] = useState('5');
|
||||
const [imageQuota, setImageQuota] = useState<ImageQuotaView | null>(null);
|
||||
const [imageQuotaLoading, setImageQuotaLoading] = useState(false);
|
||||
const [imageRemaining, setImageRemaining] = useState('');
|
||||
const [imageGrantNote, setImageGrantNote] = useState('');
|
||||
const [templateCatalog, setTemplateCatalog] = useState<AdminTemplateCatalogItem[]>([]);
|
||||
const [templateCatalogLoading, setTemplateCatalogLoading] = useState(false);
|
||||
const [grantingTemplate, setGrantingTemplate] = useState<string | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
if (!user?.spaceQuotaBytes) return;
|
||||
@@ -57,6 +64,58 @@ export function UserDetailPage() {
|
||||
};
|
||||
}, [userId, users]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!user || user.role !== 'user') {
|
||||
setImageQuota(null);
|
||||
return;
|
||||
}
|
||||
let cancelled = false;
|
||||
setImageQuotaLoading(true);
|
||||
void fetchUserImageQuota(user.id)
|
||||
.then((result) => {
|
||||
if (!cancelled) setImageQuota(result.quota);
|
||||
})
|
||||
.catch(() => {
|
||||
if (!cancelled) setImageQuota(null);
|
||||
})
|
||||
.finally(() => {
|
||||
if (!cancelled) setImageQuotaLoading(false);
|
||||
});
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
}, [user?.id, user?.role]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!imageQuota || imageQuota.unlimited || imageQuota.remaining == null) {
|
||||
setImageRemaining('');
|
||||
return;
|
||||
}
|
||||
setImageRemaining(String(imageQuota.remaining));
|
||||
}, [imageQuota?.remaining, imageQuota?.unlimited]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!user || user.role !== 'user') {
|
||||
setTemplateCatalog([]);
|
||||
return;
|
||||
}
|
||||
let cancelled = false;
|
||||
setTemplateCatalogLoading(true);
|
||||
void fetchAdminTemplateCatalog()
|
||||
.then((items) => {
|
||||
if (!cancelled) setTemplateCatalog(items);
|
||||
})
|
||||
.catch(() => {
|
||||
if (!cancelled) setTemplateCatalog([]);
|
||||
})
|
||||
.finally(() => {
|
||||
if (!cancelled) setTemplateCatalogLoading(false);
|
||||
});
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
}, [user?.id, user?.role]);
|
||||
|
||||
const handleRecharge = async (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
if (!user) return;
|
||||
@@ -75,6 +134,62 @@ export function UserDetailPage() {
|
||||
}
|
||||
};
|
||||
|
||||
const handleImageQuotaSave = async (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
if (!user) return;
|
||||
setMessage(null);
|
||||
setLocalError(null);
|
||||
setError(null);
|
||||
if (!imageQuota) {
|
||||
setLocalError('暂无额度信息,用户可能尚无有效订阅');
|
||||
return;
|
||||
}
|
||||
if (imageQuota.unlimited) {
|
||||
setLocalError('当前为无限额度套餐,无法在此调整');
|
||||
return;
|
||||
}
|
||||
const targetRemaining = Math.floor(Number(imageRemaining));
|
||||
if (!Number.isFinite(targetRemaining) || targetRemaining < 0) {
|
||||
setLocalError('请输入非负整数剩余额度');
|
||||
return;
|
||||
}
|
||||
if (targetRemaining === imageQuota.remaining) {
|
||||
setMessage('额度未变化');
|
||||
return;
|
||||
}
|
||||
try {
|
||||
const result = await setUserImageQuota(user.id, { remaining: targetRemaining }, imageGrantNote.trim());
|
||||
setImageQuota(result.quota);
|
||||
setMessage(result.unchanged ? '额度未变化' : '图片额度已更新');
|
||||
setImageGrantNote('');
|
||||
} catch (err) {
|
||||
setLocalError(err instanceof Error ? err.message : '图片额度设置失败');
|
||||
}
|
||||
};
|
||||
|
||||
const imageQuotaSummary = imageQuota
|
||||
? imageQuota.unlimited
|
||||
? '无限'
|
||||
: `剩余 ${imageQuota.remaining ?? 0} / 总计 ${imageQuota.total ?? 0}(套餐 ${imageQuota.limit} + 充值 ${imageQuota.bonus},已用 ${imageQuota.used})`
|
||||
: '';
|
||||
|
||||
const handleTemplateGrant = async (skillName: string) => {
|
||||
if (!user) return;
|
||||
setMessage(null);
|
||||
setLocalError(null);
|
||||
setError(null);
|
||||
setGrantingTemplate(skillName);
|
||||
try {
|
||||
await grantUserPageTemplate(user.id, skillName);
|
||||
const label = templateCatalog.find((item) => item.skillName === skillName)?.label ?? skillName;
|
||||
setMessage(`已为用户开通「${label}」`);
|
||||
} catch (err) {
|
||||
setLocalError(err instanceof Error ? err.message : '模板授权失败');
|
||||
} finally {
|
||||
setGrantingTemplate(null);
|
||||
}
|
||||
};
|
||||
|
||||
if (!loading && !user && !error) {
|
||||
return <Navigate to="/users" replace />;
|
||||
}
|
||||
@@ -186,6 +301,42 @@ export function UserDetailPage() {
|
||||
</form>
|
||||
</section>
|
||||
|
||||
<section className="admin-card">
|
||||
<h2>图片生成额度</h2>
|
||||
{imageQuotaLoading ? (
|
||||
<p className="muted">加载中…</p>
|
||||
) : (
|
||||
<p className="muted">{imageQuotaSummary || '暂无额度信息(用户可能尚无订阅)'}</p>
|
||||
)}
|
||||
<p className="muted">
|
||||
直接设置本周期剩余可用张数。若目标低于当前套餐额度,会自动下调该用户的周期额度上限。
|
||||
</p>
|
||||
<form className="admin-form" onSubmit={handleImageQuotaSave}>
|
||||
<input
|
||||
placeholder="剩余额度(张)"
|
||||
type="number"
|
||||
min="0"
|
||||
step="1"
|
||||
value={imageRemaining}
|
||||
onChange={(e) => setImageRemaining(e.target.value)}
|
||||
disabled={!imageQuota || imageQuota.unlimited}
|
||||
/>
|
||||
<input
|
||||
placeholder="备注"
|
||||
value={imageGrantNote}
|
||||
onChange={(e) => setImageGrantNote(e.target.value)}
|
||||
disabled={!imageQuota || imageQuota.unlimited}
|
||||
/>
|
||||
<button
|
||||
type="submit"
|
||||
className="send-btn"
|
||||
disabled={!imageQuota || imageQuota.unlimited}
|
||||
>
|
||||
保存额度
|
||||
</button>
|
||||
</form>
|
||||
</section>
|
||||
|
||||
<section className="admin-card">
|
||||
<h2>调整空间</h2>
|
||||
<p className="muted">
|
||||
@@ -206,6 +357,39 @@ export function UserDetailPage() {
|
||||
<p className="muted">当前总额约 {currentQuotaMb} MB。</p>
|
||||
</section>
|
||||
|
||||
<section className="admin-card">
|
||||
<h2>页面模板授权</h2>
|
||||
<p className="muted">
|
||||
为用户免费开通可定价 page-template skill(写入购买记录并启用 skill)。完整目录与改价见
|
||||
{' '}
|
||||
<Link to="/template-catalog">页面模板</Link>。
|
||||
</p>
|
||||
{templateCatalogLoading ? (
|
||||
<p className="muted">加载模板目录…</p>
|
||||
) : templateCatalog.length === 0 ? (
|
||||
<p className="muted">暂无模板目录。</p>
|
||||
) : (
|
||||
<div className="template-grant-list">
|
||||
{templateCatalog.map((item) => (
|
||||
<div key={item.skillName} className="template-grant-row">
|
||||
<div>
|
||||
<strong>{item.label}</strong>
|
||||
<div className="muted mono">{item.skillName}</div>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
className="ghost-btn"
|
||||
disabled={grantingTemplate === item.skillName}
|
||||
onClick={() => void handleTemplateGrant(item.skillName)}
|
||||
>
|
||||
{grantingTemplate === item.skillName ? '开通中…' : '免费开通'}
|
||||
</button>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</section>
|
||||
|
||||
<CapabilitySettings users={users} userId={user.id} userOnly />
|
||||
<SkillSettings users={users} userId={user.id} userOnly />
|
||||
<PolicySettings users={users} userId={user.id} userOnly />
|
||||
|
||||
@@ -1,24 +1,31 @@
|
||||
import { useCallback, useEffect, useState } from 'react';
|
||||
import { useCallback, useEffect, useMemo, useState } from 'react';
|
||||
import {
|
||||
cancelWechatDigest,
|
||||
clearWechatRoute,
|
||||
createWechatWebNotification,
|
||||
getWechatAdminSummary,
|
||||
getWechatIntentRouterConfig,
|
||||
getWechatIntentRouterRuntime,
|
||||
listAdminUsers,
|
||||
listLlmProviderKeys,
|
||||
listWechatBindings,
|
||||
listWechatDeliveries,
|
||||
listWechatDigests,
|
||||
listWechatMessages,
|
||||
listWechatWebNotifications,
|
||||
patchWechatIntentRouterConfig,
|
||||
resumeWechatDigest,
|
||||
updateWechatScheduleLlmConfig,
|
||||
} from '../../api/client';
|
||||
import type {
|
||||
AdminUserRow,
|
||||
LlmProviderKeyRow,
|
||||
WechatAdminSummary,
|
||||
WechatBinding,
|
||||
WechatDeliveryLog,
|
||||
WechatDigestSubscription,
|
||||
WechatIntentRouterAdminConfig,
|
||||
WechatIntentRouterRuntimeState,
|
||||
WechatMessage,
|
||||
WechatWebNotification,
|
||||
} from '../../types';
|
||||
@@ -70,6 +77,49 @@ function dateLabel(value?: number | null) {
|
||||
return value ? formatTime(value) : '—';
|
||||
}
|
||||
|
||||
type IntentRouterForm = {
|
||||
enabled: boolean;
|
||||
shadowMode: boolean;
|
||||
modelProviderKeyId: string;
|
||||
model: string;
|
||||
minConfidence: string;
|
||||
timeoutMs: string;
|
||||
canaryOpenids: string;
|
||||
};
|
||||
|
||||
function intentRouterToForm(
|
||||
config: WechatIntentRouterAdminConfig | undefined,
|
||||
keys: LlmProviderKeyRow[],
|
||||
): IntentRouterForm {
|
||||
const keyId = String(config?.modelProviderKeyId ?? '').trim();
|
||||
const selectedKey = keys.find((item) => item.id === keyId) ?? null;
|
||||
const model = String(config?.model ?? '').trim()
|
||||
|| selectedKey?.defaultModel
|
||||
|| selectedKey?.models?.[0]
|
||||
|| '';
|
||||
return {
|
||||
enabled: Boolean(config?.enabled),
|
||||
shadowMode: config?.shadowMode !== false,
|
||||
modelProviderKeyId: keyId,
|
||||
model,
|
||||
minConfidence: String(config?.minConfidence ?? 0.65),
|
||||
timeoutMs: String(config?.timeoutMs ?? 4000),
|
||||
canaryOpenids: Array.isArray(config?.canaryOpenids) ? config.canaryOpenids.join('\n') : '',
|
||||
};
|
||||
}
|
||||
|
||||
function intentRouterRuntimeMode(overrides: Record<string, string>) {
|
||||
const enabled = ['1', 'true', 'yes', 'on'].includes(
|
||||
String(overrides.MEMIND_WECHAT_INTENT_LLM_ENABLED ?? '').toLowerCase(),
|
||||
);
|
||||
const shadow = ['1', 'true', 'yes', 'on'].includes(
|
||||
String(overrides.MEMIND_WECHAT_INTENT_LLM_SHADOW ?? '').toLowerCase(),
|
||||
);
|
||||
if (!enabled) return '关闭(仅规则意图)';
|
||||
if (shadow) return 'Shadow 观测(行为不变)';
|
||||
return '已激活(chat.general 可升级为 page.generate)';
|
||||
}
|
||||
|
||||
export function WechatPage() {
|
||||
const [summary, setSummary] = useState<WechatAdminSummary | null>(null);
|
||||
const [bindings, setBindings] = useState<WechatBinding[]>([]);
|
||||
@@ -91,12 +141,27 @@ export function WechatPage() {
|
||||
const [notifyType, setNotifyType] = useState('manual');
|
||||
const [notifyChannels, setNotifyChannels] = useState<Array<'web' | 'wechat'>>(['web']);
|
||||
const [scheduleLlmEnabled, setScheduleLlmEnabled] = useState(false);
|
||||
const [llmKeys, setLlmKeys] = useState<LlmProviderKeyRow[]>([]);
|
||||
const [intentForm, setIntentForm] = useState<IntentRouterForm | null>(null);
|
||||
const [savedIntentForm, setSavedIntentForm] = useState<IntentRouterForm | null>(null);
|
||||
const [intentRuntime, setIntentRuntime] = useState<WechatIntentRouterRuntimeState | null>(null);
|
||||
const [intentSaving, setIntentSaving] = useState(false);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [busy, setBusy] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [notice, setNotice] = useState<string | null>(null);
|
||||
const safe = safeSummary(summary);
|
||||
|
||||
const intentDirty = useMemo(() => {
|
||||
if (!intentForm || !savedIntentForm) return false;
|
||||
return JSON.stringify(intentForm) !== JSON.stringify(savedIntentForm);
|
||||
}, [intentForm, savedIntentForm]);
|
||||
|
||||
const selectedIntentKey = useMemo(
|
||||
() => llmKeys.find((item) => item.id === intentForm?.modelProviderKeyId) ?? null,
|
||||
[llmKeys, intentForm?.modelProviderKeyId],
|
||||
);
|
||||
|
||||
const load = useCallback(async () => {
|
||||
setLoading(true);
|
||||
setError(null);
|
||||
@@ -109,6 +174,9 @@ export function WechatPage() {
|
||||
nextDeliveries,
|
||||
nextNotifications,
|
||||
nextUsers,
|
||||
nextLlmKeys,
|
||||
nextIntentConfig,
|
||||
nextIntentRuntime,
|
||||
] =
|
||||
await Promise.all([
|
||||
getWechatAdminSummary(),
|
||||
@@ -118,6 +186,9 @@ export function WechatPage() {
|
||||
listWechatDeliveries({ status: deliveryStatus || undefined, limit: 80 }),
|
||||
listWechatWebNotifications({ status: notificationStatus || undefined, limit: 80 }),
|
||||
listAdminUsers({ page: 1, pageSize: 200, status: 'active' }),
|
||||
listLlmProviderKeys().catch(() => [] as LlmProviderKeyRow[]),
|
||||
getWechatIntentRouterConfig().catch(() => null),
|
||||
getWechatIntentRouterRuntime().catch(() => null),
|
||||
]);
|
||||
setSummary(nextSummary);
|
||||
setScheduleLlmEnabled(nextSummary.config.scheduleLlmEnabled ?? false);
|
||||
@@ -128,6 +199,13 @@ export function WechatPage() {
|
||||
setWebNotifications(nextNotifications);
|
||||
setUsers(nextUsers.items);
|
||||
setNotifyUserId((current) => current || nextUsers.items[0]?.id || '');
|
||||
setLlmKeys(nextLlmKeys);
|
||||
if (nextIntentConfig) {
|
||||
const nextForm = intentRouterToForm(nextIntentConfig, nextLlmKeys);
|
||||
setIntentForm(nextForm);
|
||||
setSavedIntentForm(nextForm);
|
||||
}
|
||||
setIntentRuntime(nextIntentRuntime);
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : '加载服务号管理失败');
|
||||
} finally {
|
||||
@@ -235,6 +313,36 @@ export function WechatPage() {
|
||||
});
|
||||
};
|
||||
|
||||
const handleSaveIntentRouter = async () => {
|
||||
if (!intentForm) return;
|
||||
setIntentSaving(true);
|
||||
setError(null);
|
||||
setNotice(null);
|
||||
try {
|
||||
const result = await patchWechatIntentRouterConfig({
|
||||
enabled: intentForm.enabled,
|
||||
shadowMode: intentForm.shadowMode,
|
||||
modelProviderKeyId: intentForm.modelProviderKeyId || null,
|
||||
model: intentForm.model || null,
|
||||
minConfidence: Number(intentForm.minConfidence) || 0.65,
|
||||
timeoutMs: Number(intentForm.timeoutMs) || 4000,
|
||||
canaryOpenids: intentForm.canaryOpenids
|
||||
.split(/[\s,]+/u)
|
||||
.map((item) => item.trim())
|
||||
.filter(Boolean),
|
||||
});
|
||||
const nextForm = intentRouterToForm(result, llmKeys);
|
||||
setIntentForm(nextForm);
|
||||
setSavedIntentForm(nextForm);
|
||||
setIntentRuntime(await getWechatIntentRouterRuntime().catch(() => null));
|
||||
setNotice('微信 LLM 意图路由配置已保存。Portal 会在下次请求时自动热加载。');
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : '保存失败');
|
||||
} finally {
|
||||
setIntentSaving(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="admin-page">
|
||||
<div className="admin-page-head">
|
||||
@@ -316,6 +424,141 @@ export function WechatPage() {
|
||||
</section>
|
||||
)}
|
||||
|
||||
{intentForm ? (
|
||||
<section className="admin-card">
|
||||
<div className="admin-card-head">
|
||||
<div>
|
||||
<h2>微信 LLM 意图路由</h2>
|
||||
<p className="muted" style={{ marginTop: 6 }}>
|
||||
独立于 H5 Router。仅对规则判为 chat.general 的消息做 LLM 二次判定(page.generate vs chat.general)。
|
||||
</p>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
className="ghost-btn"
|
||||
onClick={() => void handleSaveIntentRouter()}
|
||||
disabled={busy || intentSaving || !intentDirty}
|
||||
>
|
||||
{intentSaving ? '保存中…' : intentDirty ? '保存配置' : '已保存'}
|
||||
</button>
|
||||
</div>
|
||||
<div className="admin-form" style={{ marginTop: 16 }}>
|
||||
<label>
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={intentForm.enabled}
|
||||
disabled={busy || intentSaving}
|
||||
onChange={(event) =>
|
||||
setIntentForm((current) => current && { ...current, enabled: event.target.checked })
|
||||
}
|
||||
/>{' '}
|
||||
启用 LLM 意图层
|
||||
</label>
|
||||
<label>
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={intentForm.shadowMode}
|
||||
disabled={busy || intentSaving || !intentForm.enabled}
|
||||
onChange={(event) =>
|
||||
setIntentForm((current) => current && { ...current, shadowMode: event.target.checked })
|
||||
}
|
||||
/>{' '}
|
||||
Shadow 模式(只观测不改行为)
|
||||
</label>
|
||||
<label>
|
||||
LLM Provider Key
|
||||
<select
|
||||
value={intentForm.modelProviderKeyId}
|
||||
disabled={busy || intentSaving}
|
||||
onChange={(event) => {
|
||||
const keyId = event.target.value;
|
||||
const key = llmKeys.find((item) => item.id === keyId) ?? null;
|
||||
setIntentForm((current) =>
|
||||
current
|
||||
? {
|
||||
...current,
|
||||
modelProviderKeyId: keyId,
|
||||
model: key?.defaultModel || key?.models?.[0] || current.model,
|
||||
}
|
||||
: current,
|
||||
);
|
||||
}}
|
||||
>
|
||||
<option value="">使用全局选中 Key</option>
|
||||
{llmKeys.map((key) => (
|
||||
<option key={key.id} value={key.id}>
|
||||
{key.name} ({key.providerLabel})
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</label>
|
||||
<label>
|
||||
模型
|
||||
<input
|
||||
list="wechat-intent-models"
|
||||
value={intentForm.model}
|
||||
disabled={busy || intentSaving}
|
||||
placeholder={selectedIntentKey?.defaultModel || 'deepseek-v4-pro'}
|
||||
onChange={(event) =>
|
||||
setIntentForm((current) => current && { ...current, model: event.target.value })
|
||||
}
|
||||
/>
|
||||
<datalist id="wechat-intent-models">
|
||||
{(selectedIntentKey?.models ?? []).map((model) => (
|
||||
<option key={model} value={model} />
|
||||
))}
|
||||
</datalist>
|
||||
</label>
|
||||
<label>
|
||||
最低置信度
|
||||
<input
|
||||
type="number"
|
||||
min={0}
|
||||
max={1}
|
||||
step={0.05}
|
||||
value={intentForm.minConfidence}
|
||||
disabled={busy || intentSaving}
|
||||
onChange={(event) =>
|
||||
setIntentForm((current) => current && { ...current, minConfidence: event.target.value })
|
||||
}
|
||||
/>
|
||||
</label>
|
||||
<label>
|
||||
超时 (ms)
|
||||
<input
|
||||
type="number"
|
||||
min={500}
|
||||
max={30000}
|
||||
step={100}
|
||||
value={intentForm.timeoutMs}
|
||||
disabled={busy || intentSaving}
|
||||
onChange={(event) =>
|
||||
setIntentForm((current) => current && { ...current, timeoutMs: event.target.value })
|
||||
}
|
||||
/>
|
||||
</label>
|
||||
<label>
|
||||
Canary OpenID(每行或逗号分隔,留空=全员)
|
||||
<textarea
|
||||
value={intentForm.canaryOpenids}
|
||||
disabled={busy || intentSaving}
|
||||
rows={3}
|
||||
placeholder="oXXXX..."
|
||||
onChange={(event) =>
|
||||
setIntentForm((current) => current && { ...current, canaryOpenids: event.target.value })
|
||||
}
|
||||
/>
|
||||
</label>
|
||||
</div>
|
||||
{intentRuntime ? (
|
||||
<p className="muted" style={{ marginTop: 12 }}>
|
||||
运行时:{intentRouterRuntimeMode(intentRuntime.overrides)} · 来源 {intentRuntime.source}
|
||||
{intentRuntime.updatedAt ? ` · 更新 ${dateLabel(intentRuntime.updatedAt)}` : ''}
|
||||
</p>
|
||||
) : null}
|
||||
</section>
|
||||
) : null}
|
||||
|
||||
<section className="admin-card">
|
||||
<div className="admin-card-head">
|
||||
<h2>通知平台</h2>
|
||||
|
||||
@@ -0,0 +1,34 @@
|
||||
import type { MemoryV2AdminSection } from '../../types';
|
||||
|
||||
type CapabilityKey =
|
||||
| 'candidateMemory'
|
||||
| 'runtimeControl'
|
||||
| 'policy'
|
||||
| 'retriever'
|
||||
| 'lifecycle'
|
||||
| 'persona'
|
||||
| 'graph'
|
||||
| 'userMemory'
|
||||
| 'pluginHealth';
|
||||
|
||||
function modeEnabled(value: unknown) {
|
||||
const mode = String(value ?? 'off').trim().toLowerCase();
|
||||
return mode !== '' && mode !== 'off';
|
||||
}
|
||||
|
||||
export function isMemoryV2SectionEnabled(
|
||||
sectionKey: CapabilityKey,
|
||||
section: MemoryV2AdminSection | undefined,
|
||||
): boolean {
|
||||
const config = section ?? {};
|
||||
if (sectionKey === 'runtimeControl') {
|
||||
return Boolean(config.agentResolveEnabled)
|
||||
|| modeEnabled(config.agentInjectionMode)
|
||||
|| Boolean(config.promotionEnabled)
|
||||
|| Boolean(config.compactionV2Enabled)
|
||||
|| Boolean(config.reflectionEnabled)
|
||||
|| Boolean(config.lifecycleWorkerEnabled)
|
||||
|| modeEnabled(config.lifecycleRolloutMode);
|
||||
}
|
||||
return Boolean(config.enabled);
|
||||
}
|
||||
+230
-2
@@ -8,10 +8,14 @@ import type {
|
||||
AdminSubscription,
|
||||
AdminUserRow,
|
||||
AuthStatus,
|
||||
BillingAdminConfig,
|
||||
BillingAdminConfigResponse,
|
||||
BlockedWord,
|
||||
CapabilityDefinition,
|
||||
CapabilityMap,
|
||||
InsufficientBalanceDetails,
|
||||
ImageQuotaLedgerEntry,
|
||||
ImageQuotaView,
|
||||
LedgerEntry,
|
||||
LlmConnectionTestResult,
|
||||
LlmExecutorBinding,
|
||||
@@ -51,10 +55,15 @@ import type {
|
||||
WechatDeliveryLog,
|
||||
WechatDigestSubscription,
|
||||
WechatScheduleLlmConfig,
|
||||
WechatIntentRouterAdminConfig,
|
||||
WechatIntentRouterConfigState,
|
||||
WechatIntentRouterRuntimeState,
|
||||
WechatMessage,
|
||||
WechatWebNotification,
|
||||
MindSearchConfig,
|
||||
MindSearchServiceTestResult,
|
||||
AdminTemplateCatalogItem,
|
||||
AdminTemplateCatalogPatch,
|
||||
} from '../types';
|
||||
import type {
|
||||
OrchestratorCanaryReadiness,
|
||||
@@ -446,6 +455,25 @@ export async function updateWechatScheduleLlmConfig(
|
||||
});
|
||||
}
|
||||
|
||||
export async function getWechatIntentRouterConfig(): Promise<WechatIntentRouterAdminConfig> {
|
||||
const result = await portalFetch<WechatIntentRouterConfigState>('/admin-api/wechat/intent-router/config');
|
||||
return result.config;
|
||||
}
|
||||
|
||||
export async function patchWechatIntentRouterConfig(
|
||||
patch: Partial<Omit<WechatIntentRouterAdminConfig, 'updatedAt' | 'updatedBy'>>,
|
||||
): Promise<WechatIntentRouterAdminConfig> {
|
||||
const result = await portalFetch<WechatIntentRouterConfigState>('/admin-api/wechat/intent-router/config', {
|
||||
method: 'PATCH',
|
||||
body: JSON.stringify(patch),
|
||||
});
|
||||
return result.config;
|
||||
}
|
||||
|
||||
export async function getWechatIntentRouterRuntime(): Promise<WechatIntentRouterRuntimeState> {
|
||||
return portalFetch<WechatIntentRouterRuntimeState>('/admin-api/wechat/intent-router/runtime');
|
||||
}
|
||||
|
||||
export async function getAssetGatewayConfig(): Promise<AssetGatewayConfig> {
|
||||
return portalFetch('/admin-api/asset-gateway/config');
|
||||
}
|
||||
@@ -485,9 +513,110 @@ export async function getUmamiSsoUrl(): Promise<string> {
|
||||
return result.url;
|
||||
}
|
||||
|
||||
export type SeoGeoAnalyticsQuery = {
|
||||
discoveryChannel: 'seo' | 'geo';
|
||||
startAt: number;
|
||||
endAt: number;
|
||||
timezone?: string;
|
||||
page?: number;
|
||||
pageSize?: number;
|
||||
sortBy?: string;
|
||||
sortOrder?: 'asc' | 'desc';
|
||||
generatedStartAt?: number;
|
||||
generatedEndAt?: number;
|
||||
};
|
||||
|
||||
export type SeoGeoAnalyticsPagesResult = {
|
||||
discoveryChannel: 'seo' | 'geo';
|
||||
data: Array<{
|
||||
urlPath: string;
|
||||
pageUrl?: string;
|
||||
hostname?: string;
|
||||
pageTitle: string;
|
||||
views: number;
|
||||
visitors: number;
|
||||
visits: number;
|
||||
clicks: number;
|
||||
engagedVisits: number;
|
||||
forms: number;
|
||||
generatedAt?: string | null;
|
||||
generatedAtInferred?: boolean;
|
||||
firstSeenAt?: string | null;
|
||||
}>;
|
||||
total: number;
|
||||
totalViews: number;
|
||||
page: number;
|
||||
pageSize: number;
|
||||
};
|
||||
|
||||
export type SeoGeoCatalogQuery = {
|
||||
startAt: number;
|
||||
endAt: number;
|
||||
page?: number;
|
||||
pageSize?: number;
|
||||
sortBy?: string;
|
||||
sortOrder?: 'asc' | 'desc';
|
||||
search?: string;
|
||||
};
|
||||
|
||||
export type SeoGeoCatalogRow = {
|
||||
publicationId: string;
|
||||
pageId: string;
|
||||
userId: string;
|
||||
ownerLabel: string;
|
||||
username: string;
|
||||
pageTitle: string;
|
||||
pageSummary: string;
|
||||
publicUrl: string;
|
||||
accessMode: string;
|
||||
indexable: boolean;
|
||||
publishedAt: number | null;
|
||||
lifetimeViewCount: number;
|
||||
totalViews: number;
|
||||
seoViews: number;
|
||||
geoViews: number;
|
||||
crawlerViews: number;
|
||||
};
|
||||
|
||||
export type SeoGeoCatalogResult = {
|
||||
data: SeoGeoCatalogRow[];
|
||||
total: number;
|
||||
page: number;
|
||||
pageSize: number;
|
||||
totals: {
|
||||
totalViews: number;
|
||||
seoViews: number;
|
||||
geoViews: number;
|
||||
crawlerViews: number;
|
||||
};
|
||||
};
|
||||
|
||||
export async function getSeoGeoPublicationCatalog(
|
||||
query: SeoGeoCatalogQuery,
|
||||
): Promise<SeoGeoCatalogResult> {
|
||||
const params = new URLSearchParams();
|
||||
Object.entries(query).forEach(([key, value]) => {
|
||||
if (value !== undefined && value !== null && value !== '') {
|
||||
params.set(key, String(value));
|
||||
}
|
||||
});
|
||||
return portalFetch(`/admin-api/analytics/seo-geo-catalog?${params.toString()}`);
|
||||
}
|
||||
|
||||
export async function getSeoGeoAnalyticsPages(
|
||||
query: SeoGeoAnalyticsQuery,
|
||||
): Promise<SeoGeoAnalyticsPagesResult> {
|
||||
const params = new URLSearchParams();
|
||||
Object.entries(query).forEach(([key, value]) => {
|
||||
if (value !== undefined && value !== null && value !== '') {
|
||||
params.set(key, String(value));
|
||||
}
|
||||
});
|
||||
return portalFetch(`/admin-api/analytics/seo-geo-pages?${params.toString()}`);
|
||||
}
|
||||
|
||||
export async function getRybbitSsoUrl(): Promise<string> {
|
||||
const result = await portalFetch<{ url: string }>('/admin-api/analytics/rybbit-sso');
|
||||
return result.url;
|
||||
throw new Error('Rybbit 已退役,请使用 SEO / GEO 流量看板或 Umami');
|
||||
}
|
||||
|
||||
export async function updateMindSpaceAdminConfig(
|
||||
@@ -1280,6 +1409,19 @@ export async function syncSubscriptionPlansToProduction(): Promise<PlanSyncResul
|
||||
return result.sync;
|
||||
}
|
||||
|
||||
export async function getBillingConfig(): Promise<BillingAdminConfigResponse> {
|
||||
return portalFetch('/admin-api/billing/config');
|
||||
}
|
||||
|
||||
export async function updateBillingConfig(
|
||||
config: BillingAdminConfig,
|
||||
): Promise<BillingAdminConfigResponse> {
|
||||
return portalFetch('/admin-api/billing/config', {
|
||||
method: 'PUT',
|
||||
body: JSON.stringify({ config }),
|
||||
});
|
||||
}
|
||||
|
||||
export async function listAdminSubscriptions(opts?: {
|
||||
userId?: string;
|
||||
status?: string;
|
||||
@@ -1329,3 +1471,89 @@ export async function getUserSubscription(userId: string): Promise<AdminSubscrip
|
||||
);
|
||||
return result.subscription;
|
||||
}
|
||||
|
||||
export async function fetchImageQuotaConfig() {
|
||||
return portalFetch<{ plans: PlanDefinition[] }>('/admin-api/image-quota/config');
|
||||
}
|
||||
|
||||
export async function patchImageQuotaPlan(planType: string, periodImages: number) {
|
||||
return portalFetch<{ ok: boolean; plan: PlanDefinition }>(`/admin-api/image-quota/config/${planType}`, {
|
||||
method: 'PATCH',
|
||||
body: JSON.stringify({ periodImages }),
|
||||
});
|
||||
}
|
||||
|
||||
export async function fetchUserImageQuota(userId: string) {
|
||||
return portalFetch<{
|
||||
subscription: AdminSubscription;
|
||||
quota: ImageQuotaView;
|
||||
ledger: ImageQuotaLedgerEntry[];
|
||||
}>(`/admin-api/users/${userId}/image-quota`);
|
||||
}
|
||||
|
||||
export async function setUserImageQuota(
|
||||
userId: string,
|
||||
payload: { remaining?: number; total?: number },
|
||||
note = '',
|
||||
) {
|
||||
return portalFetch<{
|
||||
ok: boolean;
|
||||
unchanged?: boolean;
|
||||
subscription: AdminSubscription;
|
||||
quota: ImageQuotaView;
|
||||
}>(`/admin-api/users/${userId}/image-quota`, {
|
||||
method: 'PUT',
|
||||
body: JSON.stringify({ ...payload, note }),
|
||||
});
|
||||
}
|
||||
|
||||
export async function grantUserImageQuota(userId: string, delta: number, note = '') {
|
||||
return portalFetch<{
|
||||
ok: boolean;
|
||||
subscription: AdminSubscription;
|
||||
quota: ImageQuotaView;
|
||||
}>(`/admin-api/users/${userId}/image-quota/grant`, {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({ delta, note }),
|
||||
});
|
||||
}
|
||||
|
||||
export async function fetchImageQuotaLedger(params: {
|
||||
userId?: string;
|
||||
page?: number;
|
||||
pageSize?: number;
|
||||
} = {}) {
|
||||
const q = new URLSearchParams();
|
||||
if (params.userId) q.set('userId', params.userId);
|
||||
if (params.page) q.set('page', String(params.page));
|
||||
if (params.pageSize) q.set('pageSize', String(params.pageSize));
|
||||
return portalFetch<{
|
||||
entries: ImageQuotaLedgerEntry[];
|
||||
total: number;
|
||||
page: number;
|
||||
pageSize: number;
|
||||
totalPages: number;
|
||||
}>(`/admin-api/image-quota/ledger${q.toString() ? `?${q}` : ''}`);
|
||||
}
|
||||
|
||||
export async function fetchAdminTemplateCatalog(): Promise<AdminTemplateCatalogItem[]> {
|
||||
const result = await portalFetch<{ items: AdminTemplateCatalogItem[] }>('/admin-api/template-catalog');
|
||||
return result.items ?? [];
|
||||
}
|
||||
|
||||
export async function updateAdminTemplateCatalogItem(
|
||||
skillName: string,
|
||||
patch: AdminTemplateCatalogPatch,
|
||||
): Promise<{ ok: boolean; item?: AdminTemplateCatalogItem }> {
|
||||
return portalFetch(`/admin-api/template-catalog/${encodeURIComponent(skillName)}`, {
|
||||
method: 'PUT',
|
||||
body: JSON.stringify(patch),
|
||||
});
|
||||
}
|
||||
|
||||
export async function grantUserPageTemplate(userId: string, skillName: string) {
|
||||
return portalFetch<{ ok: boolean; skillName: string; grantedSkills?: string[] }>(
|
||||
`/admin-api/users/${encodeURIComponent(userId)}/template-grants/${encodeURIComponent(skillName)}`,
|
||||
{ method: 'POST' },
|
||||
);
|
||||
}
|
||||
|
||||
@@ -2740,3 +2740,60 @@ body,
|
||||
display: flex;
|
||||
gap: 6px;
|
||||
}
|
||||
|
||||
.template-catalog-table .admin-inline-input,
|
||||
.template-catalog-table .admin-inline-textarea,
|
||||
.template-catalog-table select {
|
||||
width: 100%;
|
||||
min-width: 108px;
|
||||
padding: 8px 10px;
|
||||
border-radius: var(--radius-md);
|
||||
border: 1px solid var(--color-border-input);
|
||||
background: var(--color-bg-base);
|
||||
color: var(--color-text-primary);
|
||||
font: inherit;
|
||||
}
|
||||
|
||||
.template-catalog-table .admin-inline-input-narrow {
|
||||
min-width: 72px;
|
||||
max-width: 96px;
|
||||
}
|
||||
|
||||
.template-catalog-table .admin-inline-textarea {
|
||||
min-height: 72px;
|
||||
resize: vertical;
|
||||
}
|
||||
|
||||
.template-catalog-thumb {
|
||||
width: 96px;
|
||||
height: 54px;
|
||||
object-fit: cover;
|
||||
border-radius: var(--radius-md);
|
||||
border: 1px solid var(--color-border-input);
|
||||
display: block;
|
||||
}
|
||||
|
||||
.template-catalog-thumb-fallback {
|
||||
background: linear-gradient(135deg, #0a1628, #2f6f57);
|
||||
}
|
||||
|
||||
.admin-inline-link {
|
||||
display: inline-block;
|
||||
margin-top: 6px;
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.template-grant-list {
|
||||
display: grid;
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
.template-grant-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 12px;
|
||||
padding: 10px 12px;
|
||||
border: 1px solid var(--color-border-input);
|
||||
border-radius: var(--radius-md);
|
||||
}
|
||||
|
||||
+114
@@ -244,6 +244,7 @@ export type UsageRecord = {
|
||||
outputTokens: number;
|
||||
costCents: number;
|
||||
balanceAfterCents: number;
|
||||
billingSource: 'wallet' | 'subscription';
|
||||
createdAt: number;
|
||||
};
|
||||
|
||||
@@ -282,6 +283,7 @@ export type LedgerEntry = {
|
||||
id: number;
|
||||
userId: string;
|
||||
username: string;
|
||||
displayName: string;
|
||||
type: 'recharge' | 'deduct' | 'refund' | 'adjust';
|
||||
amountCents: number;
|
||||
tokens: number;
|
||||
@@ -317,8 +319,50 @@ export type WechatScheduleLlmConfig = {
|
||||
updatedBy: string | null;
|
||||
};
|
||||
|
||||
export type WechatIntentRouterAdminConfig = {
|
||||
enabled: boolean;
|
||||
shadowMode: boolean;
|
||||
modelProviderKeyId: string | null;
|
||||
model: string | null;
|
||||
minConfidence: number;
|
||||
timeoutMs: number;
|
||||
canaryOpenids: string[];
|
||||
updatedAt?: number | null;
|
||||
updatedBy?: string | null;
|
||||
};
|
||||
|
||||
export type WechatIntentRouterConfigState = {
|
||||
config: WechatIntentRouterAdminConfig;
|
||||
};
|
||||
|
||||
export type WechatIntentRouterRuntimeState = {
|
||||
source: string;
|
||||
updatedAt: number | null;
|
||||
updatedBy: string | null;
|
||||
fingerprint?: string;
|
||||
overrides: Record<string, string>;
|
||||
config: WechatIntentRouterAdminConfig;
|
||||
};
|
||||
|
||||
export type MindSpaceSeoGeoConfig = {
|
||||
enabled: boolean;
|
||||
seo: {
|
||||
enabled: boolean;
|
||||
canonical: boolean;
|
||||
sitemap: boolean;
|
||||
robotsTxt: boolean;
|
||||
baiduPush: boolean;
|
||||
};
|
||||
geo: {
|
||||
enabled: boolean;
|
||||
jsonLd: boolean;
|
||||
llmsTxt: boolean;
|
||||
};
|
||||
};
|
||||
|
||||
export type MindSpaceAdminConfig = {
|
||||
publicPageLimit: number;
|
||||
seoGeo: MindSpaceSeoGeoConfig;
|
||||
analytics: {
|
||||
enabled: boolean;
|
||||
websiteId: string;
|
||||
@@ -620,6 +664,27 @@ export type BlockedWord = {
|
||||
updated_at: number;
|
||||
};
|
||||
|
||||
export type BillingAdminConfig = {
|
||||
useBackendCost: boolean;
|
||||
usdCnyRate: number;
|
||||
marginMultiplier: number;
|
||||
inputCentsPer1k: number;
|
||||
outputCentsPer1k: number;
|
||||
minBillCents: number;
|
||||
costEstimateFromTokens: boolean;
|
||||
costEstimateInputUsdPer1M: number;
|
||||
costEstimateOutputUsdPer1M: number;
|
||||
};
|
||||
|
||||
export type BillingAdminConfigResponse = {
|
||||
config: BillingAdminConfig;
|
||||
updatedAt: number | null;
|
||||
updatedBy: string | null;
|
||||
source?: string;
|
||||
envOverrideActive?: boolean;
|
||||
formula?: string;
|
||||
};
|
||||
|
||||
export type PlanDefinition = {
|
||||
planType: string;
|
||||
name: string;
|
||||
@@ -656,6 +721,32 @@ export type AdminSubscription = {
|
||||
periodTokensUsed: number;
|
||||
periodImagesLimit: number;
|
||||
periodImagesUsed: number;
|
||||
periodImagesBonus?: number;
|
||||
note: string | null;
|
||||
createdAt: number;
|
||||
};
|
||||
|
||||
export type ImageQuotaView = {
|
||||
limit: number;
|
||||
bonus: number;
|
||||
used: number;
|
||||
total: number | null;
|
||||
remaining: number | null;
|
||||
unlimited: boolean;
|
||||
periodEnd: number | null;
|
||||
planType: string | null;
|
||||
};
|
||||
|
||||
export type ImageQuotaLedgerEntry = {
|
||||
id: string;
|
||||
userId: string;
|
||||
username?: string;
|
||||
displayName?: string;
|
||||
delta: number;
|
||||
balanceAfter: number | null;
|
||||
reason: string;
|
||||
refId: string | null;
|
||||
operatorId: string | null;
|
||||
note: string | null;
|
||||
createdAt: number;
|
||||
};
|
||||
@@ -701,3 +792,26 @@ export type MindSearchServiceTestResult = {
|
||||
resultCount?: number | null;
|
||||
message: string;
|
||||
};
|
||||
|
||||
export type AdminTemplateCatalogItem = {
|
||||
skillName: string;
|
||||
label: string;
|
||||
description: string;
|
||||
previewUrl: string | null;
|
||||
priceCents: number;
|
||||
currency: string;
|
||||
billingMode: 'free' | 'one_time' | 'subscription';
|
||||
status: 'draft' | 'active' | 'archived';
|
||||
sortOrder: number;
|
||||
chatSkillId: string;
|
||||
};
|
||||
|
||||
export type AdminTemplateCatalogPatch = {
|
||||
label?: string;
|
||||
description?: string;
|
||||
previewUrl?: string | null;
|
||||
priceCents?: number;
|
||||
billingMode?: AdminTemplateCatalogItem['billingMode'];
|
||||
status?: AdminTemplateCatalogItem['status'];
|
||||
sortOrder?: number;
|
||||
};
|
||||
|
||||
+2
-2
@@ -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