Compare commits

..

1 Commits

Author SHA1 Message Date
john 458f291985 docs: clarify Rybbit env requirements on Analytics config page
Explain that Rybbit reuses the stored anonymization secret and needs Portal
MEMIND_RYBBIT_* environment variables.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-07-27 22:10:07 +08:00
21 changed files with 73 additions and 2963 deletions
-13
View File
@@ -19,16 +19,3 @@ bash scripts/check-release-ready.sh
- `memind_adm` 可以独立开发,但共享用户、权限、策略、技能、计费体系必须继续复用 `Memind` 主实现。
- 发版须 Git commit,禁止本机直 `rsync``103/105`
- 共享用户、计费、空间额度、策略同步相关改动必须保留业务验收记录。
## 必读:本仓库是唯一合法的管理后台 UI(5174)
**所有平台管理后台的前端功能只能在本仓库(memind_adm)开发,本地端口 5174,生产 gadm。禁止在 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 | 业务逻辑与 PortalUI 不在此仓库 |
新增管理功能时:在本仓库添加 `src/admin/pages/*`、更新 `AdminNav.tsx``App.tsx`;若需新 API,在 `server/app.mjs` 挂载并复用 Memind 模块。Memind 侧仅实现共享业务,不在 `ops/` 做 UI。
+2 -400
View File
@@ -20,7 +20,6 @@
"react": "^19.0.0",
"react-dom": "^19.0.0",
"react-router-dom": "^7.13.1",
"recharts": "^3.10.1",
"redis": "^4.7.1",
"sharp": "0.35.2",
"undici": "^6.26.0"
@@ -1712,32 +1711,6 @@
"@redis/client": "^1.0.0"
}
},
"node_modules/@reduxjs/toolkit": {
"version": "2.12.0",
"resolved": "https://registry.npmjs.org/@reduxjs/toolkit/-/toolkit-2.12.0.tgz",
"integrity": "sha512-KiT+RzZbp6mQET+Mg+h2c97+9j1sNflUxQkIHI7Yuzf6Peu+OYpmkn6nbHWmLLWj+1ZODUJFwGZ7gx3L9R9EOw==",
"license": "MIT",
"dependencies": {
"@standard-schema/spec": "^1.0.0",
"@standard-schema/utils": "^0.3.0",
"immer": "^11.0.0",
"redux": "^5.0.1",
"redux-thunk": "^3.1.0",
"reselect": "^5.1.0"
},
"peerDependencies": {
"react": "^16.9.0 || ^17.0.0 || ^18 || ^19",
"react-redux": "^7.2.1 || ^8.1.3 || ^9.0.0"
},
"peerDependenciesMeta": {
"react": {
"optional": true
},
"react-redux": {
"optional": true
}
}
},
"node_modules/@resvg/resvg-js": {
"version": "2.6.2",
"resolved": "https://registry.npmjs.org/@resvg/resvg-js/-/resvg-js-2.6.2.tgz",
@@ -2322,18 +2295,6 @@
"win32"
]
},
"node_modules/@standard-schema/spec": {
"version": "1.1.0",
"resolved": "https://registry.npmjs.org/@standard-schema/spec/-/spec-1.1.0.tgz",
"integrity": "sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w==",
"license": "MIT"
},
"node_modules/@standard-schema/utils": {
"version": "0.3.0",
"resolved": "https://registry.npmjs.org/@standard-schema/utils/-/utils-0.3.0.tgz",
"integrity": "sha512-e7Mew686owMaPJVNNLs55PUvgz371nKgwsc4vxE49zsODpJEnxgxRo2y/OKrqueavXgZNMDVj3DdHFlaSAeU8g==",
"license": "MIT"
},
"node_modules/@tybys/wasm-util": {
"version": "0.10.2",
"resolved": "https://registry.npmjs.org/@tybys/wasm-util/-/wasm-util-0.10.2.tgz",
@@ -2389,69 +2350,6 @@
"@babel/types": "^7.28.2"
}
},
"node_modules/@types/d3-array": {
"version": "3.2.2",
"resolved": "https://registry.npmjs.org/@types/d3-array/-/d3-array-3.2.2.tgz",
"integrity": "sha512-hOLWVbm7uRza0BYXpIIW5pxfrKe0W+D5lrFiAEYR+pb6w3N2SwSMaJbXdUfSEv+dT4MfHBLtn5js0LAWaO6otw==",
"license": "MIT"
},
"node_modules/@types/d3-color": {
"version": "3.1.3",
"resolved": "https://registry.npmjs.org/@types/d3-color/-/d3-color-3.1.3.tgz",
"integrity": "sha512-iO90scth9WAbmgv7ogoq57O9YpKmFBbmoEoCHDB2xMBY0+/KVrqAaCDyCE16dUspeOvIxFFRI+0sEtqDqy2b4A==",
"license": "MIT"
},
"node_modules/@types/d3-ease": {
"version": "3.0.2",
"resolved": "https://registry.npmjs.org/@types/d3-ease/-/d3-ease-3.0.2.tgz",
"integrity": "sha512-NcV1JjO5oDzoK26oMzbILE6HW7uVXOHLQvHshBUW4UMdZGfiY6v5BeQwh9a9tCzv+CeefZQHJt5SRgK154RtiA==",
"license": "MIT"
},
"node_modules/@types/d3-interpolate": {
"version": "3.0.4",
"resolved": "https://registry.npmjs.org/@types/d3-interpolate/-/d3-interpolate-3.0.4.tgz",
"integrity": "sha512-mgLPETlrpVV1YRJIglr4Ez47g7Yxjl1lj7YKsiMCb27VJH9W8NVM6Bb9d8kkpG/uAQS5AmbA48q2IAolKKo1MA==",
"license": "MIT",
"dependencies": {
"@types/d3-color": "*"
}
},
"node_modules/@types/d3-path": {
"version": "3.1.1",
"resolved": "https://registry.npmjs.org/@types/d3-path/-/d3-path-3.1.1.tgz",
"integrity": "sha512-VMZBYyQvbGmWyWVea0EHs/BwLgxc+MKi1zLDCONksozI4YJMcTt8ZEuIR4Sb1MMTE8MMW49v0IwI5+b7RmfWlg==",
"license": "MIT"
},
"node_modules/@types/d3-scale": {
"version": "4.0.9",
"resolved": "https://registry.npmjs.org/@types/d3-scale/-/d3-scale-4.0.9.tgz",
"integrity": "sha512-dLmtwB8zkAeO/juAMfnV+sItKjlsw2lKdZVVy6LRr0cBmegxSABiLEpGVmSJJ8O08i4+sGR6qQtb6WtuwJdvVw==",
"license": "MIT",
"dependencies": {
"@types/d3-time": "*"
}
},
"node_modules/@types/d3-shape": {
"version": "3.1.8",
"resolved": "https://registry.npmjs.org/@types/d3-shape/-/d3-shape-3.1.8.tgz",
"integrity": "sha512-lae0iWfcDeR7qt7rA88BNiqdvPS5pFVPpo5OfjElwNaT2yyekbM0C9vK+yqBqEmHr6lDkRnYNoTBYlAgJa7a4w==",
"license": "MIT",
"dependencies": {
"@types/d3-path": "*"
}
},
"node_modules/@types/d3-time": {
"version": "3.0.4",
"resolved": "https://registry.npmjs.org/@types/d3-time/-/d3-time-3.0.4.tgz",
"integrity": "sha512-yuzZug1nkAAaBlBBikKZTgzCeA+k1uy4ZFwWANOfKw5z5LRhV0gNA7gNkKm7HoK+HRN0wX3EkxGk0fpbWhmB7g==",
"license": "MIT"
},
"node_modules/@types/d3-timer": {
"version": "3.0.2",
"resolved": "https://registry.npmjs.org/@types/d3-timer/-/d3-timer-3.0.2.tgz",
"integrity": "sha512-Ps3T8E8dZDam6fUyNiMkekK3XUsaUEik+idO9/YjPtfj2qruF8tFBXS7XhtE4iIXBLxhmLjP3SXpLhVf21I9Lw==",
"license": "MIT"
},
"node_modules/@types/estree": {
"version": "1.0.9",
"resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.9.tgz",
@@ -2481,7 +2379,7 @@
"version": "19.2.17",
"resolved": "https://registry.npmjs.org/@types/react/-/react-19.2.17.tgz",
"integrity": "sha512-MXfmqaVPEVgkBT/aY0aGCkRWWtByiYQXo3xdQ8r5RzuFrPiRn8Gar2tQdXSUQ2GKV3bkXckek89V8wQBY2Q/Aw==",
"devOptional": true,
"dev": true,
"license": "MIT",
"dependencies": {
"csstype": "^3.2.2"
@@ -2497,12 +2395,6 @@
"@types/react": "^19.2.0"
}
},
"node_modules/@types/use-sync-external-store": {
"version": "0.0.6",
"resolved": "https://registry.npmjs.org/@types/use-sync-external-store/-/use-sync-external-store-0.0.6.tgz",
"integrity": "sha512-zFDAD+tlpf2r4asuHEj0XH6pY6i0g5NeAHPn+15wk3BV6JA69eERFXC1gyGThDkVa1zCyKr5jox1+2LbV/AMLg==",
"license": "MIT"
},
"node_modules/@vitejs/plugin-react": {
"version": "4.7.0",
"resolved": "https://registry.npmjs.org/@vitejs/plugin-react/-/plugin-react-4.7.0.tgz",
@@ -2753,15 +2645,6 @@
"wrap-ansi": "^6.2.0"
}
},
"node_modules/clsx": {
"version": "2.1.1",
"resolved": "https://registry.npmjs.org/clsx/-/clsx-2.1.1.tgz",
"integrity": "sha512-eYm0QWBtUrBWZWG0d386OGAw16Z995PiOVo2B7bjWSbHedGl5e0ZWaq65kOGgUSNesEIDkB9ISbTg/JK9dhCZA==",
"license": "MIT",
"engines": {
"node": ">=6"
}
},
"node_modules/cluster-key-slot": {
"version": "1.1.2",
"resolved": "https://registry.npmjs.org/cluster-key-slot/-/cluster-key-slot-1.1.2.tgz",
@@ -2840,130 +2723,9 @@
"version": "3.2.3",
"resolved": "https://registry.npmjs.org/csstype/-/csstype-3.2.3.tgz",
"integrity": "sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==",
"devOptional": true,
"dev": true,
"license": "MIT"
},
"node_modules/d3-array": {
"version": "3.2.4",
"resolved": "https://registry.npmjs.org/d3-array/-/d3-array-3.2.4.tgz",
"integrity": "sha512-tdQAmyA18i4J7wprpYq8ClcxZy3SC31QMeByyCFyRt7BVHdREQZ5lpzoe5mFEYZUWe+oq8HBvk9JjpibyEV4Jg==",
"license": "ISC",
"dependencies": {
"internmap": "1 - 2"
},
"engines": {
"node": ">=12"
}
},
"node_modules/d3-color": {
"version": "3.1.0",
"resolved": "https://registry.npmjs.org/d3-color/-/d3-color-3.1.0.tgz",
"integrity": "sha512-zg/chbXyeBtMQ1LbD/WSoW2DpC3I0mpmPdW+ynRTj/x2DAWYrIY7qeZIHidozwV24m4iavr15lNwIwLxRmOxhA==",
"license": "ISC",
"engines": {
"node": ">=12"
}
},
"node_modules/d3-ease": {
"version": "3.0.1",
"resolved": "https://registry.npmjs.org/d3-ease/-/d3-ease-3.0.1.tgz",
"integrity": "sha512-wR/XK3D3XcLIZwpbvQwQ5fK+8Ykds1ip7A2Txe0yxncXSdq1L9skcG7blcedkOX+ZcgxGAmLX1FrRGbADwzi0w==",
"license": "BSD-3-Clause",
"engines": {
"node": ">=12"
}
},
"node_modules/d3-format": {
"version": "3.1.2",
"resolved": "https://registry.npmjs.org/d3-format/-/d3-format-3.1.2.tgz",
"integrity": "sha512-AJDdYOdnyRDV5b6ArilzCPPwc1ejkHcoyFarqlPqT7zRYjhavcT3uSrqcMvsgh2CgoPbK3RCwyHaVyxYcP2Arg==",
"license": "ISC",
"engines": {
"node": ">=12"
}
},
"node_modules/d3-interpolate": {
"version": "3.0.1",
"resolved": "https://registry.npmjs.org/d3-interpolate/-/d3-interpolate-3.0.1.tgz",
"integrity": "sha512-3bYs1rOD33uo8aqJfKP3JWPAibgw8Zm2+L9vBKEHJ2Rg+viTR7o5Mmv5mZcieN+FRYaAOWX5SJATX6k1PWz72g==",
"license": "ISC",
"dependencies": {
"d3-color": "1 - 3"
},
"engines": {
"node": ">=12"
}
},
"node_modules/d3-path": {
"version": "3.1.0",
"resolved": "https://registry.npmjs.org/d3-path/-/d3-path-3.1.0.tgz",
"integrity": "sha512-p3KP5HCf/bvjBSSKuXid6Zqijx7wIfNW+J/maPs+iwR35at5JCbLUT0LzF1cnjbCHWhqzQTIN2Jpe8pRebIEFQ==",
"license": "ISC",
"engines": {
"node": ">=12"
}
},
"node_modules/d3-scale": {
"version": "4.0.2",
"resolved": "https://registry.npmjs.org/d3-scale/-/d3-scale-4.0.2.tgz",
"integrity": "sha512-GZW464g1SH7ag3Y7hXjf8RoUuAFIqklOAq3MRl4OaWabTFJY9PN/E1YklhXLh+OQ3fM9yS2nOkCoS+WLZ6kvxQ==",
"license": "ISC",
"dependencies": {
"d3-array": "2.10.0 - 3",
"d3-format": "1 - 3",
"d3-interpolate": "1.2.0 - 3",
"d3-time": "2.1.1 - 3",
"d3-time-format": "2 - 4"
},
"engines": {
"node": ">=12"
}
},
"node_modules/d3-shape": {
"version": "3.2.0",
"resolved": "https://registry.npmjs.org/d3-shape/-/d3-shape-3.2.0.tgz",
"integrity": "sha512-SaLBuwGm3MOViRq2ABk3eLoxwZELpH6zhl3FbAoJ7Vm1gofKx6El1Ib5z23NUEhF9AsGl7y+dzLe5Cw2AArGTA==",
"license": "ISC",
"dependencies": {
"d3-path": "^3.1.0"
},
"engines": {
"node": ">=12"
}
},
"node_modules/d3-time": {
"version": "3.1.0",
"resolved": "https://registry.npmjs.org/d3-time/-/d3-time-3.1.0.tgz",
"integrity": "sha512-VqKjzBLejbSMT4IgbmVgDjpkYrNWUYJnbCGo874u7MMKIWsILRX+OpX/gTk8MqjpT1A/c6HY2dCA77ZN0lkQ2Q==",
"license": "ISC",
"dependencies": {
"d3-array": "2 - 3"
},
"engines": {
"node": ">=12"
}
},
"node_modules/d3-time-format": {
"version": "4.1.0",
"resolved": "https://registry.npmjs.org/d3-time-format/-/d3-time-format-4.1.0.tgz",
"integrity": "sha512-dJxPBlzC7NugB2PDLwo9Q8JiTR3M3e4/XANkreKSUxF8vvXKqm1Yfq4Q5dl8budlunRVlUUaDUgFt7eA8D6NLg==",
"license": "ISC",
"dependencies": {
"d3-time": "1 - 3"
},
"engines": {
"node": ">=12"
}
},
"node_modules/d3-timer": {
"version": "3.0.1",
"resolved": "https://registry.npmjs.org/d3-timer/-/d3-timer-3.0.1.tgz",
"integrity": "sha512-ndfJ/JxxMd3nw31uyKoY2naivF+r29V+Lc0svZxe1JvvIRmi8hUsrMvdOwgS1o6uBHmiz91geQ0ylPP0aj1VUA==",
"license": "ISC",
"engines": {
"node": ">=12"
}
},
"node_modules/debug": {
"version": "4.4.3",
"resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz",
@@ -2990,12 +2752,6 @@
"node": ">=0.10.0"
}
},
"node_modules/decimal.js-light": {
"version": "2.5.1",
"resolved": "https://registry.npmjs.org/decimal.js-light/-/decimal.js-light-2.5.1.tgz",
"integrity": "sha512-qIMFpTMZmny+MMIitAB6D7iVPEorVw6YQRWkvarTkT4tBeSLLiHzcwj6q0MmYSFCiVpiqPJTJEYIrpcPzVEIvg==",
"license": "MIT"
},
"node_modules/denque": {
"version": "2.1.0",
"resolved": "https://registry.npmjs.org/denque/-/denque-2.1.0.tgz",
@@ -3111,17 +2867,6 @@
"node": ">= 0.4"
}
},
"node_modules/es-toolkit": {
"version": "1.50.0",
"resolved": "https://registry.npmjs.org/es-toolkit/-/es-toolkit-1.50.0.tgz",
"integrity": "sha512-OyZKhUVvEep9ITEiwHn8GKnMRQIVqoSIX7WnRbkWgJkllCujilqP2rD0u979tkl8wqyc8ICwlc1UBVv/Sl1G6w==",
"license": "MIT",
"workspaces": [
"docs",
"benchmarks",
"tests/types"
]
},
"node_modules/esbuild": {
"version": "0.25.12",
"resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.25.12.tgz",
@@ -3576,31 +3321,12 @@
"node": ">=0.10.0"
}
},
"node_modules/immer": {
"version": "11.1.15",
"resolved": "https://registry.npmjs.org/immer/-/immer-11.1.15.tgz",
"integrity": "sha512-VrNANlmnWQnh5COXIIOQXM9oOJw7naGKlBT74ZOOR6lpVXc3gFEu9FJLDFcpCJ2j+NWr8TIwtWD//T6ZX6TKiQ==",
"license": "MIT",
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/immer"
}
},
"node_modules/inherits": {
"version": "2.0.4",
"resolved": "https://registry.npmjs.org/inherits/-/inherits-2.0.4.tgz",
"integrity": "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==",
"license": "ISC"
},
"node_modules/internmap": {
"version": "2.0.3",
"resolved": "https://registry.npmjs.org/internmap/-/internmap-2.0.3.tgz",
"integrity": "sha512-5Hh7Y1wQbvY5ooGgPbDaL5iYLAPzMTUrjMulskHLH6wnv/A+1q5rgEaiuqEjB+oxGXIVZs1FF+R/KPN3ZSQYYg==",
"license": "ISC",
"engines": {
"node": ">=12"
}
},
"node_modules/ipaddr.js": {
"version": "1.9.1",
"resolved": "https://registry.npmjs.org/ipaddr.js/-/ipaddr.js-1.9.1.tgz",
@@ -4297,36 +4023,6 @@
"react": "^19.2.7"
}
},
"node_modules/react-is": {
"version": "19.2.8",
"resolved": "https://registry.npmjs.org/react-is/-/react-is-19.2.8.tgz",
"integrity": "sha512-s5un28nYxKJw5gvUHyW5PCC28CvBqLu9r3cWgzHT4Vo/5fqqkFcdRYsGcKf50WMPpjjFZS5d76fn3YCo2njKwQ==",
"license": "MIT",
"peer": true
},
"node_modules/react-redux": {
"version": "9.3.0",
"resolved": "https://registry.npmjs.org/react-redux/-/react-redux-9.3.0.tgz",
"integrity": "sha512-KQopgqFo/p/fgmAs5qz6p5RWaNAzq40WAu7fJIXnQpYxFPbJYtsJPWvGeF2rOBaY/kEuV77AVsX8TsQzKm+A/g==",
"license": "MIT",
"dependencies": {
"@types/use-sync-external-store": "^0.0.6",
"use-sync-external-store": "^1.4.0"
},
"peerDependencies": {
"@types/react": "^18.2.25 || ^19",
"react": "^18.0 || ^19",
"redux": "^5.0.0"
},
"peerDependenciesMeta": {
"@types/react": {
"optional": true
},
"redux": {
"optional": true
}
}
},
"node_modules/react-refresh": {
"version": "0.17.0",
"resolved": "https://registry.npmjs.org/react-refresh/-/react-refresh-0.17.0.tgz",
@@ -4375,42 +4071,6 @@
"react-dom": ">=18"
}
},
"node_modules/recharts": {
"version": "3.10.1",
"resolved": "https://registry.npmjs.org/recharts/-/recharts-3.10.1.tgz",
"integrity": "sha512-QXFrvt6IVcw7eeZCoyXTwkIJAX3Dv1nyVhMicXJ47GsGDDpcN8z6o644DibE9XjpBTThtsomLKnTV6lc+cVFUA==",
"license": "MIT",
"workspaces": [
"www"
],
"dependencies": {
"@reduxjs/toolkit": "^1.9.0 || 2.x.x",
"clsx": "^2.1.1",
"decimal.js-light": "^2.5.1",
"es-toolkit": "^1.39.3",
"eventemitter3": "^5.0.1",
"immer": "^11.1.8",
"react-redux": "8.x.x || 9.x.x",
"reselect": "5.2.0",
"tiny-invariant": "^1.3.3",
"use-sync-external-store": "^1.2.2",
"victory-vendor": "^37.0.2"
},
"engines": {
"node": ">=18"
},
"peerDependencies": {
"react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0",
"react-dom": "^16.0.0 || ^17.0.0 || ^18.0.0 || ^19.0.0",
"react-is": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0"
}
},
"node_modules/recharts/node_modules/eventemitter3": {
"version": "5.0.4",
"resolved": "https://registry.npmjs.org/eventemitter3/-/eventemitter3-5.0.4.tgz",
"integrity": "sha512-mlsTRyGaPBjPedk6Bvw+aqbsXDtoAyAzm5MO7JgU+yVRyMQ5O8bD4Kcci7BS85f93veegeCPkL8R4GLClnjLFw==",
"license": "MIT"
},
"node_modules/redis": {
"version": "4.7.1",
"resolved": "https://registry.npmjs.org/redis/-/redis-4.7.1.tgz",
@@ -4428,21 +4088,6 @@
"@redis/time-series": "1.1.0"
}
},
"node_modules/redux": {
"version": "5.0.1",
"resolved": "https://registry.npmjs.org/redux/-/redux-5.0.1.tgz",
"integrity": "sha512-M9/ELqF6fy8FwmkpnF0S3YKOqMyoWJ4+CS5Efg2ct3oY9daQvd/Pc71FpGZsVsbl3Cpb+IIcjBDUnnyBdQbq4w==",
"license": "MIT"
},
"node_modules/redux-thunk": {
"version": "3.1.0",
"resolved": "https://registry.npmjs.org/redux-thunk/-/redux-thunk-3.1.0.tgz",
"integrity": "sha512-NW2r5T6ksUKXCabzhL9z+h206HQw/NJkcLm1GPImRQ8IzfXwRGqjVhKJGauHirT0DAuyy6hjdnMZaRoAcy0Klw==",
"license": "MIT",
"peerDependencies": {
"redux": "^5.0.0"
}
},
"node_modules/require-directory": {
"version": "2.1.1",
"resolved": "https://registry.npmjs.org/require-directory/-/require-directory-2.1.1.tgz",
@@ -4464,12 +4109,6 @@
"integrity": "sha512-KigOCHcocU3XODJxsu8i/j8T9tzT4adHiecwORRQ0ZZFcp7ahwXuRU1m+yuO90C5ZUyGeGfocHDI14M3L3yDAQ==",
"license": "MIT"
},
"node_modules/reselect": {
"version": "5.2.0",
"resolved": "https://registry.npmjs.org/reselect/-/reselect-5.2.0.tgz",
"integrity": "sha512-AgZ3UOZm3YndfrJ4OYjgrT7bmCm/1iqkjvEfH/oYjzh6PD2qw4QuT3jjnXIrpdt4MTpMXclMT3lXbmRY+XRakw==",
"license": "MIT"
},
"node_modules/rollup": {
"version": "4.62.0",
"resolved": "https://registry.npmjs.org/rollup/-/rollup-4.62.0.tgz",
@@ -4826,12 +4465,6 @@
"node": ">=8"
}
},
"node_modules/tiny-invariant": {
"version": "1.3.3",
"resolved": "https://registry.npmjs.org/tiny-invariant/-/tiny-invariant-1.3.3.tgz",
"integrity": "sha512-+FbBPE1o9QAYvviau/qC5SE3caw21q3xkvWKBtja5vgqOWIHHJ3ioaq1VPfn/Szqctz2bU/oYeKd9/z5BL+PVg==",
"license": "MIT"
},
"node_modules/tinyglobby": {
"version": "0.2.17",
"resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.17.tgz",
@@ -4959,15 +4592,6 @@
"browserslist": ">= 4.21.0"
}
},
"node_modules/use-sync-external-store": {
"version": "1.6.0",
"resolved": "https://registry.npmjs.org/use-sync-external-store/-/use-sync-external-store-1.6.0.tgz",
"integrity": "sha512-Pp6GSwGP/NrPIrxVFAIkOQeyw8lFenOHijQWkUTrDvrF4ALqylP2C/KCkeS9dpUM3KvYRQhna5vt7IL95+ZQ9w==",
"license": "MIT",
"peerDependencies": {
"react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0"
}
},
"node_modules/utils-merge": {
"version": "1.0.1",
"resolved": "https://registry.npmjs.org/utils-merge/-/utils-merge-1.0.1.tgz",
@@ -4986,28 +4610,6 @@
"node": ">= 0.8"
}
},
"node_modules/victory-vendor": {
"version": "37.3.6",
"resolved": "https://registry.npmjs.org/victory-vendor/-/victory-vendor-37.3.6.tgz",
"integrity": "sha512-SbPDPdDBYp+5MJHhBCAyI7wKM3d5ivekigc2Dk2s7pgbZ9wIgIBYGVw4zGHBml/qTFbexrofXW6Gu4noGxrOwQ==",
"license": "MIT AND ISC",
"dependencies": {
"@types/d3-array": "^3.0.3",
"@types/d3-ease": "^3.0.0",
"@types/d3-interpolate": "^3.0.1",
"@types/d3-scale": "^4.0.2",
"@types/d3-shape": "^3.1.0",
"@types/d3-time": "^3.0.0",
"@types/d3-timer": "^3.0.0",
"d3-array": "^3.1.6",
"d3-ease": "^3.0.1",
"d3-interpolate": "^3.0.1",
"d3-scale": "^4.0.2",
"d3-shape": "^3.1.0",
"d3-time": "^3.0.0",
"d3-timer": "^3.0.1"
}
},
"node_modules/vite": {
"version": "6.4.3",
"resolved": "https://registry.npmjs.org/vite/-/vite-6.4.3.tgz",
-1
View File
@@ -29,7 +29,6 @@
"react": "^19.0.0",
"react-dom": "^19.0.0",
"react-router-dom": "^7.13.1",
"recharts": "^3.10.1",
"redis": "^4.7.1",
"sharp": "0.35.2",
"undici": "^6.26.0"
-122
View File
@@ -1,122 +0,0 @@
#!/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);
});
-92
View File
@@ -1,92 +0,0 @@
#!/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);
});
+1 -111
View File
@@ -4,7 +4,7 @@ import path from 'node:path';
import { spawn } from 'node:child_process';
import { fileURLToPath } from 'node:url';
import express from 'express';
import { listUsagePaged, listLedgerPaged, getUsageStats, getUsageSummary } from './pagination.mjs';
import { listUsagePaged, listLedgerPaged } from './pagination.mjs';
const projectRoot = path.join(path.dirname(fileURLToPath(import.meta.url)), '..');
@@ -307,29 +307,7 @@ export function createAdminApp(services) {
res.json({ user: result.user });
});
async function handleUsageStats(req, res) {
try {
const result = await getUsageStats(pool, req.query);
res.json(result);
} catch (err) {
if (err?.code === 'INVALID_TIME_RANGE') {
return res.status(400).json({ message: '无效的时间范围' });
}
throw err;
}
}
adminApi.get('/usage/summary', requireAdmin, async (req, res) => {
const result = await getUsageSummary(pool, req.query);
res.json(result);
});
adminApi.get('/usage/stats', requireAdmin, handleUsageStats);
adminApi.get('/usage', requireAdmin, async (req, res) => {
if (req.query.stats === '1' || req.query.stats === 'true') {
return handleUsageStats(req, res);
}
const result = await listUsagePaged(pool, req.query);
res.json(result);
});
@@ -421,14 +399,6 @@ export function createAdminApp(services) {
res.json(result);
});
adminApi.get('/memory-v2/runtime', requireAdmin, async (_req, res) => {
if (!memoryV2ConfigService?.getRuntimeState) {
return res.status(503).json({ message: 'Memory V2 配置服务未启用' });
}
const result = await memoryV2ConfigService.getRuntimeState();
return res.json(result);
});
adminApi.get('/mindsearch/config', requireAdmin, async (_req, res) => {
if (!mindSearchConfigService?.getAdminConfig) return res.status(503).json({ message: 'MindSearch 配置未启用' });
res.json(await mindSearchConfigService.getAdminConfig());
@@ -1183,86 +1153,6 @@ 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.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);
});
app.use('/admin-api', adminApi);
if (services.createOpsApi) {
-1
View File
@@ -159,7 +159,6 @@ export async function bootstrapAdminServices() {
const subscriptionService = createSubscriptionService(pool, {
getPlanAsync: (planType) => planCatalogService.getPlan(planType),
});
subscriptionService._planCatalogService = planCatalogService;
await ensureSystemTestAccountSchema(pool);
const systemTestAccountService = createSystemTestAccountService(pool);
+3 -128
View File
@@ -17,145 +17,21 @@ export function paginateArray(items, query, defaultPageSize = 20) {
};
}
const MS_THRESHOLD = 1_000_000_000_000;
function parseUsageTimeFilters(query) {
const startAt = Number(query.startAt);
const endAt = Number(query.endAt);
const hasStart = Number.isFinite(startAt) && startAt > 0;
const hasEnd = Number.isFinite(endAt) && endAt > 0;
if (!hasStart || !hasEnd || startAt > endAt) {
return null;
}
// Frontend sends unix seconds; h5_usage_records.created_at is stored in milliseconds.
const startAtMs = startAt < MS_THRESHOLD ? startAt * 1000 : startAt;
const endAtMs = endAt < MS_THRESHOLD ? endAt * 1000 + 999 : endAt;
return { startAt, endAt, startAtMs, endAtMs };
}
function formatSqlDay(value) {
if (value instanceof Date) {
const y = value.getFullYear();
const m = String(value.getMonth() + 1).padStart(2, '0');
const d = String(value.getDate()).padStart(2, '0');
return `${y}-${m}-${d}`;
}
return String(value).slice(0, 10);
}
export async function getUsageTotals(pool, { userId = null, startAtMs = null, endAtMs = null } = {}) {
const params = [];
const clauses = [];
if (userId) {
clauses.push('r.user_id = ?');
params.push(userId);
}
if (startAtMs != null && endAtMs != null) {
clauses.push('r.created_at >= ?', 'r.created_at <= ?');
params.push(startAtMs, endAtMs);
}
const where = clauses.length ? `WHERE ${clauses.join(' AND ')}` : '';
const [[row]] = await pool.query(
`SELECT COUNT(*) AS count,
COALESCE(SUM(r.input_tokens), 0) AS input_tokens,
COALESCE(SUM(r.output_tokens), 0) AS output_tokens,
COALESCE(SUM(r.cost_cents), 0) AS cost_cents
FROM h5_usage_records r
${where}`,
params,
);
return {
count: Number(row.count),
inputTokens: Number(row.input_tokens),
outputTokens: Number(row.output_tokens),
costCents: Number(row.cost_cents),
};
}
export async function getUsageSummary(pool, query) {
const userId = typeof query.userId === 'string' && query.userId ? query.userId : null;
const range = parseUsageTimeFilters(query);
const allTime = await getUsageTotals(pool, { userId });
if (!range) {
return { range: null, allTime };
}
const rangeTotals = await getUsageTotals(pool, {
userId,
startAtMs: range.startAtMs,
endAtMs: range.endAtMs,
});
return {
range: {
...rangeTotals,
startAt: range.startAt,
endAt: range.endAt,
},
allTime,
};
}
export async function getUsageStats(pool, query) {
const userId = typeof query.userId === 'string' && query.userId ? query.userId : null;
const range = parseUsageTimeFilters(query);
if (!range) {
const err = new Error('INVALID_TIME_RANGE');
err.code = 'INVALID_TIME_RANGE';
throw err;
}
const { startAt, endAt, startAtMs, endAtMs } = range;
const params = [startAtMs, endAtMs];
const clauses = ['r.created_at >= ?', 'r.created_at <= ?'];
if (userId) {
clauses.push('r.user_id = ?');
params.push(userId);
}
const where = `WHERE ${clauses.join(' AND ')}`;
const [rows] = await pool.query(
`SELECT DATE(FROM_UNIXTIME(r.created_at / 1000)) AS day,
COUNT(*) AS count,
COALESCE(SUM(r.input_tokens), 0) AS input_tokens,
COALESCE(SUM(r.output_tokens), 0) AS output_tokens,
COALESCE(SUM(r.cost_cents), 0) AS cost_cents
FROM h5_usage_records r
${where}
GROUP BY day
ORDER BY day ASC`,
params,
);
return {
buckets: rows.map((row) => ({
day: formatSqlDay(row.day),
count: Number(row.count),
inputTokens: Number(row.input_tokens),
outputTokens: Number(row.output_tokens),
costCents: Number(row.cost_cents),
})),
startAt,
endAt,
};
}
export async function listUsagePaged(pool, query) {
const { page, pageSize, offset } = parsePageQuery(query, 20);
const userId = typeof query.userId === 'string' && query.userId ? query.userId : null;
const range = parseUsageTimeFilters(query);
const params = [];
const clauses = [];
let where = '';
if (userId) {
clauses.push('r.user_id = ?');
where = 'WHERE r.user_id = ?';
params.push(userId);
}
if (range) {
clauses.push('r.created_at >= ?', 'r.created_at <= ?');
params.push(range.startAtMs, range.endAtMs);
}
const where = clauses.length ? `WHERE ${clauses.join(' AND ')}` : '';
const [[{ total }]] = await pool.query(
`SELECT COUNT(*) AS total FROM h5_usage_records r ${where}`,
params,
);
const [rows] = await pool.query(
`SELECT r.id, r.user_id, u.username, u.display_name, r.agent_session_id, r.request_id,
`SELECT r.id, r.user_id, u.username, r.agent_session_id, r.request_id,
r.input_tokens, r.output_tokens, r.cost_cents, r.balance_after_cents, r.created_at
FROM h5_usage_records r
JOIN h5_users u ON u.id = r.user_id
@@ -169,7 +45,6 @@ export async function listUsagePaged(pool, query) {
id: Number(row.id),
userId: row.user_id,
username: row.username,
displayName: row.display_name,
agentSessionId: row.agent_session_id,
requestId: row.request_id,
inputTokens: Number(row.input_tokens),
-3
View File
@@ -5,7 +5,6 @@ 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';
@@ -120,7 +119,6 @@ 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="capabilities" element={<CapabilitiesPage />} />
<Route path="skills" element={<SkillsPage />} />
<Route path="system-tests" element={<SystemTestsPage />} />
@@ -172,7 +170,6 @@ function loginRedirectPath(pathname: string, role: string | undefined) {
if (
pathname.startsWith('/users')
|| pathname.startsWith('/billing')
|| pathname.startsWith('/image-quota')
|| pathname.startsWith('/capabilities')
|| pathname.startsWith('/skills')
|| pathname.startsWith('/system-tests')
+1 -4
View File
@@ -21,10 +21,7 @@ const NAV_SECTIONS: NavSection[] = [
},
{
label: '计费',
items: [
{ to: '/billing', label: '计费中心', end: false },
{ to: '/image-quota', label: '图片额度' },
],
items: [{ to: '/billing', label: '计费中心', end: false }],
},
{
label: '平台配置',
+1 -1
View File
@@ -75,7 +75,7 @@ export function AnalyticsConfigPage() {
};
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"> Memind Umami Rybbit Portal MEMIND_RYBBIT_ENABLEDMEMIND_RYBBIT_SITE_ID</p></div>
{error && <p className="banner banner-error">{error}</p>}
{message && <p className="banner banner-info">{message}</p>}
<section className="admin-card">
+59 -581
View File
@@ -1,11 +1,8 @@
import { useCallback, useEffect, useRef, useState } from 'react';
import { useLocation, useNavigate } from 'react-router-dom';
import {
cancelUserSubscription,
createSubscriptionPlan,
deleteSubscriptionPlan,
getAdminUsageStats,
getAdminUsageSummary,
grantUserSubscription,
listAdminLedger,
listAdminSubscriptions,
@@ -16,19 +13,8 @@ import {
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, LedgerEntry, PlanDefinition, UsageRecord } from '../../types';
import { Pagination } from '../../components/Pagination';
import {
dateRangeToUnix,
DEFAULT_CHART_METRICS,
METRIC_CONFIG,
METRIC_OPTIONS,
presetDateRange,
toggleChartMetric,
UsageTrendChart,
type UsageChartMetric,
type UsageChartType,
} from '../../components/UsageTrendChart';
import { useAdminUsers } from '../hooks/useAdminUsers';
import { formatTime, formatYuan } from '../utils/format';
@@ -125,22 +111,6 @@ const TABS: { key: TabKey; label: string }[] = [
{ key: 'ledger', label: '资金流水' },
];
const TAB_PATHS: Record<TabKey, string> = {
subscriptions: '/billing',
recharge: '/billing/recharge',
usage: '/billing/usage',
ledger: '/billing/ledger',
};
function tabFromPath(pathname: string): TabKey {
const suffix = pathname.replace(/^\/billing\/?/, '');
if (suffix === 'usage' || suffix.startsWith('usage/')) return 'usage';
if (suffix === 'recharge') return 'recharge';
if (suffix === 'ledger') return 'ledger';
if (suffix === 'subscriptions') return 'subscriptions';
return 'subscriptions';
}
const PAGE_SIZE = 20;
function RechargeTab() {
@@ -191,414 +161,21 @@ function RechargeTab() {
);
}
function UsageRecordsTable({
result,
onPage,
}: {
result: PagedResult<UsageRecord>;
onPage: (page: number) => void;
}) {
return (
<>
<div className="admin-table-wrap">
<table className="admin-table">
<thead>
<tr>
<th></th>
<th></th>
<th style={{ textAlign: 'right' }}> Token</th>
<th style={{ textAlign: 'right' }}> Token</th>
<th style={{ textAlign: 'right' }}></th>
<th style={{ textAlign: 'right' }}></th>
</tr>
</thead>
<tbody>
{result.items.map((row) => (
<tr key={row.id}>
<td className="billing-time">{formatTime(row.createdAt)}</td>
<td>
<span>{row.displayName || row.username}</span>
<span className="muted"> @{row.username}</span>
</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>
</tr>
))}
</tbody>
</table>
</div>
<Pagination
page={result.page}
totalPages={result.totalPages}
total={result.total}
pageSize={result.pageSize}
onChange={onPage}
/>
</>
);
}
function UsageTotalsGrid({ totals }: { totals: UsageTotals }) {
const totalTokens = totals.inputTokens + totals.outputTokens;
return (
<div className="admin-stat-grid usage-summary-grid">
<div className="admin-stat-card">
<div className="admin-stat-label"></div>
<div className="admin-stat-value">{totals.count.toLocaleString()}</div>
</div>
<div className="admin-stat-card">
<div className="admin-stat-label">Token </div>
<div className="admin-stat-value">{totalTokens.toLocaleString()}</div>
<div className="muted usage-summary-sub">
{totals.inputTokens.toLocaleString()} / {totals.outputTokens.toLocaleString()}
</div>
</div>
<div className="admin-stat-card">
<div className="admin-stat-label"></div>
<div className="admin-stat-value">¥{formatYuan(totals.costCents)}</div>
</div>
</div>
);
}
function UsageSummaryPanel({
summary,
rangeLabel,
allTimeLabel = '累计(不限区间)',
loading,
}: {
summary: UsageSummaryResult | null;
rangeLabel: string;
allTimeLabel?: string;
loading?: boolean;
}) {
if (loading && !summary) {
return <p className="muted usage-summary-loading"></p>;
}
if (!summary) return null;
return (
<div className="usage-summary-panel">
{summary.range && (
<section className="usage-summary-block">
<h4 className="usage-summary-title">{rangeLabel}</h4>
<UsageTotalsGrid totals={summary.range} />
</section>
)}
<section className="usage-summary-block">
<h4 className="usage-summary-title">{allTimeLabel}</h4>
<UsageTotalsGrid totals={summary.allTime} />
</section>
</div>
);
}
function UsageChartPanel({
stats,
chartMetrics,
chartType,
onMetricsChange,
onChartTypeChange,
title = '用量趋势',
hint,
}: {
stats: UsageStatsResult;
chartMetrics: UsageChartMetric[];
chartType: UsageChartType;
onMetricsChange: (metrics: UsageChartMetric[]) => void;
onChartTypeChange: (type: UsageChartType) => void;
title?: string;
hint?: string;
}) {
return (
<div className="usage-chart-panel">
<div className="usage-chart-head">
<div>
<h3>{title}</h3>
{hint && <p className="muted usage-chart-hint">{hint}</p>}
</div>
<div className="usage-chart-controls">
<div className="usage-metric-checks" role="group" aria-label="图表指标">
{METRIC_OPTIONS.map(({ key, label }) => {
const checked = chartMetrics.includes(key);
const color = METRIC_CONFIG[key].color;
return (
<label key={key} className={`usage-metric-check${checked ? ' checked' : ''}`}>
<input
type="checkbox"
checked={checked}
onChange={() => onMetricsChange(toggleChartMetric(chartMetrics, key))}
/>
<span className="usage-metric-check-dot" style={{ background: color }} />
{label}
</label>
);
})}
</div>
<div className="usage-chart-type-toggle" role="group" aria-label="图表类型">
<button
type="button"
className={`usage-chart-type-btn${chartType === 'bar' ? ' active' : ''}`}
onClick={() => onChartTypeChange('bar')}
>
</button>
<button
type="button"
className={`usage-chart-type-btn${chartType === 'line' ? ' active' : ''}`}
onClick={() => onChartTypeChange('line')}
>
线
</button>
</div>
</div>
</div>
<UsageTrendChart
buckets={stats.buckets}
startAt={stats.startAt}
endAt={stats.endAt}
metrics={chartMetrics}
chartType={chartType}
/>
</div>
);
}
type UsageView = 'overview' | 'query';
function UsageOverviewPanel() {
const [result, setResult] = useState<PagedResult<UsageRecord> | null>(null);
const [stats, setStats] = useState<UsageStatsResult | null>(null);
const [summary, setSummary] = useState<UsageSummaryResult | null>(null);
const [loadingTable, setLoadingTable] = useState(false);
const [loadingChart, setLoadingChart] = useState(false);
const [loadingSummary, setLoadingSummary] = useState(false);
const [error, setError] = useState<string | null>(null);
const [startDate, setStartDate] = useState('');
const [endDate, setEndDate] = useState('');
const [chartMetrics, setChartMetrics] = useState<UsageChartMetric[]>(DEFAULT_CHART_METRICS);
const [chartType, setChartType] = useState<UsageChartType>('bar');
const hasDateFilter = Boolean(startDate && endDate && startDate <= endDate);
const loadTable = useCallback(async (page: number, start: string, end: string) => {
if (start && end && start > end) {
setError('请选择有效的时间范围');
return;
}
setLoadingTable(true);
setError(null);
try {
const params: { page: number; pageSize: number; startAt?: number; endAt?: number } = {
page,
pageSize: PAGE_SIZE,
};
if (start && end) {
const range = dateRangeToUnix(start, end);
params.startAt = range.startAt;
params.endAt = range.endAt;
}
const data = await listAdminUsage(params);
setResult(data);
} catch (err) {
setError(err instanceof Error ? err.message : '加载失败');
} finally {
setLoadingTable(false);
}
}, []);
const loadChart = useCallback(async (start: string, end: string) => {
if (start && end && start > end) return;
setLoadingChart(true);
setError(null);
try {
const chartRange = start && end
? { startDate: start, endDate: end }
: presetDateRange(30);
const { startAt, endAt } = dateRangeToUnix(chartRange.startDate, chartRange.endDate);
const statsData = await getAdminUsageStats({ startAt, endAt });
setStats(statsData);
} catch (err) {
setError(err instanceof Error ? err.message : '图表加载失败');
} finally {
setLoadingChart(false);
}
}, []);
const loadSummary = useCallback(async (start: string, end: string) => {
if (start && end && start > end) return;
setLoadingSummary(true);
setError(null);
try {
const data = start && end
? await getAdminUsageSummary(dateRangeToUnix(start, end))
: await getAdminUsageSummary();
setSummary(data);
} catch (err) {
setError(err instanceof Error ? err.message : '汇总加载失败');
} finally {
setLoadingSummary(false);
}
}, []);
useEffect(() => {
void loadTable(1, startDate, endDate);
}, [startDate, endDate, loadTable]);
useEffect(() => {
void loadChart(startDate, endDate);
}, [startDate, endDate, loadChart]);
useEffect(() => {
void loadSummary(startDate, endDate);
}, [startDate, endDate, loadSummary]);
const applyPreset = (days: number) => {
const range = presetDateRange(days);
setStartDate(range.startDate);
setEndDate(range.endDate);
};
const clearDateFilter = () => {
setStartDate('');
setEndDate('');
};
const handlePage = (page: number) => void loadTable(page, startDate, endDate);
const chartHint = hasDateFilter
? `${startDate}${endDate} 全平台用量(列表与图表同步筛选)`
: '近 30 天全平台用量(列表展示全部记录;设置日期后列表与图表同步筛选)';
const rangeSummaryLabel = hasDateFilter
? `区间汇总(${startDate}${endDate}`
: '';
return (
<>
{error && <p className="banner banner-error">{error}</p>}
<div className="usage-overview-filter">
<span className="muted usage-overview-chart-label"></span>
<div className="usage-date-range">
<input
type="date"
className="billing-filter-select usage-date-input"
value={startDate}
onChange={(e) => setStartDate(e.target.value)}
/>
<span className="muted usage-date-sep"></span>
<input
type="date"
className="billing-filter-select usage-date-input"
value={endDate}
onChange={(e) => setEndDate(e.target.value)}
/>
</div>
<div className="usage-preset-btns">
<button type="button" className="admin-btn-secondary usage-preset-btn" onClick={() => applyPreset(7)}>7</button>
<button type="button" className="admin-btn-secondary usage-preset-btn" onClick={() => applyPreset(30)}>30</button>
<button type="button" className="admin-btn-secondary usage-preset-btn" onClick={() => applyPreset(90)}>90</button>
<button
type="button"
className={`admin-btn-secondary usage-preset-btn${!hasDateFilter ? ' active' : ''}`}
onClick={clearDateFilter}
>
</button>
</div>
</div>
<UsageSummaryPanel
summary={summary}
rangeLabel={rangeSummaryLabel}
allTimeLabel="累计汇总(不限区间 · 全平台)"
loading={loadingSummary}
/>
{loadingChart && !stats && <p className="muted"></p>}
{!loadingChart && stats && (
<UsageChartPanel
stats={stats}
chartMetrics={chartMetrics}
chartType={chartType}
onMetricsChange={setChartMetrics}
onChartTypeChange={setChartType}
title="用量统计"
hint={chartHint}
/>
)}
{loadingTable && !result && <p className="muted"></p>}
{!loadingTable && result && (
result.items.length === 0 ? (
<p className="muted billing-empty"></p>
) : (
<UsageRecordsTable result={result} onPage={handlePage} />
)
)}
</>
);
}
function UsageQueryPanel() {
function UsageTab() {
const { users } = useAdminUsers();
const defaultRange = presetDateRange(30);
const [result, setResult] = useState<PagedResult<UsageRecord> | null>(null);
const [stats, setStats] = useState<UsageStatsResult | null>(null);
const [summary, setSummary] = useState<UsageSummaryResult | null>(null);
const [loading, setLoading] = useState(false);
const [error, setError] = useState<string | null>(null);
const [userId, setUserId] = useState('');
const [startDate, setStartDate] = useState(defaultRange.startDate);
const [endDate, setEndDate] = useState(defaultRange.endDate);
const [chartMetrics, setChartMetrics] = useState<UsageChartMetric[]>(DEFAULT_CHART_METRICS);
const [chartType, setChartType] = useState<UsageChartType>('bar');
const [filterUserId, setFilterUserId] = useState('');
const [pendingUserId, setPendingUserId] = useState('');
const hasDateFilter = Boolean(startDate && endDate && startDate <= endDate);
const load = useCallback(async (p: number, uid: string, start: string, end: string, withChart: boolean) => {
if (!uid) return;
if (start && end && start > end) {
setError('请选择有效的时间范围');
return;
}
if ((start && !end) || (!start && end)) {
setError('请完整选择起止日期');
return;
}
const filtered = Boolean(start && end);
const load = useCallback(async (p: number, userId: string) => {
setLoading(true);
setError(null);
try {
const listParams: { userId: string; page: number; pageSize: number; startAt?: number; endAt?: number } = {
userId: uid,
page: p,
pageSize: PAGE_SIZE,
};
let statsPromise: Promise<UsageStatsResult | null> = Promise.resolve(null);
let summaryPromise: Promise<UsageSummaryResult | null> = Promise.resolve(null);
if (filtered) {
const { startAt, endAt } = dateRangeToUnix(start, end);
listParams.startAt = startAt;
listParams.endAt = endAt;
if (withChart) {
statsPromise = getAdminUsageStats({ userId: uid, startAt, endAt });
summaryPromise = getAdminUsageSummary({ userId: uid, startAt, endAt });
}
} else if (withChart) {
const endAt = Math.floor(Date.now() / 1000);
statsPromise = getAdminUsageStats({ userId: uid, startAt: 1, endAt });
summaryPromise = getAdminUsageSummary({ userId: uid });
}
const [data, statsData, summaryData] = await Promise.all([
listAdminUsage(listParams),
statsPromise,
summaryPromise,
]);
const data = await listAdminUsage({ userId: userId || undefined, page: p, pageSize: PAGE_SIZE });
setResult(data);
if (statsData) setStats(statsData);
if (summaryData) setSummary(summaryData);
setFilterUserId(userId);
} catch (err) {
setError(err instanceof Error ? err.message : '加载失败');
} finally {
@@ -606,165 +183,68 @@ function UsageQueryPanel() {
}
}, []);
useEffect(() => {
if (!userId) {
setResult(null);
setStats(null);
setSummary(null);
setError(null);
return;
}
void load(1, userId, startDate, endDate, true);
}, [userId, startDate, endDate, load]);
useEffect(() => { void load(1, ''); }, [load]);
const applyPreset = (days: number) => {
const range = presetDateRange(days);
setStartDate(range.startDate);
setEndDate(range.endDate);
};
const clearDateFilter = () => {
setStartDate('');
setEndDate('');
};
const handlePage = (p: number) => void load(p, userId, startDate, endDate, false);
const selectedUser = users.find((u) => u.id === userId);
const chartHint = hasDateFilter
? `${startDate}${endDate}${selectedUser ? ` · ${selectedUser.displayName}` : ''}`
: `全部日期${selectedUser ? ` · ${selectedUser.displayName}` : ''}`;
const rangeSummaryLabel = hasDateFilter
? `区间汇总(${startDate}${endDate}${selectedUser ? ` · ${selectedUser.displayName}` : ''}`
: '';
const allTimeSummaryLabel = selectedUser
? `累计汇总(${selectedUser.displayName} · 全部日期)`
: '累计汇总(全部日期)';
const handleQuery = () => void load(1, pendingUserId);
const handlePage = (p: number) => void load(p, filterUserId);
return (
<>
<div className="billing-toolbar usage-toolbar usage-query-toolbar">
<UserCombobox
users={users}
value={userId}
onChange={setUserId}
placeholder="选择用户"
/>
<div className="usage-date-range">
<input
type="date"
className="billing-filter-select usage-date-input"
value={startDate}
onChange={(e) => setStartDate(e.target.value)}
disabled={!userId}
<section className="admin-card">
<div className="admin-card-head">
<h2></h2>
<div className="billing-toolbar">
<UserCombobox
users={users}
value={pendingUserId}
onChange={setPendingUserId}
placeholder="全部用户"
/>
<span className="muted usage-date-sep"></span>
<input
type="date"
className="billing-filter-select usage-date-input"
value={endDate}
onChange={(e) => setEndDate(e.target.value)}
disabled={!userId}
/>
</div>
<div className="usage-preset-btns">
<button type="button" className="admin-btn-secondary usage-preset-btn" disabled={!userId} onClick={() => applyPreset(7)}>7</button>
<button type="button" className="admin-btn-secondary usage-preset-btn" disabled={!userId} onClick={() => applyPreset(30)}>30</button>
<button type="button" className="admin-btn-secondary usage-preset-btn" disabled={!userId} onClick={() => applyPreset(90)}>90</button>
<button
type="button"
className={`admin-btn-secondary usage-preset-btn${userId && !hasDateFilter ? ' active' : ''}`}
disabled={!userId}
onClick={clearDateFilter}
>
<button type="button" className="send-btn billing-query-btn" onClick={handleQuery} disabled={loading}>
</button>
</div>
</div>
{error && <p className="banner banner-error">{error}</p>}
{!userId && !loading && (
<p className="muted billing-empty"></p>
{!result && !loading && (
<p className="muted billing-empty"></p>
)}
{userId && loading && !result && !stats && <p className="muted"></p>}
{userId && selectedUser && (
<div className="usage-user-balance-bar">
<span className="usage-user-balance-name">
{selectedUser.displayName}
<span className="muted"> @{selectedUser.username}</span>
</span>
<span className="usage-user-balance-label muted"></span>
<span className="usage-user-balance-value">¥{formatYuan(selectedUser.balanceCents)}</span>
</div>
)}
{userId && (
<UsageSummaryPanel
summary={summary}
rangeLabel={rangeSummaryLabel}
allTimeLabel={allTimeSummaryLabel}
loading={loading && !summary}
/>
)}
{userId && !loading && stats && (
<UsageChartPanel
stats={stats}
chartMetrics={chartMetrics}
chartType={chartType}
onMetricsChange={setChartMetrics}
onChartTypeChange={setChartType}
title="用户用量趋势"
hint={chartHint}
/>
)}
{userId && !loading && result && (
{loading && <p className="muted"></p>}
{!loading && result && (
result.items.length === 0 ? (
<p className="muted billing-empty">{hasDateFilter ? '该时间范围内暂无记录' : '该用户暂无用量记录'}</p>
<p className="muted billing-empty"></p>
) : (
<UsageRecordsTable result={result} onPage={handlePage} />
<>
<div className="admin-table-wrap">
<table className="admin-table">
<thead>
<tr>
<th></th>
<th></th>
<th style={{ textAlign: 'right' }}> Token</th>
<th style={{ textAlign: 'right' }}> Token</th>
<th style={{ textAlign: 'right' }}></th>
<th style={{ textAlign: 'right' }}></th>
</tr>
</thead>
<tbody>
{result.items.map((row) => (
<tr key={row.id}>
<td className="billing-time">{formatTime(row.createdAt)}</td>
<td>@{row.username}</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>
</tr>
))}
</tbody>
</table>
</div>
<Pagination page={result.page} totalPages={result.totalPages} total={result.total}
pageSize={result.pageSize} onChange={handlePage} />
</>
)
)}
</>
);
}
function UsageTab() {
const location = useLocation();
const navigate = useNavigate();
const view: UsageView = location.pathname.endsWith('/query') ? 'query' : 'overview';
const setView = (next: UsageView) => {
navigate(next === 'query' ? '/billing/usage/query' : '/billing/usage');
};
return (
<section className="admin-card">
<div className="admin-card-head usage-tab-head">
<h2></h2>
<div className="usage-view-switch" role="tablist" aria-label="用量记录视图">
<button
type="button"
role="tab"
aria-selected={view === 'overview'}
className={`usage-view-btn${view === 'overview' ? ' active' : ''}`}
onClick={() => setView('overview')}
>
</button>
<button
type="button"
role="tab"
aria-selected={view === 'query'}
className={`usage-view-btn${view === 'query' ? ' active' : ''}`}
onClick={() => setView('query')}
>
</button>
</div>
</div>
<div className="usage-tab-body">
{view === 'overview' ? <UsageOverviewPanel /> : <UsageQueryPanel />}
</div>
</section>
);
}
@@ -816,7 +296,7 @@ function LedgerTab() {
</div>
{error && <p className="banner banner-error">{error}</p>}
{!result && !loading && (
<p className="muted billing-empty"></p>
<p className="muted billing-empty"></p>
)}
{loading && <p className="muted"></p>}
{!loading && result && (
@@ -1442,9 +922,7 @@ function SubscriptionsTab() {
}
export function BillingPage() {
const location = useLocation();
const navigate = useNavigate();
const activeTab = tabFromPath(location.pathname);
const [activeTab, setActiveTab] = useState<TabKey>('subscriptions');
return (
<div className="admin-page">
@@ -1456,7 +934,7 @@ export function BillingPage() {
{TABS.map((tab) => (
<button key={tab.key} type="button" role="tab" aria-selected={activeTab === tab.key}
className={`admin-tab${activeTab === tab.key ? ' active' : ''}`}
onClick={() => navigate(TAB_PATHS[tab.key])}>
onClick={() => setActiveTab(tab.key)}>
{tab.label}
</button>
))}
-285
View File
@@ -1,285 +0,0 @@
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>
</>
);
}
+3 -5
View File
@@ -16,7 +16,6 @@ import type {
MemoryV2RuntimeStatusResponse,
PersonalMemoryCandidateListResponse,
} from '../../types';
import { isMemoryV2SectionEnabled } from './memory-v2-config';
type BackendKey =
| 'pgvector'
@@ -924,8 +923,7 @@ export function MemoryV2Page() {
{CAPABILITIES.map((capability, index) => {
const section = draft[capability.key];
const currentSection = current[capability.key];
const enabled = isMemoryV2SectionEnabled(capability.key, section);
const savedEnabled = isMemoryV2SectionEnabled(capability.key, currentSection);
const enabled = Boolean(section.enabled);
const accent = BACKEND_ACCENTS[index % BACKEND_ACCENTS.length];
return (
<section
@@ -1017,8 +1015,8 @@ export function MemoryV2Page() {
)}
</div>
)}
<span className={`asset-status ${savedEnabled ? 'is-on' : ''}`}>
{savedEnabled ? '启用' : '关闭'}
<span className={`asset-status ${Boolean(currentSection.enabled) ? 'is-on' : ''}`}>
{Boolean(currentSection.enabled) ? '启用' : '关闭'}
</span>
<button
type="button"
+2 -80
View File
@@ -1,12 +1,12 @@
import { useEffect, useState } from 'react';
import { Link, Navigate, useParams } from 'react-router-dom';
import { getAdminUser, rechargeUser, updateAdminUser, fetchUserImageQuota, grantUserImageQuota } from '../../api/client';
import { getAdminUser, rechargeUser, updateAdminUser } from '../../api/client';
import { CapabilitySettings } from '../../components/CapabilitySettings';
import { PolicySettings } from '../../components/PolicySettings';
import { SkillSettings } from '../../components/SkillSettings';
import { useAdminUsers } from '../hooks/useAdminUsers';
import { formatYuan } from '../utils/format';
import type { ImageQuotaView, PortalUser } from '../../types';
import type { PortalUser } from '../../types';
function formatBytes(bytes: number) {
if (bytes < 1024) return `${bytes} B`;
@@ -23,9 +23,6 @@ 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 [imageGrant, setImageGrant] = useState({ delta: '', note: '' });
useEffect(() => {
if (!user?.spaceQuotaBytes) return;
@@ -60,28 +57,6 @@ 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]);
const handleRecharge = async (e: React.FormEvent) => {
e.preventDefault();
if (!user) return;
@@ -100,33 +75,6 @@ export function UserDetailPage() {
}
};
const handleImageGrant = async (e: React.FormEvent) => {
e.preventDefault();
if (!user) return;
setMessage(null);
setLocalError(null);
setError(null);
const delta = Math.floor(Number(imageGrant.delta));
if (!Number.isFinite(delta) || delta === 0) {
setLocalError('请输入非零整数额度');
return;
}
try {
const result = await grantUserImageQuota(user.id, delta, imageGrant.note.trim());
setImageQuota(result.quota);
setMessage('图片额度已更新');
setImageGrant({ delta: '', note: '' });
} 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}`
: '';
if (!loading && !user && !error) {
return <Navigate to="/users" replace />;
}
@@ -238,32 +186,6 @@ export function UserDetailPage() {
</form>
</section>
<section className="admin-card">
<h2></h2>
{imageQuotaLoading ? (
<p className="muted"></p>
) : (
<p className="muted">{imageQuotaSummary || '暂无额度信息(用户可能尚无订阅)'}</p>
)}
<form className="admin-form" onSubmit={handleImageGrant}>
<input
placeholder="调整额度(张,正数充值、负数扣减)"
type="number"
step="1"
value={imageGrant.delta}
onChange={(e) => setImageGrant((s) => ({ ...s, delta: e.target.value }))}
/>
<input
placeholder="备注"
value={imageGrant.note}
onChange={(e) => setImageGrant((s) => ({ ...s, note: e.target.value }))}
/>
<button type="submit" className="send-btn">
</button>
</form>
</section>
<section className="admin-card">
<h2></h2>
<p className="muted">
+1 -244
View File
@@ -1,31 +1,24 @@
import { useCallback, useEffect, useMemo, useState } from 'react';
import { useCallback, useEffect, 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';
@@ -77,49 +70,6 @@ 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[]>([]);
@@ -141,27 +91,12 @@ 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);
@@ -174,9 +109,6 @@ export function WechatPage() {
nextDeliveries,
nextNotifications,
nextUsers,
nextLlmKeys,
nextIntentConfig,
nextIntentRuntime,
] =
await Promise.all([
getWechatAdminSummary(),
@@ -186,9 +118,6 @@ 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);
@@ -199,13 +128,6 @@ 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 {
@@ -313,36 +235,6 @@ 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">
@@ -424,141 +316,6 @@ 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>
-34
View File
@@ -1,34 +0,0 @@
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);
}
-225
View File
@@ -12,8 +12,6 @@ import type {
CapabilityDefinition,
CapabilityMap,
InsufficientBalanceDetails,
ImageQuotaLedgerEntry,
ImageQuotaView,
LedgerEntry,
LlmConnectionTestResult,
LlmExecutorBinding,
@@ -37,25 +35,17 @@ import type {
MemoryV2AdminConfigResponse,
MemoryV2ModelApiType,
MemoryV2RuntimeStatusResponse,
MemoryV2ConfigRuntimeState,
SkillRuntimeAdminConfig,
SkillRuntimeAdminConfigResponse,
SkillRuntimeCatalogItem,
SkillDefinition,
SkillMap,
UsageRecord,
UsageStatsBucket,
UsageStatsResult,
UsageSummaryResult,
UsageTotals,
WechatAdminSummary,
WechatBinding,
WechatDeliveryLog,
WechatDigestSubscription,
WechatScheduleLlmConfig,
WechatIntentRouterAdminConfig,
WechatIntentRouterConfigState,
WechatIntentRouterRuntimeState,
WechatMessage,
WechatWebNotification,
MindSearchConfig,
@@ -239,15 +229,11 @@ export async function listAdminUsage(params?: {
userId?: string;
page?: number;
pageSize?: number;
startAt?: number;
endAt?: number;
}): Promise<PagedResult<UsageRecord>> {
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));
if (params?.startAt) q.set('startAt', String(params.startAt));
if (params?.endAt) q.set('endAt', String(params.endAt));
const result = await portalFetch<{ records: UsageRecord[]; total: number; page: number; pageSize: number }>(
`/admin-api/usage${q.toString() ? `?${q}` : ''}`,
);
@@ -256,146 +242,6 @@ export async function listAdminUsage(params?: {
return { items: result.records ?? [], total, page: result.page ?? 1, pageSize, totalPages: Math.ceil(total / pageSize) };
}
function toMillis(ts: number) {
return ts >= 1_000_000_000_000 ? ts : ts * 1000;
}
function usageDayKey(createdAt: number) {
const d = new Date(toMillis(createdAt));
const y = d.getFullYear();
const m = String(d.getMonth() + 1).padStart(2, '0');
const day = String(d.getDate()).padStart(2, '0');
return `${y}-${m}-${day}`;
}
async function aggregateUsageStatsFromRecords(params: {
userId?: string;
startAt: number;
endAt: number;
}): Promise<UsageStatsResult> {
const bucketMap = new Map<string, UsageStatsBucket>();
let page = 1;
const pageSize = 200;
const maxPages = 50;
const startMs = params.startAt * 1000;
const endMs = params.endAt * 1000 + 999;
while (page <= maxPages) {
const result = await listAdminUsage({
userId: params.userId,
page,
pageSize,
startAt: params.startAt,
endAt: params.endAt,
});
for (const record of result.items) {
const createdMs = toMillis(record.createdAt);
if (createdMs < startMs || createdMs > endMs) continue;
const day = usageDayKey(record.createdAt);
const bucket = bucketMap.get(day) ?? {
day,
count: 0,
inputTokens: 0,
outputTokens: 0,
costCents: 0,
};
bucket.count += 1;
bucket.inputTokens += record.inputTokens;
bucket.outputTokens += record.outputTokens;
bucket.costCents += record.costCents;
bucketMap.set(day, bucket);
}
if (page >= result.totalPages || result.items.length === 0) break;
page += 1;
}
return {
buckets: [...bucketMap.values()].sort((a, b) => a.day.localeCompare(b.day)),
startAt: params.startAt,
endAt: params.endAt,
};
}
function sumUsageBuckets(buckets: UsageStatsBucket[]): UsageTotals {
return buckets.reduce(
(acc, b) => ({
count: acc.count + b.count,
inputTokens: acc.inputTokens + b.inputTokens,
outputTokens: acc.outputTokens + b.outputTokens,
costCents: acc.costCents + b.costCents,
}),
{ count: 0, inputTokens: 0, outputTokens: 0, costCents: 0 },
);
}
export async function getAdminUsageSummary(params?: {
userId?: string;
startAt?: number;
endAt?: number;
}): Promise<UsageSummaryResult> {
const q = new URLSearchParams();
if (params?.userId) q.set('userId', params.userId);
if (params?.startAt) q.set('startAt', String(params.startAt));
if (params?.endAt) q.set('endAt', String(params.endAt));
const suffix = q.toString() ? `?${q}` : '';
try {
return await portalFetch<UsageSummaryResult>(`/admin-api/usage/summary${suffix}`);
} catch (err) {
if (!(err instanceof ApiError) || err.status !== 404) throw err;
}
const allTime = sumUsageBuckets(
(await aggregateUsageStatsFromRecords({
userId: params?.userId,
startAt: 0,
endAt: Math.floor(Date.now() / 1000),
})).buckets,
);
if (!params?.startAt || !params?.endAt) {
return { range: null, allTime };
}
const rangeRows = await getAdminUsageStats({
userId: params.userId,
startAt: params.startAt,
endAt: params.endAt,
});
return {
range: {
...sumUsageBuckets(rangeRows.buckets),
startAt: params.startAt,
endAt: params.endAt,
},
allTime,
};
}
export async function getAdminUsageStats(params: {
userId?: string;
startAt: number;
endAt: number;
}): Promise<UsageStatsResult> {
const q = new URLSearchParams();
if (params.userId) q.set('userId', params.userId);
q.set('startAt', String(params.startAt));
q.set('endAt', String(params.endAt));
const attempts = [
`/admin-api/usage/stats?${q}`,
`/admin-api/usage?${q}&stats=1`,
];
for (const path of attempts) {
try {
return await portalFetch<UsageStatsResult>(path);
} catch (err) {
if (err instanceof ApiError && err.status === 404) continue;
throw err;
}
}
return aggregateUsageStatsFromRecords(params);
}
export async function listAdminLedger(params?: {
userId?: string;
page?: number;
@@ -451,25 +297,6 @@ 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');
}
@@ -532,10 +359,6 @@ export async function getMemoryV2RuntimeStatus(): Promise<MemoryV2RuntimeStatusR
return portalFetch('/admin-api/memory-v2/status');
}
export async function getMemoryV2ConfigRuntime(): Promise<MemoryV2ConfigRuntimeState> {
return portalFetch('/admin-api/memory-v2/runtime');
}
export async function listPersonalMemoryCandidates(
status = 'candidate',
): Promise<PersonalMemoryCandidateListResponse> {
@@ -1353,51 +1176,3 @@ 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 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}` : ''}`);
}
-251
View File
@@ -1,251 +0,0 @@
import { useMemo } from 'react';
import {
Bar,
CartesianGrid,
ComposedChart,
Legend,
Line,
ResponsiveContainer,
Tooltip,
XAxis,
YAxis,
} from 'recharts';
import type { UsageStatsBucket } from '../types';
import { formatYuan } from '../admin/utils/format';
export type UsageChartMetric = 'cost' | 'tokens' | 'count';
export type UsageChartType = 'bar' | 'line';
export const METRIC_OPTIONS: { key: UsageChartMetric; label: string }[] = [
{ key: 'tokens', label: 'Token 总量' },
{ key: 'cost', label: '扣费金额' },
{ key: 'count', label: '请求次数' },
];
export const METRIC_CONFIG: Record<
UsageChartMetric,
{ label: string; color: string; yAxisId: 'left' | 'right' }
> = {
tokens: { label: 'Token 总量', color: '#3d8bfd', yAxisId: 'left' },
cost: { label: '扣费金额', color: '#22c55e', yAxisId: 'right' },
count: { label: '请求次数', color: '#f59e0b', yAxisId: 'right' },
};
export const DEFAULT_CHART_METRICS: UsageChartMetric[] = ['tokens'];
function formatDayLabel(day: string) {
const [, month, date] = day.split('-');
return `${Number(month)}/${Number(date)}`;
}
function fillDailyBuckets(buckets: UsageStatsBucket[], startAt: number, endAt: number) {
const map = new Map(buckets.map((b) => [b.day, b]));
const start = new Date(startAt * 1000);
start.setHours(0, 0, 0, 0);
const end = new Date(endAt * 1000);
end.setHours(0, 0, 0, 0);
const filled: UsageStatsBucket[] = [];
for (let cursor = new Date(start); cursor <= end; cursor.setDate(cursor.getDate() + 1)) {
const y = cursor.getFullYear();
const m = String(cursor.getMonth() + 1).padStart(2, '0');
const d = String(cursor.getDate()).padStart(2, '0');
const day = `${y}-${m}-${d}`;
filled.push(
map.get(day) ?? {
day,
count: 0,
inputTokens: 0,
outputTokens: 0,
costCents: 0,
},
);
}
return filled;
}
function formatMetricValue(value: number, metric: UsageChartMetric) {
if (metric === 'cost') return `¥${formatYuan(Math.round(value * 100))}`;
return value.toLocaleString();
}
type ChartPoint = {
day: string;
label: string;
tokens: number;
cost: number;
count: number;
inputTokens: number;
outputTokens: number;
costCents: number;
};
function UsageTooltip({
active,
payload,
metrics,
}: {
active?: boolean;
payload?: Array<{ payload: ChartPoint; dataKey?: string; color?: string }>;
metrics: UsageChartMetric[];
}) {
if (!active || !payload?.length) return null;
const row = payload[0].payload;
return (
<div className="usage-chart-tooltip">
<div className="usage-chart-tooltip-title">{row.day}</div>
{metrics.map((metric) => (
<div key={metric} className="usage-chart-tooltip-metric">
<span className="usage-chart-tooltip-dot" style={{ background: METRIC_CONFIG[metric].color }} />
{METRIC_CONFIG[metric].label}{formatMetricValue(row[metric], metric)}
</div>
))}
<div className="muted usage-chart-tooltip-detail">
{row.inputTokens.toLocaleString()} / {row.outputTokens.toLocaleString()} Token
</div>
</div>
);
}
export function UsageTrendChart({
buckets,
startAt,
endAt,
metrics,
chartType,
}: {
buckets: UsageStatsBucket[];
startAt: number;
endAt: number;
metrics: UsageChartMetric[];
chartType: UsageChartType;
}) {
const activeMetrics = metrics.length > 0 ? metrics : DEFAULT_CHART_METRICS;
const data = useMemo(() => {
const filled = fillDailyBuckets(buckets, startAt, endAt);
return filled.map((bucket) => ({
day: bucket.day,
label: formatDayLabel(bucket.day),
tokens: bucket.inputTokens + bucket.outputTokens,
cost: bucket.costCents / 100,
count: bucket.count,
inputTokens: bucket.inputTokens,
outputTokens: bucket.outputTokens,
costCents: bucket.costCents,
}));
}, [buckets, startAt, endAt]);
const hasData = data.some((row) => activeMetrics.some((metric) => row[metric] > 0));
const showLeftAxis = activeMetrics.includes('tokens');
const showRightAxis = activeMetrics.some((m) => m === 'cost' || m === 'count');
const resolveYAxisId = (metric: UsageChartMetric): 'left' | 'right' => {
if (showLeftAxis && showRightAxis) return METRIC_CONFIG[metric].yAxisId;
if (showLeftAxis) return 'left';
return 'right';
};
if (!hasData) {
return <p className="muted usage-chart-empty"></p>;
}
return (
<div className="usage-chart-wrap">
<ResponsiveContainer width="100%" height={300}>
<ComposedChart data={data} margin={{ top: 8, right: showRightAxis ? 16 : 8, left: 0, bottom: 0 }}>
<CartesianGrid strokeDasharray="3 3" stroke="var(--color-border-subtle)" />
<XAxis
dataKey="label"
tick={{ fontSize: 11, fill: 'var(--color-text-muted)' }}
interval={data.length > 14 ? Math.floor(data.length / 7) : 0}
/>
{showLeftAxis && (
<YAxis
yAxisId="left"
tick={{ fontSize: 11, fill: METRIC_CONFIG.tokens.color }}
tickFormatter={(v) => (v >= 10000 ? `${(v / 10000).toFixed(0)}` : String(v))}
width={52}
/>
)}
{showRightAxis && (
<YAxis
yAxisId="right"
orientation="right"
tick={{ fontSize: 11, fill: 'var(--color-text-muted)' }}
tickFormatter={(v) => (Number.isInteger(v) ? String(v) : v.toFixed(2))}
width={48}
/>
)}
<Tooltip content={<UsageTooltip metrics={activeMetrics} />} />
<Legend
wrapperStyle={{ fontSize: 12, paddingTop: 8 }}
formatter={(value) => <span style={{ color: 'var(--color-text-muted)' }}>{value}</span>}
/>
{activeMetrics.map((metric) => {
const cfg = METRIC_CONFIG[metric];
const axisId = resolveYAxisId(metric);
if (chartType === 'bar') {
return (
<Bar
key={metric}
yAxisId={axisId}
dataKey={metric}
name={cfg.label}
fill={cfg.color}
radius={[4, 4, 0, 0]}
maxBarSize={activeMetrics.length > 1 ? 24 : 40}
/>
);
}
return (
<Line
key={metric}
yAxisId={axisId}
type="monotone"
dataKey={metric}
name={cfg.label}
stroke={cfg.color}
strokeWidth={2}
dot={{ r: 3, fill: cfg.color }}
activeDot={{ r: 5 }}
/>
);
})}
</ComposedChart>
</ResponsiveContainer>
</div>
);
}
export function toggleChartMetric(current: UsageChartMetric[], metric: UsageChartMetric): UsageChartMetric[] {
if (current.includes(metric)) {
if (current.length === 1) return current;
return current.filter((m) => m !== metric);
}
return [...current, metric];
}
export function dateRangeToUnix(startDate: string, endDate: string) {
const [sy, sm, sd] = startDate.split('-').map(Number);
const [ey, em, ed] = endDate.split('-').map(Number);
const startAt = Math.floor(new Date(sy, sm - 1, sd, 0, 0, 0, 0).getTime() / 1000);
const endAt = Math.floor(new Date(ey, em - 1, ed, 23, 59, 59, 999).getTime() / 1000);
return { startAt, endAt };
}
export function formatDateInput(date: Date) {
const y = date.getFullYear();
const m = String(date.getMonth() + 1).padStart(2, '0');
const d = String(date.getDate()).padStart(2, '0');
return `${y}-${m}-${d}`;
}
export function presetDateRange(days: number) {
const end = new Date();
end.setHours(0, 0, 0, 0);
const start = new Date(end);
start.setDate(start.getDate() - (days - 1));
return { startDate: formatDateInput(start), endDate: formatDateInput(end) };
}
-291
View File
@@ -2097,297 +2097,6 @@ body,
text-align: center;
}
.usage-toolbar {
flex-wrap: wrap;
}
.usage-tab-head {
flex-wrap: wrap;
gap: 12px;
}
.usage-view-switch {
display: inline-flex;
border: 1px solid var(--color-border-input);
border-radius: var(--radius-md);
overflow: hidden;
}
.usage-view-btn {
padding: 6px 14px;
font-size: 13px;
border: none;
background: var(--color-bg-base);
color: var(--color-text-muted);
cursor: pointer;
}
.usage-view-btn.active {
background: var(--color-accent);
color: #fff;
}
.usage-tab-body {
margin-top: 4px;
}
.usage-query-toolbar {
margin-bottom: 16px;
}
.usage-user-balance-bar {
display: flex;
flex-wrap: wrap;
align-items: baseline;
gap: 8px 16px;
margin-bottom: 16px;
padding: 12px 16px;
border: 1px solid var(--color-border-subtle);
border-radius: var(--radius-md);
background: var(--color-bg-subtle);
}
.usage-user-balance-name {
font-weight: 600;
margin-right: auto;
}
.usage-user-balance-value {
font-size: 20px;
font-weight: 600;
font-variant-numeric: tabular-nums;
}
.usage-summary-panel {
display: flex;
flex-direction: column;
gap: 16px;
margin-bottom: 16px;
}
.usage-summary-block {
padding: 14px 16px;
border: 1px solid var(--color-border-subtle);
border-radius: var(--radius-md);
background: var(--color-bg-subtle);
}
.usage-summary-title {
margin: 0 0 10px;
font-size: 13px;
font-weight: 600;
}
.usage-summary-grid {
grid-template-columns: repeat(auto-fit, minmax(140px, 1fr));
}
.usage-summary-grid .admin-stat-card {
padding: 10px 12px;
}
.usage-summary-grid .admin-stat-value {
font-size: 20px;
}
.usage-summary-sub {
margin-top: 4px;
font-size: 11px;
}
.usage-summary-loading {
margin-bottom: 12px;
}
.usage-overview-filter,
.usage-overview-chart-bar {
display: flex;
align-items: center;
gap: 10px;
margin-bottom: 12px;
flex-wrap: wrap;
}
.usage-overview-chart-label {
font-size: 13px;
}
.usage-chart-hint {
margin: 4px 0 0;
font-size: 12px;
}
.usage-preset-btn.active {
background: var(--color-accent);
border-color: var(--color-accent);
color: #fff;
}
.usage-date-range {
display: flex;
align-items: center;
gap: 6px;
}
.usage-date-input {
width: 138px;
}
.usage-date-sep {
font-size: 12px;
}
.usage-preset-btns {
display: flex;
gap: 4px;
}
.usage-preset-btn {
padding: 5px 10px;
font-size: 12px;
min-width: unset;
}
.usage-chart-panel {
margin: 16px 0 20px;
padding: 16px;
border: 1px solid var(--color-border-subtle);
border-radius: var(--radius-md);
background: var(--color-bg-subtle);
}
.usage-chart-head {
display: flex;
align-items: center;
justify-content: space-between;
gap: 12px;
margin-bottom: 12px;
flex-wrap: wrap;
}
.usage-chart-head h3 {
margin: 0;
font-size: 14px;
font-weight: 600;
}
.usage-chart-controls {
display: flex;
align-items: center;
gap: 12px;
flex-wrap: wrap;
}
.usage-metric-checks {
display: flex;
align-items: center;
gap: 8px;
flex-wrap: wrap;
}
.usage-metric-check {
display: inline-flex;
align-items: center;
gap: 5px;
padding: 4px 10px;
border: 1px solid var(--color-border-input);
border-radius: var(--radius-md);
font-size: 12px;
cursor: pointer;
user-select: none;
background: var(--color-bg-base);
color: var(--color-text-muted);
}
.usage-metric-check.checked {
border-color: var(--color-border-subtle);
color: var(--color-text-primary);
}
.usage-metric-check input {
position: absolute;
opacity: 0;
width: 0;
height: 0;
}
.usage-metric-check-dot {
width: 8px;
height: 8px;
border-radius: 50%;
flex-shrink: 0;
}
.usage-chart-tooltip-metric {
display: flex;
align-items: center;
gap: 6px;
margin-top: 2px;
}
.usage-chart-tooltip-dot {
width: 8px;
height: 8px;
border-radius: 50%;
flex-shrink: 0;
}
.usage-chart-tooltip-detail {
margin-top: 6px;
font-size: 11px;
}
.usage-chart-select {
min-width: 120px;
padding: 5px 8px;
font-size: 12px;
}
.usage-chart-type-toggle {
display: inline-flex;
border: 1px solid var(--color-border-input);
border-radius: var(--radius-md);
overflow: hidden;
}
.usage-chart-type-btn {
padding: 5px 12px;
font-size: 12px;
border: none;
background: var(--color-bg-base);
color: var(--color-text-muted);
cursor: pointer;
}
.usage-chart-type-btn.active {
background: var(--color-accent);
color: #fff;
}
.usage-chart-wrap {
width: 100%;
}
.usage-chart-empty {
padding: 40px 0;
text-align: center;
}
.usage-chart-tooltip {
padding: 8px 10px;
border-radius: var(--radius-md);
background: var(--color-bg-base);
border: 1px solid var(--color-border-subtle);
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.08);
font-size: 12px;
line-height: 1.5;
}
.usage-chart-tooltip-title {
font-weight: 600;
margin-bottom: 4px;
}
.text-income {
color: #22c55e;
}
-91
View File
@@ -237,7 +237,6 @@ export type UsageRecord = {
id: number;
userId: string;
username: string;
displayName: string;
agentSessionId: string;
requestId: string | null;
inputTokens: number;
@@ -247,37 +246,6 @@ export type UsageRecord = {
createdAt: number;
};
export type UsageStatsBucket = {
day: string;
count: number;
inputTokens: number;
outputTokens: number;
costCents: number;
};
export type UsageTotals = {
count: number;
inputTokens: number;
outputTokens: number;
costCents: number;
};
export type UsageRangeTotals = UsageTotals & {
startAt: number;
endAt: number;
};
export type UsageSummaryResult = {
range: UsageRangeTotals | null;
allTime: UsageTotals;
};
export type UsageStatsResult = {
buckets: UsageStatsBucket[];
startAt: number;
endAt: number;
};
export type LedgerEntry = {
id: number;
userId: string;
@@ -317,31 +285,6 @@ 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 MindSpaceAdminConfig = {
publicPageLimit: number;
analytics: {
@@ -399,14 +342,6 @@ export type MemoryV2AdminConfigResponse = {
updatedBy: string | null;
};
export type MemoryV2ConfigRuntimeState = {
source: string;
updatedAt: number | null;
updatedBy: string | null;
fingerprint?: string;
overrides: Record<string, string>;
};
export type MemoryV2RuntimeStatusResponse = {
ok: boolean;
checkedAt?: number;
@@ -681,32 +616,6 @@ 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;
};