mirror of
https://github.com/fawney19/Aether.git
synced 2026-01-06 09:42:28 +08:00
Compare commits
53 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
0f78d5cbf3 | ||
|
|
431c6de8d2 | ||
|
|
142e15bbcc | ||
|
|
31acc5c607 | ||
|
|
bfa0a26d41 | ||
|
|
93ab9b6a5e | ||
|
|
35e29d46bd | ||
|
|
465da6f818 | ||
|
|
e5f12fddd9 | ||
|
|
4fa9a1303a | ||
|
|
43f349d415 | ||
|
|
02069954de | ||
|
|
2e15875fed | ||
|
|
b34cfb676d | ||
|
|
3064497636 | ||
|
|
dec681fea0 | ||
|
|
523e27ba9a | ||
|
|
e7db76e581 | ||
|
|
689339117a | ||
|
|
b202765be4 | ||
|
|
3bbf3073df | ||
|
|
f46aaa2182 | ||
|
|
a2f33a6c35 | ||
|
|
b6bd6357ed | ||
|
|
c3a5878b1b | ||
|
|
c02ac56da8 | ||
|
|
cddc22d2b3 | ||
|
|
11ded575d5 | ||
|
|
394cc536a9 | ||
|
|
6bd8cdb9cf | ||
|
|
e20a09f15a | ||
|
|
b89a4af0cf | ||
|
|
a56854af43 | ||
|
|
4a35d78c8d | ||
|
|
26b281271e | ||
|
|
96094cfde2 | ||
|
|
7e26af5476 | ||
|
|
c8dfb784bc | ||
|
|
fd3a5a5afe | ||
|
|
599b3d4c95 | ||
|
|
41719a00e7 | ||
|
|
b5c0f85dca | ||
|
|
7d6d262ed3 | ||
|
|
e21acd73eb | ||
|
|
702f9bc5f1 | ||
|
|
d0ce798881 | ||
|
|
2b1d197047 | ||
|
|
71bc2e6aab | ||
|
|
afb329934a | ||
|
|
1313af45a3 | ||
|
|
dddb327885 | ||
|
|
26b4a37323 | ||
|
|
9dad194130 |
15
LICENSE
15
LICENSE
@@ -5,12 +5,17 @@ Aether 非商业开源许可证
|
||||
特此授予任何获得本软件及其相关文档文件(以下简称"软件")副本的人免费使用、
|
||||
复制、修改、合并、发布和分发本软件的权限,但须遵守以下条件:
|
||||
|
||||
1. 仅限非商业用途
|
||||
本软件不得用于商业目的。商业目的包括但不限于:
|
||||
1. 仅限非盈利用途
|
||||
本软件不得用于盈利目的。盈利目的包括但不限于:
|
||||
- 出售本软件或任何衍生作品
|
||||
- 使用本软件提供付费服务
|
||||
- 将本软件用于商业产品或服务
|
||||
- 将本软件用于任何旨在获取商业利益或金钱报酬的活动
|
||||
- 将本软件用于以盈利为目的的商业产品或服务
|
||||
|
||||
以下用途被明确允许:
|
||||
- 个人学习和研究
|
||||
- 教育机构的教学和研究
|
||||
- 非盈利组织的内部使用
|
||||
- 企业内部非盈利性质的使用(如内部工具、测试环境等)
|
||||
|
||||
2. 署名要求
|
||||
上述版权声明和本许可声明应包含在本软件的所有副本或主要部分中。
|
||||
@@ -22,7 +27,7 @@ Aether 非商业开源许可证
|
||||
您不得以不同的条款将本软件再许可给他人。
|
||||
|
||||
5. 商业许可
|
||||
如需商业使用,请联系版权持有人以获取单独的商业许可。
|
||||
如需将本软件用于盈利目的,请联系版权持有人以获取单独的商业许可。
|
||||
|
||||
本软件按"原样"提供,不提供任何明示或暗示的保证,包括但不限于对适销性、
|
||||
特定用途适用性和非侵权性的保证。在任何情况下,作者或版权持有人均不对任何
|
||||
|
||||
22
README.md
22
README.md
@@ -58,13 +58,13 @@ cp .env.example .env
|
||||
python generate_keys.py # 生成密钥, 并将生成的密钥填入 .env
|
||||
|
||||
# 3. 部署
|
||||
docker-compose up -d
|
||||
docker compose up -d
|
||||
|
||||
# 4. 首次部署时, 初始化数据库
|
||||
./migrate.sh
|
||||
|
||||
# 5. 更新
|
||||
docker-compose pull && docker-compose up -d && ./migrate.sh
|
||||
docker compose pull && docker compose up -d && ./migrate.sh
|
||||
```
|
||||
|
||||
### Docker Compose(本地构建镜像)
|
||||
@@ -86,7 +86,7 @@ python generate_keys.py # 生成密钥, 并将生成的密钥填入 .env
|
||||
|
||||
```bash
|
||||
# 启动依赖
|
||||
docker-compose -f docker-compose.build.yml up -d postgres redis
|
||||
docker compose -f docker-compose.build.yml up -d postgres redis
|
||||
|
||||
# 后端
|
||||
uv sync
|
||||
@@ -143,7 +143,7 @@ cd frontend && npm install && npm run dev
|
||||
- **模型级别**: 在模型管理中针对指定模型开启 1H缓存策略
|
||||
- **密钥级别**: 在密钥管理中针对指定密钥使用 1H缓存策略
|
||||
|
||||
> **注意**: 若对密钥设置强制 1H缓存, 则该密钥只能调用支持 1H缓存的模型
|
||||
> **注意**: 若对密钥设置强制 1H缓存, 则该密钥只能使用支持 1H缓存的模型, 匹配提供商Key, 将会导致这个Key无法同时用于Claude Code、Codex、GeminiCLI, 因为更推荐使用模型开启1H缓存.
|
||||
|
||||
### Q: 如何配置负载均衡?
|
||||
|
||||
@@ -162,4 +162,16 @@ cd frontend && npm install && npm run dev
|
||||
|
||||
## 许可证
|
||||
|
||||
本项目采用 [Aether 非商业开源许可证](LICENSE)。
|
||||
本项目采用 [Aether 非商业开源许可证](LICENSE)。允许个人学习、教育研究、非盈利组织及企业内部非盈利性质的使用;禁止用于盈利目的。商业使用请联系获取商业许可。
|
||||
|
||||
## 联系作者
|
||||
|
||||
<p align="center">
|
||||
<img src="docs/author/qq_qrcode.jpg" width="200" alt="QQ二维码">
|
||||
</p>
|
||||
|
||||
## Star History
|
||||
|
||||
[](https://star-history.com/#fawney19/Aether&Date)
|
||||
|
||||
|
||||
|
||||
@@ -30,7 +30,7 @@ from src.models.database import Base
|
||||
config = context.config
|
||||
|
||||
# 从环境变量获取数据库 URL
|
||||
# 优先使用 DATABASE_URL,否则从 DB_PASSWORD 自动构建(与 docker-compose 保持一致)
|
||||
# 优先使用 DATABASE_URL,否则从 DB_PASSWORD 自动构建(与 docker compose 保持一致)
|
||||
database_url = os.getenv("DATABASE_URL")
|
||||
if not database_url:
|
||||
db_password = os.getenv("DB_PASSWORD", "")
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
# Aether 部署配置 - 本地构建
|
||||
# 使用方法:
|
||||
# 首次构建 base: docker build -f Dockerfile.base -t aether-base:latest .
|
||||
# 启动服务: docker-compose -f docker-compose.build.yml up -d --build
|
||||
# 启动服务: docker compose -f docker-compose.build.yml up -d --build
|
||||
|
||||
services:
|
||||
postgres:
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
# Aether 部署配置 - 使用预构建镜像
|
||||
# 使用方法: docker-compose up -d
|
||||
# 使用方法: docker compose up -d
|
||||
|
||||
services:
|
||||
postgres:
|
||||
|
||||
BIN
docs/author/qq_qrcode.jpg
Normal file
BIN
docs/author/qq_qrcode.jpg
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 266 KiB |
BIN
docs/author/wechat_payment.jpg
Normal file
BIN
docs/author/wechat_payment.jpg
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 113 KiB |
522
frontend/package-lock.json
generated
522
frontend/package-lock.json
generated
@@ -262,6 +262,7 @@
|
||||
}
|
||||
],
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
},
|
||||
@@ -305,6 +306,7 @@
|
||||
}
|
||||
],
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
@@ -316,9 +318,9 @@
|
||||
"license": "Apache-2.0"
|
||||
},
|
||||
"node_modules/@esbuild/aix-ppc64": {
|
||||
"version": "0.25.9",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.25.9.tgz",
|
||||
"integrity": "sha512-OaGtL73Jck6pBKjNIe24BnFE6agGl+6KxDtTfHhy1HmhthfKouEcOhqpSL64K4/0WCtbKFLOdzD/44cJ4k9opA==",
|
||||
"version": "0.27.2",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.27.2.tgz",
|
||||
"integrity": "sha512-GZMB+a0mOMZs4MpDbj8RJp4cw+w1WV5NYD6xzgvzUJ5Ek2jerwfO2eADyI6ExDSUED+1X8aMbegahsJi+8mgpw==",
|
||||
"cpu": [
|
||||
"ppc64"
|
||||
],
|
||||
@@ -333,9 +335,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@esbuild/android-arm": {
|
||||
"version": "0.25.9",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.25.9.tgz",
|
||||
"integrity": "sha512-5WNI1DaMtxQ7t7B6xa572XMXpHAaI/9Hnhk8lcxF4zVN4xstUgTlvuGDorBguKEnZO70qwEcLpfifMLoxiPqHQ==",
|
||||
"version": "0.27.2",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.27.2.tgz",
|
||||
"integrity": "sha512-DVNI8jlPa7Ujbr1yjU2PfUSRtAUZPG9I1RwW4F4xFB1Imiu2on0ADiI/c3td+KmDtVKNbi+nffGDQMfcIMkwIA==",
|
||||
"cpu": [
|
||||
"arm"
|
||||
],
|
||||
@@ -350,9 +352,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@esbuild/android-arm64": {
|
||||
"version": "0.25.9",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.25.9.tgz",
|
||||
"integrity": "sha512-IDrddSmpSv51ftWslJMvl3Q2ZT98fUSL2/rlUXuVqRXHCs5EUF1/f+jbjF5+NG9UffUDMCiTyh8iec7u8RlTLg==",
|
||||
"version": "0.27.2",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.27.2.tgz",
|
||||
"integrity": "sha512-pvz8ZZ7ot/RBphf8fv60ljmaoydPU12VuXHImtAs0XhLLw+EXBi2BLe3OYSBslR4rryHvweW5gmkKFwTiFy6KA==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
@@ -367,9 +369,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@esbuild/android-x64": {
|
||||
"version": "0.25.9",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.25.9.tgz",
|
||||
"integrity": "sha512-I853iMZ1hWZdNllhVZKm34f4wErd4lMyeV7BLzEExGEIZYsOzqDWDf+y082izYUE8gtJnYHdeDpN/6tUdwvfiw==",
|
||||
"version": "0.27.2",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.27.2.tgz",
|
||||
"integrity": "sha512-z8Ank4Byh4TJJOh4wpz8g2vDy75zFL0TlZlkUkEwYXuPSgX8yzep596n6mT7905kA9uHZsf/o2OJZubl2l3M7A==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
@@ -384,9 +386,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@esbuild/darwin-arm64": {
|
||||
"version": "0.25.9",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.25.9.tgz",
|
||||
"integrity": "sha512-XIpIDMAjOELi/9PB30vEbVMs3GV1v2zkkPnuyRRURbhqjyzIINwj+nbQATh4H9GxUgH1kFsEyQMxwiLFKUS6Rg==",
|
||||
"version": "0.27.2",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.27.2.tgz",
|
||||
"integrity": "sha512-davCD2Zc80nzDVRwXTcQP/28fiJbcOwvdolL0sOiOsbwBa72kegmVU0Wrh1MYrbuCL98Omp5dVhQFWRKR2ZAlg==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
@@ -401,9 +403,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@esbuild/darwin-x64": {
|
||||
"version": "0.25.9",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.25.9.tgz",
|
||||
"integrity": "sha512-jhHfBzjYTA1IQu8VyrjCX4ApJDnH+ez+IYVEoJHeqJm9VhG9Dh2BYaJritkYK3vMaXrf7Ogr/0MQ8/MeIefsPQ==",
|
||||
"version": "0.27.2",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.27.2.tgz",
|
||||
"integrity": "sha512-ZxtijOmlQCBWGwbVmwOF/UCzuGIbUkqB1faQRf5akQmxRJ1ujusWsb3CVfk/9iZKr2L5SMU5wPBi1UWbvL+VQA==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
@@ -418,9 +420,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@esbuild/freebsd-arm64": {
|
||||
"version": "0.25.9",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.25.9.tgz",
|
||||
"integrity": "sha512-z93DmbnY6fX9+KdD4Ue/H6sYs+bhFQJNCPZsi4XWJoYblUqT06MQUdBCpcSfuiN72AbqeBFu5LVQTjfXDE2A6Q==",
|
||||
"version": "0.27.2",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.27.2.tgz",
|
||||
"integrity": "sha512-lS/9CN+rgqQ9czogxlMcBMGd+l8Q3Nj1MFQwBZJyoEKI50XGxwuzznYdwcav6lpOGv5BqaZXqvBSiB/kJ5op+g==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
@@ -435,9 +437,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@esbuild/freebsd-x64": {
|
||||
"version": "0.25.9",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.25.9.tgz",
|
||||
"integrity": "sha512-mrKX6H/vOyo5v71YfXWJxLVxgy1kyt1MQaD8wZJgJfG4gq4DpQGpgTB74e5yBeQdyMTbgxp0YtNj7NuHN0PoZg==",
|
||||
"version": "0.27.2",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.27.2.tgz",
|
||||
"integrity": "sha512-tAfqtNYb4YgPnJlEFu4c212HYjQWSO/w/h/lQaBK7RbwGIkBOuNKQI9tqWzx7Wtp7bTPaGC6MJvWI608P3wXYA==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
@@ -452,9 +454,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@esbuild/linux-arm": {
|
||||
"version": "0.25.9",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.25.9.tgz",
|
||||
"integrity": "sha512-HBU2Xv78SMgaydBmdor38lg8YDnFKSARg1Q6AT0/y2ezUAKiZvc211RDFHlEZRFNRVhcMamiToo7bDx3VEOYQw==",
|
||||
"version": "0.27.2",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.27.2.tgz",
|
||||
"integrity": "sha512-vWfq4GaIMP9AIe4yj1ZUW18RDhx6EPQKjwe7n8BbIecFtCQG4CfHGaHuh7fdfq+y3LIA2vGS/o9ZBGVxIDi9hw==",
|
||||
"cpu": [
|
||||
"arm"
|
||||
],
|
||||
@@ -469,9 +471,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@esbuild/linux-arm64": {
|
||||
"version": "0.25.9",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.25.9.tgz",
|
||||
"integrity": "sha512-BlB7bIcLT3G26urh5Dmse7fiLmLXnRlopw4s8DalgZ8ef79Jj4aUcYbk90g8iCa2467HX8SAIidbL7gsqXHdRw==",
|
||||
"version": "0.27.2",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.27.2.tgz",
|
||||
"integrity": "sha512-hYxN8pr66NsCCiRFkHUAsxylNOcAQaxSSkHMMjcpx0si13t1LHFphxJZUiGwojB1a/Hd5OiPIqDdXONia6bhTw==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
@@ -486,9 +488,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@esbuild/linux-ia32": {
|
||||
"version": "0.25.9",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.25.9.tgz",
|
||||
"integrity": "sha512-e7S3MOJPZGp2QW6AK6+Ly81rC7oOSerQ+P8L0ta4FhVi+/j/v2yZzx5CqqDaWjtPFfYz21Vi1S0auHrap3Ma3A==",
|
||||
"version": "0.27.2",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.27.2.tgz",
|
||||
"integrity": "sha512-MJt5BRRSScPDwG2hLelYhAAKh9imjHK5+NE/tvnRLbIqUWa+0E9N4WNMjmp/kXXPHZGqPLxggwVhz7QP8CTR8w==",
|
||||
"cpu": [
|
||||
"ia32"
|
||||
],
|
||||
@@ -503,9 +505,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@esbuild/linux-loong64": {
|
||||
"version": "0.25.9",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.25.9.tgz",
|
||||
"integrity": "sha512-Sbe10Bnn0oUAB2AalYztvGcK+o6YFFA/9829PhOCUS9vkJElXGdphz0A3DbMdP8gmKkqPmPcMJmJOrI3VYB1JQ==",
|
||||
"version": "0.27.2",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.27.2.tgz",
|
||||
"integrity": "sha512-lugyF1atnAT463aO6KPshVCJK5NgRnU4yb3FUumyVz+cGvZbontBgzeGFO1nF+dPueHD367a2ZXe1NtUkAjOtg==",
|
||||
"cpu": [
|
||||
"loong64"
|
||||
],
|
||||
@@ -520,9 +522,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@esbuild/linux-mips64el": {
|
||||
"version": "0.25.9",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.25.9.tgz",
|
||||
"integrity": "sha512-YcM5br0mVyZw2jcQeLIkhWtKPeVfAerES5PvOzaDxVtIyZ2NUBZKNLjC5z3/fUlDgT6w89VsxP2qzNipOaaDyA==",
|
||||
"version": "0.27.2",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.27.2.tgz",
|
||||
"integrity": "sha512-nlP2I6ArEBewvJ2gjrrkESEZkB5mIoaTswuqNFRv/WYd+ATtUpe9Y09RnJvgvdag7he0OWgEZWhviS1OTOKixw==",
|
||||
"cpu": [
|
||||
"mips64el"
|
||||
],
|
||||
@@ -537,9 +539,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@esbuild/linux-ppc64": {
|
||||
"version": "0.25.9",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.25.9.tgz",
|
||||
"integrity": "sha512-++0HQvasdo20JytyDpFvQtNrEsAgNG2CY1CLMwGXfFTKGBGQT3bOeLSYE2l1fYdvML5KUuwn9Z8L1EWe2tzs1w==",
|
||||
"version": "0.27.2",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.27.2.tgz",
|
||||
"integrity": "sha512-C92gnpey7tUQONqg1n6dKVbx3vphKtTHJaNG2Ok9lGwbZil6DrfyecMsp9CrmXGQJmZ7iiVXvvZH6Ml5hL6XdQ==",
|
||||
"cpu": [
|
||||
"ppc64"
|
||||
],
|
||||
@@ -554,9 +556,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@esbuild/linux-riscv64": {
|
||||
"version": "0.25.9",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.25.9.tgz",
|
||||
"integrity": "sha512-uNIBa279Y3fkjV+2cUjx36xkx7eSjb8IvnL01eXUKXez/CBHNRw5ekCGMPM0BcmqBxBcdgUWuUXmVWwm4CH9kg==",
|
||||
"version": "0.27.2",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.27.2.tgz",
|
||||
"integrity": "sha512-B5BOmojNtUyN8AXlK0QJyvjEZkWwy/FKvakkTDCziX95AowLZKR6aCDhG7LeF7uMCXEJqwa8Bejz5LTPYm8AvA==",
|
||||
"cpu": [
|
||||
"riscv64"
|
||||
],
|
||||
@@ -571,9 +573,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@esbuild/linux-s390x": {
|
||||
"version": "0.25.9",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.25.9.tgz",
|
||||
"integrity": "sha512-Mfiphvp3MjC/lctb+7D287Xw1DGzqJPb/J2aHHcHxflUo+8tmN/6d4k6I2yFR7BVo5/g7x2Monq4+Yew0EHRIA==",
|
||||
"version": "0.27.2",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.27.2.tgz",
|
||||
"integrity": "sha512-p4bm9+wsPwup5Z8f4EpfN63qNagQ47Ua2znaqGH6bqLlmJ4bx97Y9JdqxgGZ6Y8xVTixUnEkoKSHcpRlDnNr5w==",
|
||||
"cpu": [
|
||||
"s390x"
|
||||
],
|
||||
@@ -588,9 +590,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@esbuild/linux-x64": {
|
||||
"version": "0.25.9",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.25.9.tgz",
|
||||
"integrity": "sha512-iSwByxzRe48YVkmpbgoxVzn76BXjlYFXC7NvLYq+b+kDjyyk30J0JY47DIn8z1MO3K0oSl9fZoRmZPQI4Hklzg==",
|
||||
"version": "0.27.2",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.27.2.tgz",
|
||||
"integrity": "sha512-uwp2Tip5aPmH+NRUwTcfLb+W32WXjpFejTIOWZFw/v7/KnpCDKG66u4DLcurQpiYTiYwQ9B7KOeMJvLCu/OvbA==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
@@ -605,9 +607,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@esbuild/netbsd-arm64": {
|
||||
"version": "0.25.9",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/netbsd-arm64/-/netbsd-arm64-0.25.9.tgz",
|
||||
"integrity": "sha512-9jNJl6FqaUG+COdQMjSCGW4QiMHH88xWbvZ+kRVblZsWrkXlABuGdFJ1E9L7HK+T0Yqd4akKNa/lO0+jDxQD4Q==",
|
||||
"version": "0.27.2",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/netbsd-arm64/-/netbsd-arm64-0.27.2.tgz",
|
||||
"integrity": "sha512-Kj6DiBlwXrPsCRDeRvGAUb/LNrBASrfqAIok+xB0LxK8CHqxZ037viF13ugfsIpePH93mX7xfJp97cyDuTZ3cw==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
@@ -622,9 +624,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@esbuild/netbsd-x64": {
|
||||
"version": "0.25.9",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.25.9.tgz",
|
||||
"integrity": "sha512-RLLdkflmqRG8KanPGOU7Rpg829ZHu8nFy5Pqdi9U01VYtG9Y0zOG6Vr2z4/S+/3zIyOxiK6cCeYNWOFR9QP87g==",
|
||||
"version": "0.27.2",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.27.2.tgz",
|
||||
"integrity": "sha512-HwGDZ0VLVBY3Y+Nw0JexZy9o/nUAWq9MlV7cahpaXKW6TOzfVno3y3/M8Ga8u8Yr7GldLOov27xiCnqRZf0tCA==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
@@ -639,9 +641,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@esbuild/openbsd-arm64": {
|
||||
"version": "0.25.9",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/openbsd-arm64/-/openbsd-arm64-0.25.9.tgz",
|
||||
"integrity": "sha512-YaFBlPGeDasft5IIM+CQAhJAqS3St3nJzDEgsgFixcfZeyGPCd6eJBWzke5piZuZ7CtL656eOSYKk4Ls2C0FRQ==",
|
||||
"version": "0.27.2",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/openbsd-arm64/-/openbsd-arm64-0.27.2.tgz",
|
||||
"integrity": "sha512-DNIHH2BPQ5551A7oSHD0CKbwIA/Ox7+78/AWkbS5QoRzaqlev2uFayfSxq68EkonB+IKjiuxBFoV8ESJy8bOHA==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
@@ -656,9 +658,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@esbuild/openbsd-x64": {
|
||||
"version": "0.25.9",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.25.9.tgz",
|
||||
"integrity": "sha512-1MkgTCuvMGWuqVtAvkpkXFmtL8XhWy+j4jaSO2wxfJtilVCi0ZE37b8uOdMItIHz4I6z1bWWtEX4CJwcKYLcuA==",
|
||||
"version": "0.27.2",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.27.2.tgz",
|
||||
"integrity": "sha512-/it7w9Nb7+0KFIzjalNJVR5bOzA9Vay+yIPLVHfIQYG/j+j9VTH84aNB8ExGKPU4AzfaEvN9/V4HV+F+vo8OEg==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
@@ -673,9 +675,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@esbuild/openharmony-arm64": {
|
||||
"version": "0.25.9",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/openharmony-arm64/-/openharmony-arm64-0.25.9.tgz",
|
||||
"integrity": "sha512-4Xd0xNiMVXKh6Fa7HEJQbrpP3m3DDn43jKxMjxLLRjWnRsfxjORYJlXPO4JNcXtOyfajXorRKY9NkOpTHptErg==",
|
||||
"version": "0.27.2",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/openharmony-arm64/-/openharmony-arm64-0.27.2.tgz",
|
||||
"integrity": "sha512-LRBbCmiU51IXfeXk59csuX/aSaToeG7w48nMwA6049Y4J4+VbWALAuXcs+qcD04rHDuSCSRKdmY63sruDS5qag==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
@@ -690,9 +692,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@esbuild/sunos-x64": {
|
||||
"version": "0.25.9",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.25.9.tgz",
|
||||
"integrity": "sha512-WjH4s6hzo00nNezhp3wFIAfmGZ8U7KtrJNlFMRKxiI9mxEK1scOMAaa9i4crUtu+tBr+0IN6JCuAcSBJZfnphw==",
|
||||
"version": "0.27.2",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.27.2.tgz",
|
||||
"integrity": "sha512-kMtx1yqJHTmqaqHPAzKCAkDaKsffmXkPHThSfRwZGyuqyIeBvf08KSsYXl+abf5HDAPMJIPnbBfXvP2ZC2TfHg==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
@@ -707,9 +709,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@esbuild/win32-arm64": {
|
||||
"version": "0.25.9",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.25.9.tgz",
|
||||
"integrity": "sha512-mGFrVJHmZiRqmP8xFOc6b84/7xa5y5YvR1x8djzXpJBSv/UsNK6aqec+6JDjConTgvvQefdGhFDAs2DLAds6gQ==",
|
||||
"version": "0.27.2",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.27.2.tgz",
|
||||
"integrity": "sha512-Yaf78O/B3Kkh+nKABUF++bvJv5Ijoy9AN1ww904rOXZFLWVc5OLOfL56W+C8F9xn5JQZa3UX6m+IktJnIb1Jjg==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
@@ -724,9 +726,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@esbuild/win32-ia32": {
|
||||
"version": "0.25.9",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.25.9.tgz",
|
||||
"integrity": "sha512-b33gLVU2k11nVx1OhX3C8QQP6UHQK4ZtN56oFWvVXvz2VkDoe6fbG8TOgHFxEvqeqohmRnIHe5A1+HADk4OQww==",
|
||||
"version": "0.27.2",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.27.2.tgz",
|
||||
"integrity": "sha512-Iuws0kxo4yusk7sw70Xa2E2imZU5HoixzxfGCdxwBdhiDgt9vX9VUCBhqcwY7/uh//78A1hMkkROMJq9l27oLQ==",
|
||||
"cpu": [
|
||||
"ia32"
|
||||
],
|
||||
@@ -741,9 +743,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@esbuild/win32-x64": {
|
||||
"version": "0.25.9",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.25.9.tgz",
|
||||
"integrity": "sha512-PPOl1mi6lpLNQxnGoyAfschAodRFYXJ+9fs6WHXz7CSWKbOqiMZsubC+BQsVKuul+3vKLuwTHsS2c2y9EoKwxQ==",
|
||||
"version": "0.27.2",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.27.2.tgz",
|
||||
"integrity": "sha512-sRdU18mcKf7F+YgheI/zGf5alZatMUTKj/jNS6l744f9u3WFu4v7twcUI9vu4mknF4Y9aDlblIie0IM+5xxaqQ==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
@@ -1598,6 +1600,7 @@
|
||||
"integrity": "sha512-GKBNHjoNw3Kra1Qg5UXttsY5kiWMEfoHq2TmXb+b1rcm6N7B3wTrFYIf/oSZ1xNQ+hVVijgLkiDZh7jRRsh+Gw==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"undici-types": "~7.10.0"
|
||||
}
|
||||
@@ -1676,6 +1679,7 @@
|
||||
"integrity": "sha512-N9lBGA9o9aqb1hVMc9hzySbhKibHmB+N3IpoShyV6HyQYRGIhlrO5rQgttypi+yEeKsKI4idxC8Jw6gXKD4THA==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"@typescript-eslint/scope-manager": "8.49.0",
|
||||
"@typescript-eslint/types": "8.49.0",
|
||||
@@ -2004,6 +2008,7 @@
|
||||
"integrity": "sha512-oWtNM89Np+YsQO3ttT5i1Aer/0xbzQzp66NzuJn/U16bB7MnvSzdLKXgk1kkMLYyKSSzA2ajzqMkYheaE9opuQ==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"@vitest/utils": "4.0.10",
|
||||
"fflate": "^0.8.2",
|
||||
@@ -2301,6 +2306,7 @@
|
||||
"integrity": "sha512-NZyJarBfL7nWwIq+FDL6Zp/yHEhePMNnnJ0y3qfieCrmNvYct8uvtiV41UvlSe6apAfk0fY1FbWx+NwfmpvtTg==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"bin": {
|
||||
"acorn": "bin/acorn"
|
||||
},
|
||||
@@ -2602,6 +2608,7 @@
|
||||
}
|
||||
],
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"baseline-browser-mapping": "^2.8.2",
|
||||
"caniuse-lite": "^1.0.30001741",
|
||||
@@ -2718,6 +2725,7 @@
|
||||
"resolved": "https://registry.npmjs.org/chart.js/-/chart.js-4.5.0.tgz",
|
||||
"integrity": "sha512-aYeC/jDgSEx8SHWZvANYMioYMZ2KX02W6f6uVfyteuCGcadDLcYVHdfdygsTQkQ4TKn5lghoojAsPj5pu0SnvQ==",
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"@kurkle/color": "^0.3.0"
|
||||
},
|
||||
@@ -2940,6 +2948,7 @@
|
||||
"resolved": "https://registry.npmjs.org/date-fns/-/date-fns-4.1.0.tgz",
|
||||
"integrity": "sha512-Ukq0owbQXxa/U3EGtsdVBkR1w7KOQ5gIBqdH2hkvknzZPYvBxb/aa6E8L7tmjFtkwZBu3UXBbjIgPo/Ez4xaNg==",
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"funding": {
|
||||
"type": "github",
|
||||
"url": "https://github.com/sponsors/kossnocorp"
|
||||
@@ -2999,18 +3008,6 @@
|
||||
"node": ">=0.4.0"
|
||||
}
|
||||
},
|
||||
"node_modules/detect-libc": {
|
||||
"version": "2.0.4",
|
||||
"resolved": "https://registry.npmjs.org/detect-libc/-/detect-libc-2.0.4.tgz",
|
||||
"integrity": "sha512-3UDv+G9CsCKO1WKMGw9fwq/SWJYbI0c5Y7LU1AXYoDdbhE2AHQ6N6Nb34sG8Fj7T5APy8qXDCKuuIHd1BR0tVA==",
|
||||
"dev": true,
|
||||
"license": "Apache-2.0",
|
||||
"optional": true,
|
||||
"peer": true,
|
||||
"engines": {
|
||||
"node": ">=8"
|
||||
}
|
||||
},
|
||||
"node_modules/didyoumean": {
|
||||
"version": "1.2.2",
|
||||
"resolved": "https://registry.npmjs.org/didyoumean/-/didyoumean-1.2.2.tgz",
|
||||
@@ -3134,9 +3131,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/esbuild": {
|
||||
"version": "0.25.9",
|
||||
"resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.25.9.tgz",
|
||||
"integrity": "sha512-CRbODhYyQx3qp7ZEwzxOk4JBqmD/seJrzPa/cGjY1VtIn5E09Oi9/dB4JwctnfZ8Q8iT7rioVv5k/FNT/uf54g==",
|
||||
"version": "0.27.2",
|
||||
"resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.27.2.tgz",
|
||||
"integrity": "sha512-HyNQImnsOC7X9PMNaCIeAm4ISCQXs5a5YasTXVliKv4uuBo1dKrG0A+uQS8M5eXjVMnLg3WgXaKvprHlFJQffw==",
|
||||
"dev": true,
|
||||
"hasInstallScript": true,
|
||||
"license": "MIT",
|
||||
@@ -3147,32 +3144,32 @@
|
||||
"node": ">=18"
|
||||
},
|
||||
"optionalDependencies": {
|
||||
"@esbuild/aix-ppc64": "0.25.9",
|
||||
"@esbuild/android-arm": "0.25.9",
|
||||
"@esbuild/android-arm64": "0.25.9",
|
||||
"@esbuild/android-x64": "0.25.9",
|
||||
"@esbuild/darwin-arm64": "0.25.9",
|
||||
"@esbuild/darwin-x64": "0.25.9",
|
||||
"@esbuild/freebsd-arm64": "0.25.9",
|
||||
"@esbuild/freebsd-x64": "0.25.9",
|
||||
"@esbuild/linux-arm": "0.25.9",
|
||||
"@esbuild/linux-arm64": "0.25.9",
|
||||
"@esbuild/linux-ia32": "0.25.9",
|
||||
"@esbuild/linux-loong64": "0.25.9",
|
||||
"@esbuild/linux-mips64el": "0.25.9",
|
||||
"@esbuild/linux-ppc64": "0.25.9",
|
||||
"@esbuild/linux-riscv64": "0.25.9",
|
||||
"@esbuild/linux-s390x": "0.25.9",
|
||||
"@esbuild/linux-x64": "0.25.9",
|
||||
"@esbuild/netbsd-arm64": "0.25.9",
|
||||
"@esbuild/netbsd-x64": "0.25.9",
|
||||
"@esbuild/openbsd-arm64": "0.25.9",
|
||||
"@esbuild/openbsd-x64": "0.25.9",
|
||||
"@esbuild/openharmony-arm64": "0.25.9",
|
||||
"@esbuild/sunos-x64": "0.25.9",
|
||||
"@esbuild/win32-arm64": "0.25.9",
|
||||
"@esbuild/win32-ia32": "0.25.9",
|
||||
"@esbuild/win32-x64": "0.25.9"
|
||||
"@esbuild/aix-ppc64": "0.27.2",
|
||||
"@esbuild/android-arm": "0.27.2",
|
||||
"@esbuild/android-arm64": "0.27.2",
|
||||
"@esbuild/android-x64": "0.27.2",
|
||||
"@esbuild/darwin-arm64": "0.27.2",
|
||||
"@esbuild/darwin-x64": "0.27.2",
|
||||
"@esbuild/freebsd-arm64": "0.27.2",
|
||||
"@esbuild/freebsd-x64": "0.27.2",
|
||||
"@esbuild/linux-arm": "0.27.2",
|
||||
"@esbuild/linux-arm64": "0.27.2",
|
||||
"@esbuild/linux-ia32": "0.27.2",
|
||||
"@esbuild/linux-loong64": "0.27.2",
|
||||
"@esbuild/linux-mips64el": "0.27.2",
|
||||
"@esbuild/linux-ppc64": "0.27.2",
|
||||
"@esbuild/linux-riscv64": "0.27.2",
|
||||
"@esbuild/linux-s390x": "0.27.2",
|
||||
"@esbuild/linux-x64": "0.27.2",
|
||||
"@esbuild/netbsd-arm64": "0.27.2",
|
||||
"@esbuild/netbsd-x64": "0.27.2",
|
||||
"@esbuild/openbsd-arm64": "0.27.2",
|
||||
"@esbuild/openbsd-x64": "0.27.2",
|
||||
"@esbuild/openharmony-arm64": "0.27.2",
|
||||
"@esbuild/sunos-x64": "0.27.2",
|
||||
"@esbuild/win32-arm64": "0.27.2",
|
||||
"@esbuild/win32-ia32": "0.27.2",
|
||||
"@esbuild/win32-x64": "0.27.2"
|
||||
}
|
||||
},
|
||||
"node_modules/escalade": {
|
||||
@@ -3204,6 +3201,7 @@
|
||||
"integrity": "sha512-BhHmn2yNOFA9H9JmmIVKJmd288g9hrVRDkdoIgRCRuSySRUHH7r/DI6aAXW9T1WwUuY3DFgrcaqB+deURBLR5g==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"@eslint-community/eslint-utils": "^4.8.0",
|
||||
"@eslint-community/regexpp": "^4.12.1",
|
||||
@@ -3747,9 +3745,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/glob": {
|
||||
"version": "10.4.5",
|
||||
"resolved": "https://registry.npmjs.org/glob/-/glob-10.4.5.tgz",
|
||||
"integrity": "sha512-7Bv8RF0k6xjo7d4A/PxYLbUCfb6c+Vpd2/mB2yRDlew7Jb5hEXiCD9ibfO7wpk8i4sevK6DFny9h7EYbM3/sHg==",
|
||||
"version": "10.5.0",
|
||||
"resolved": "https://registry.npmjs.org/glob/-/glob-10.5.0.tgz",
|
||||
"integrity": "sha512-DfXN8DfhJ7NH3Oe7cFmu3NCu1wKbkReJ8TorzSAFbSKrlNaQSKfIzqYqVY8zlbs2NLBbWpRiU52GX2PbaBVNkg==",
|
||||
"dev": true,
|
||||
"license": "ISC",
|
||||
"dependencies": {
|
||||
@@ -4084,18 +4082,6 @@
|
||||
"@pkgjs/parseargs": "^0.11.0"
|
||||
}
|
||||
},
|
||||
"node_modules/jiti": {
|
||||
"version": "2.5.1",
|
||||
"resolved": "https://registry.npmjs.org/jiti/-/jiti-2.5.1.tgz",
|
||||
"integrity": "sha512-twQoecYPiVA5K/h6SxtORw/Bs3ar+mLUtoPSc7iMXzQzK8d7eJ/R09wmTwAjiamETn1cXYPGfNnu7DMoHgu12w==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"peer": true,
|
||||
"bin": {
|
||||
"jiti": "lib/jiti-cli.mjs"
|
||||
}
|
||||
},
|
||||
"node_modules/js-yaml": {
|
||||
"version": "4.1.1",
|
||||
"resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-4.1.1.tgz",
|
||||
@@ -4115,6 +4101,7 @@
|
||||
"integrity": "sha512-454TI39PeRDW1LgpyLPyURtB4Zx1tklSr6+OFOipsxGUH1WMTvk6C65JQdrj455+DP2uJ1+veBEHTGFKWVLFoA==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"@acemir/cssom": "^0.9.23",
|
||||
"@asamuzakjp/dom-selector": "^6.7.4",
|
||||
@@ -4194,257 +4181,6 @@
|
||||
"node": ">= 0.8.0"
|
||||
}
|
||||
},
|
||||
"node_modules/lightningcss": {
|
||||
"version": "1.30.1",
|
||||
"resolved": "https://registry.npmjs.org/lightningcss/-/lightningcss-1.30.1.tgz",
|
||||
"integrity": "sha512-xi6IyHML+c9+Q3W0S4fCQJOym42pyurFiJUHEcEyHS0CeKzia4yZDEsLlqOFykxOdHpNy0NmvVO31vcSqAxJCg==",
|
||||
"dev": true,
|
||||
"license": "MPL-2.0",
|
||||
"optional": true,
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"detect-libc": "^2.0.3"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">= 12.0.0"
|
||||
},
|
||||
"funding": {
|
||||
"type": "opencollective",
|
||||
"url": "https://opencollective.com/parcel"
|
||||
},
|
||||
"optionalDependencies": {
|
||||
"lightningcss-darwin-arm64": "1.30.1",
|
||||
"lightningcss-darwin-x64": "1.30.1",
|
||||
"lightningcss-freebsd-x64": "1.30.1",
|
||||
"lightningcss-linux-arm-gnueabihf": "1.30.1",
|
||||
"lightningcss-linux-arm64-gnu": "1.30.1",
|
||||
"lightningcss-linux-arm64-musl": "1.30.1",
|
||||
"lightningcss-linux-x64-gnu": "1.30.1",
|
||||
"lightningcss-linux-x64-musl": "1.30.1",
|
||||
"lightningcss-win32-arm64-msvc": "1.30.1",
|
||||
"lightningcss-win32-x64-msvc": "1.30.1"
|
||||
}
|
||||
},
|
||||
"node_modules/lightningcss-darwin-arm64": {
|
||||
"version": "1.30.1",
|
||||
"resolved": "https://registry.npmjs.org/lightningcss-darwin-arm64/-/lightningcss-darwin-arm64-1.30.1.tgz",
|
||||
"integrity": "sha512-c8JK7hyE65X1MHMN+Viq9n11RRC7hgin3HhYKhrMyaXflk5GVplZ60IxyoVtzILeKr+xAJwg6zK6sjTBJ0FKYQ==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MPL-2.0",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"darwin"
|
||||
],
|
||||
"peer": true,
|
||||
"engines": {
|
||||
"node": ">= 12.0.0"
|
||||
},
|
||||
"funding": {
|
||||
"type": "opencollective",
|
||||
"url": "https://opencollective.com/parcel"
|
||||
}
|
||||
},
|
||||
"node_modules/lightningcss-darwin-x64": {
|
||||
"version": "1.30.1",
|
||||
"resolved": "https://registry.npmjs.org/lightningcss-darwin-x64/-/lightningcss-darwin-x64-1.30.1.tgz",
|
||||
"integrity": "sha512-k1EvjakfumAQoTfcXUcHQZhSpLlkAuEkdMBsI/ivWw9hL+7FtilQc0Cy3hrx0AAQrVtQAbMI7YjCgYgvn37PzA==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MPL-2.0",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"darwin"
|
||||
],
|
||||
"peer": true,
|
||||
"engines": {
|
||||
"node": ">= 12.0.0"
|
||||
},
|
||||
"funding": {
|
||||
"type": "opencollective",
|
||||
"url": "https://opencollective.com/parcel"
|
||||
}
|
||||
},
|
||||
"node_modules/lightningcss-freebsd-x64": {
|
||||
"version": "1.30.1",
|
||||
"resolved": "https://registry.npmjs.org/lightningcss-freebsd-x64/-/lightningcss-freebsd-x64-1.30.1.tgz",
|
||||
"integrity": "sha512-kmW6UGCGg2PcyUE59K5r0kWfKPAVy4SltVeut+umLCFoJ53RdCUWxcRDzO1eTaxf/7Q2H7LTquFHPL5R+Gjyig==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MPL-2.0",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"freebsd"
|
||||
],
|
||||
"peer": true,
|
||||
"engines": {
|
||||
"node": ">= 12.0.0"
|
||||
},
|
||||
"funding": {
|
||||
"type": "opencollective",
|
||||
"url": "https://opencollective.com/parcel"
|
||||
}
|
||||
},
|
||||
"node_modules/lightningcss-linux-arm-gnueabihf": {
|
||||
"version": "1.30.1",
|
||||
"resolved": "https://registry.npmjs.org/lightningcss-linux-arm-gnueabihf/-/lightningcss-linux-arm-gnueabihf-1.30.1.tgz",
|
||||
"integrity": "sha512-MjxUShl1v8pit+6D/zSPq9S9dQ2NPFSQwGvxBCYaBYLPlCWuPh9/t1MRS8iUaR8i+a6w7aps+B4N0S1TYP/R+Q==",
|
||||
"cpu": [
|
||||
"arm"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MPL-2.0",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"linux"
|
||||
],
|
||||
"peer": true,
|
||||
"engines": {
|
||||
"node": ">= 12.0.0"
|
||||
},
|
||||
"funding": {
|
||||
"type": "opencollective",
|
||||
"url": "https://opencollective.com/parcel"
|
||||
}
|
||||
},
|
||||
"node_modules/lightningcss-linux-arm64-gnu": {
|
||||
"version": "1.30.1",
|
||||
"resolved": "https://registry.npmjs.org/lightningcss-linux-arm64-gnu/-/lightningcss-linux-arm64-gnu-1.30.1.tgz",
|
||||
"integrity": "sha512-gB72maP8rmrKsnKYy8XUuXi/4OctJiuQjcuqWNlJQ6jZiWqtPvqFziskH3hnajfvKB27ynbVCucKSm2rkQp4Bw==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MPL-2.0",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"linux"
|
||||
],
|
||||
"peer": true,
|
||||
"engines": {
|
||||
"node": ">= 12.0.0"
|
||||
},
|
||||
"funding": {
|
||||
"type": "opencollective",
|
||||
"url": "https://opencollective.com/parcel"
|
||||
}
|
||||
},
|
||||
"node_modules/lightningcss-linux-arm64-musl": {
|
||||
"version": "1.30.1",
|
||||
"resolved": "https://registry.npmjs.org/lightningcss-linux-arm64-musl/-/lightningcss-linux-arm64-musl-1.30.1.tgz",
|
||||
"integrity": "sha512-jmUQVx4331m6LIX+0wUhBbmMX7TCfjF5FoOH6SD1CttzuYlGNVpA7QnrmLxrsub43ClTINfGSYyHe2HWeLl5CQ==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MPL-2.0",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"linux"
|
||||
],
|
||||
"peer": true,
|
||||
"engines": {
|
||||
"node": ">= 12.0.0"
|
||||
},
|
||||
"funding": {
|
||||
"type": "opencollective",
|
||||
"url": "https://opencollective.com/parcel"
|
||||
}
|
||||
},
|
||||
"node_modules/lightningcss-linux-x64-gnu": {
|
||||
"version": "1.30.1",
|
||||
"resolved": "https://registry.npmjs.org/lightningcss-linux-x64-gnu/-/lightningcss-linux-x64-gnu-1.30.1.tgz",
|
||||
"integrity": "sha512-piWx3z4wN8J8z3+O5kO74+yr6ze/dKmPnI7vLqfSqI8bccaTGY5xiSGVIJBDd5K5BHlvVLpUB3S2YCfelyJ1bw==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MPL-2.0",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"linux"
|
||||
],
|
||||
"peer": true,
|
||||
"engines": {
|
||||
"node": ">= 12.0.0"
|
||||
},
|
||||
"funding": {
|
||||
"type": "opencollective",
|
||||
"url": "https://opencollective.com/parcel"
|
||||
}
|
||||
},
|
||||
"node_modules/lightningcss-linux-x64-musl": {
|
||||
"version": "1.30.1",
|
||||
"resolved": "https://registry.npmjs.org/lightningcss-linux-x64-musl/-/lightningcss-linux-x64-musl-1.30.1.tgz",
|
||||
"integrity": "sha512-rRomAK7eIkL+tHY0YPxbc5Dra2gXlI63HL+v1Pdi1a3sC+tJTcFrHX+E86sulgAXeI7rSzDYhPSeHHjqFhqfeQ==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MPL-2.0",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"linux"
|
||||
],
|
||||
"peer": true,
|
||||
"engines": {
|
||||
"node": ">= 12.0.0"
|
||||
},
|
||||
"funding": {
|
||||
"type": "opencollective",
|
||||
"url": "https://opencollective.com/parcel"
|
||||
}
|
||||
},
|
||||
"node_modules/lightningcss-win32-arm64-msvc": {
|
||||
"version": "1.30.1",
|
||||
"resolved": "https://registry.npmjs.org/lightningcss-win32-arm64-msvc/-/lightningcss-win32-arm64-msvc-1.30.1.tgz",
|
||||
"integrity": "sha512-mSL4rqPi4iXq5YVqzSsJgMVFENoa4nGTT/GjO2c0Yl9OuQfPsIfncvLrEW6RbbB24WtZ3xP/2CCmI3tNkNV4oA==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MPL-2.0",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"win32"
|
||||
],
|
||||
"peer": true,
|
||||
"engines": {
|
||||
"node": ">= 12.0.0"
|
||||
},
|
||||
"funding": {
|
||||
"type": "opencollective",
|
||||
"url": "https://opencollective.com/parcel"
|
||||
}
|
||||
},
|
||||
"node_modules/lightningcss-win32-x64-msvc": {
|
||||
"version": "1.30.1",
|
||||
"resolved": "https://registry.npmjs.org/lightningcss-win32-x64-msvc/-/lightningcss-win32-x64-msvc-1.30.1.tgz",
|
||||
"integrity": "sha512-PVqXh48wh4T53F/1CCu8PIPCxLzWyCnn/9T5W1Jpmdy5h9Cwd+0YQS6/LwhHXSafuc61/xg9Lv5OrCby6a++jg==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MPL-2.0",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"win32"
|
||||
],
|
||||
"peer": true,
|
||||
"engines": {
|
||||
"node": ">= 12.0.0"
|
||||
},
|
||||
"funding": {
|
||||
"type": "opencollective",
|
||||
"url": "https://opencollective.com/parcel"
|
||||
}
|
||||
},
|
||||
"node_modules/lilconfig": {
|
||||
"version": "3.1.3",
|
||||
"resolved": "https://registry.npmjs.org/lilconfig/-/lilconfig-3.1.3.tgz",
|
||||
@@ -4930,6 +4666,7 @@
|
||||
"integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"engines": {
|
||||
"node": ">=12"
|
||||
},
|
||||
@@ -4997,6 +4734,7 @@
|
||||
}
|
||||
],
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"nanoid": "^3.3.11",
|
||||
"picocolors": "^1.1.1",
|
||||
@@ -6027,6 +5765,7 @@
|
||||
"integrity": "sha512-p1diW6TqL9L07nNxvRMM7hMMw4c5XOo/1ibL4aAIGmSAt9slTE1Xgw5KWuof2uTOvCg9BY7ZRi+GaF+7sfgPeQ==",
|
||||
"devOptional": true,
|
||||
"license": "Apache-2.0",
|
||||
"peer": true,
|
||||
"bin": {
|
||||
"tsc": "bin/tsc",
|
||||
"tsserver": "bin/tsserver"
|
||||
@@ -6115,13 +5854,14 @@
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/vite": {
|
||||
"version": "7.1.5",
|
||||
"resolved": "https://registry.npmjs.org/vite/-/vite-7.1.5.tgz",
|
||||
"integrity": "sha512-4cKBO9wR75r0BeIWWWId9XK9Lj6La5X846Zw9dFfzMRw38IlTk2iCcUt6hsyiDRcPidc55ZParFYDXi0nXOeLQ==",
|
||||
"version": "7.3.0",
|
||||
"resolved": "https://registry.npmjs.org/vite/-/vite-7.3.0.tgz",
|
||||
"integrity": "sha512-dZwN5L1VlUBewiP6H9s2+B3e3Jg96D0vzN+Ry73sOefebhYr9f94wwkMNN/9ouoU8pV1BqA1d1zGk8928cx0rg==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"esbuild": "^0.25.0",
|
||||
"esbuild": "^0.27.0",
|
||||
"fdir": "^6.5.0",
|
||||
"picomatch": "^4.0.3",
|
||||
"postcss": "^8.5.6",
|
||||
@@ -6195,6 +5935,7 @@
|
||||
"integrity": "sha512-2Fqty3MM9CDwOVet/jaQalYlbcjATZwPYGcqpiYQqgQ/dLC7GuHdISKgTYIVF/kaishKxLzleKWWfbSDklyIKg==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"@vitest/expect": "4.0.10",
|
||||
"@vitest/mocker": "4.0.10",
|
||||
@@ -6279,6 +6020,7 @@
|
||||
"resolved": "https://registry.npmjs.org/vue/-/vue-3.5.21.tgz",
|
||||
"integrity": "sha512-xxf9rum9KtOdwdRkiApWL+9hZEMWE90FHh8yS1+KJAiWYh+iGWV1FquPjoO9VUHQ+VIhsCXNNyZ5Sf4++RVZBA==",
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"@vue/compiler-dom": "3.5.21",
|
||||
"@vue/compiler-sfc": "3.5.21",
|
||||
@@ -6311,7 +6053,6 @@
|
||||
"integrity": "sha512-CydUvFOQKD928UzZhTp4pr2vWz1L+H99t7Pkln2QSPdvmURT0MoC4wUccfCnuEaihNsu9aYYyk+bep8rlfkUXw==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"debug": "^4.4.0",
|
||||
"eslint-scope": "^8.2.0",
|
||||
@@ -6336,7 +6077,6 @@
|
||||
"integrity": "sha512-Uhdk5sfqcee/9H/rCOJikYz67o0a2Tw2hGRPOG2Y1R2dg7brRe1uG0yaNQDHu+TO/uQPF/5eCapvYSmHUjt7JQ==",
|
||||
"dev": true,
|
||||
"license": "Apache-2.0",
|
||||
"peer": true,
|
||||
"engines": {
|
||||
"node": "^18.18.0 || ^20.9.0 || >=21.1.0"
|
||||
},
|
||||
|
||||
@@ -13,6 +13,7 @@ export interface UsersExportData {
|
||||
version: string
|
||||
exported_at: string
|
||||
users: UserExport[]
|
||||
standalone_keys?: StandaloneKeyExport[]
|
||||
}
|
||||
|
||||
export interface UserExport {
|
||||
@@ -42,15 +43,19 @@ export interface UserApiKeyExport {
|
||||
allowed_endpoints?: string[] | null
|
||||
allowed_api_formats?: string[] | null
|
||||
allowed_models?: string[] | null
|
||||
rate_limit?: number
|
||||
rate_limit?: number | null // null = 无限制
|
||||
concurrent_limit?: number | null
|
||||
force_capabilities?: any
|
||||
is_active: boolean
|
||||
expires_at?: string | null
|
||||
auto_delete_on_expiry?: boolean
|
||||
total_requests?: number
|
||||
total_cost_usd?: number
|
||||
}
|
||||
|
||||
// 独立余额 Key 导出结构(与 UserApiKeyExport 相同,但不包含 is_standalone)
|
||||
export type StandaloneKeyExport = Omit<UserApiKeyExport, 'is_standalone'>
|
||||
|
||||
export interface GlobalModelExport {
|
||||
name: string
|
||||
display_name: string
|
||||
@@ -124,6 +129,37 @@ export interface ModelExport {
|
||||
config?: any
|
||||
}
|
||||
|
||||
// 邮件模板接口
|
||||
export interface EmailTemplateInfo {
|
||||
type: string
|
||||
name: string
|
||||
variables: string[]
|
||||
subject: string
|
||||
html: string
|
||||
is_custom: boolean
|
||||
default_subject?: string
|
||||
default_html?: string
|
||||
}
|
||||
|
||||
export interface EmailTemplatesResponse {
|
||||
templates: EmailTemplateInfo[]
|
||||
}
|
||||
|
||||
export interface EmailTemplatePreviewResponse {
|
||||
html: string
|
||||
variables: Record<string, string>
|
||||
}
|
||||
|
||||
export interface EmailTemplateResetResponse {
|
||||
message: string
|
||||
template: {
|
||||
type: string
|
||||
name: string
|
||||
subject: string
|
||||
html: string
|
||||
}
|
||||
}
|
||||
|
||||
// Provider 模型查询响应
|
||||
export interface ProviderModelsQueryResponse {
|
||||
success: boolean
|
||||
@@ -158,6 +194,7 @@ export interface UsersImportResponse {
|
||||
stats: {
|
||||
users: { created: number; updated: number; skipped: number }
|
||||
api_keys: { created: number; skipped: number }
|
||||
standalone_keys?: { created: number; skipped: number }
|
||||
errors: string[]
|
||||
}
|
||||
}
|
||||
@@ -189,7 +226,7 @@ export interface AdminApiKey {
|
||||
total_requests?: number
|
||||
total_tokens?: number
|
||||
total_cost_usd?: number
|
||||
rate_limit?: number
|
||||
rate_limit?: number | null // null = 无限制
|
||||
allowed_providers?: string[] | null // 允许的提供商列表
|
||||
allowed_api_formats?: string[] | null // 允许的 API 格式列表
|
||||
allowed_models?: string[] | null // 允许的模型列表
|
||||
@@ -205,8 +242,8 @@ export interface CreateStandaloneApiKeyRequest {
|
||||
allowed_providers?: string[] | null
|
||||
allowed_api_formats?: string[] | null
|
||||
allowed_models?: string[] | null
|
||||
rate_limit?: number
|
||||
expire_days?: number | null // null = 永不过期
|
||||
rate_limit?: number | null // null = 无限制
|
||||
expires_at?: string | null // ISO 日期字符串,如 "2025-12-31",null = 永不过期
|
||||
initial_balance_usd: number // 初始余额,必须设置
|
||||
auto_delete_on_expiry?: boolean // 过期后是否自动删除
|
||||
}
|
||||
@@ -386,5 +423,69 @@ export const adminApi = {
|
||||
{ provider_id: providerId, api_key_id: apiKeyId }
|
||||
)
|
||||
return response.data
|
||||
},
|
||||
|
||||
// 测试 SMTP 连接,支持传入未保存的配置
|
||||
async testSmtpConnection(config: Record<string, any> = {}): Promise<{ success: boolean; message: string }> {
|
||||
const response = await apiClient.post<{ success: boolean; message: string }>(
|
||||
'/api/admin/system/smtp/test',
|
||||
config
|
||||
)
|
||||
return response.data
|
||||
},
|
||||
|
||||
// 邮件模板相关
|
||||
// 获取所有邮件模板
|
||||
async getEmailTemplates(): Promise<EmailTemplatesResponse> {
|
||||
const response = await apiClient.get<EmailTemplatesResponse>('/api/admin/system/email/templates')
|
||||
return response.data
|
||||
},
|
||||
|
||||
// 获取指定类型的邮件模板
|
||||
async getEmailTemplate(templateType: string): Promise<EmailTemplateInfo> {
|
||||
const response = await apiClient.get<EmailTemplateInfo>(
|
||||
`/api/admin/system/email/templates/${templateType}`
|
||||
)
|
||||
return response.data
|
||||
},
|
||||
|
||||
// 更新邮件模板
|
||||
async updateEmailTemplate(
|
||||
templateType: string,
|
||||
data: { subject?: string; html?: string }
|
||||
): Promise<{ message: string }> {
|
||||
const response = await apiClient.put<{ message: string }>(
|
||||
`/api/admin/system/email/templates/${templateType}`,
|
||||
data
|
||||
)
|
||||
return response.data
|
||||
},
|
||||
|
||||
// 预览邮件模板
|
||||
async previewEmailTemplate(
|
||||
templateType: string,
|
||||
data?: { html?: string } & Record<string, string>
|
||||
): Promise<EmailTemplatePreviewResponse> {
|
||||
const response = await apiClient.post<EmailTemplatePreviewResponse>(
|
||||
`/api/admin/system/email/templates/${templateType}/preview`,
|
||||
data || {}
|
||||
)
|
||||
return response.data
|
||||
},
|
||||
|
||||
// 重置邮件模板为默认值
|
||||
async resetEmailTemplate(templateType: string): Promise<EmailTemplateResetResponse> {
|
||||
const response = await apiClient.post<EmailTemplateResetResponse>(
|
||||
`/api/admin/system/email/templates/${templateType}/reset`
|
||||
)
|
||||
return response.data
|
||||
},
|
||||
|
||||
// 获取系统版本信息
|
||||
async getSystemVersion(): Promise<{ version: string }> {
|
||||
const response = await apiClient.get<{ version: string }>(
|
||||
'/api/admin/system/version'
|
||||
)
|
||||
return response.data
|
||||
}
|
||||
}
|
||||
|
||||
@@ -31,6 +31,56 @@ export interface UserStats {
|
||||
[key: string]: unknown // 允许扩展其他统计数据
|
||||
}
|
||||
|
||||
export interface SendVerificationCodeRequest {
|
||||
email: string
|
||||
}
|
||||
|
||||
export interface SendVerificationCodeResponse {
|
||||
message: string
|
||||
success: boolean
|
||||
expire_minutes?: number
|
||||
}
|
||||
|
||||
export interface VerifyEmailRequest {
|
||||
email: string
|
||||
code: string
|
||||
}
|
||||
|
||||
export interface VerifyEmailResponse {
|
||||
message: string
|
||||
success: boolean
|
||||
}
|
||||
|
||||
export interface VerificationStatusRequest {
|
||||
email: string
|
||||
}
|
||||
|
||||
export interface VerificationStatusResponse {
|
||||
email: string
|
||||
has_pending_code: boolean
|
||||
is_verified: boolean
|
||||
cooldown_remaining: number | null
|
||||
code_expires_in: number | null
|
||||
}
|
||||
|
||||
export interface RegisterRequest {
|
||||
email: string
|
||||
username: string
|
||||
password: string
|
||||
}
|
||||
|
||||
export interface RegisterResponse {
|
||||
user_id: string
|
||||
email: string
|
||||
username: string
|
||||
message: string
|
||||
}
|
||||
|
||||
export interface RegistrationSettingsResponse {
|
||||
enable_registration: boolean
|
||||
require_email_verification: boolean
|
||||
}
|
||||
|
||||
export interface User {
|
||||
id: string // UUID
|
||||
username: string
|
||||
@@ -87,5 +137,41 @@ export const authApi = {
|
||||
localStorage.setItem('refresh_token', response.data.refresh_token)
|
||||
}
|
||||
return response.data
|
||||
},
|
||||
|
||||
async sendVerificationCode(email: string): Promise<SendVerificationCodeResponse> {
|
||||
const response = await apiClient.post<SendVerificationCodeResponse>(
|
||||
'/api/auth/send-verification-code',
|
||||
{ email }
|
||||
)
|
||||
return response.data
|
||||
},
|
||||
|
||||
async verifyEmail(email: string, code: string): Promise<VerifyEmailResponse> {
|
||||
const response = await apiClient.post<VerifyEmailResponse>(
|
||||
'/api/auth/verify-email',
|
||||
{ email, code }
|
||||
)
|
||||
return response.data
|
||||
},
|
||||
|
||||
async register(data: RegisterRequest): Promise<RegisterResponse> {
|
||||
const response = await apiClient.post<RegisterResponse>('/api/auth/register', data)
|
||||
return response.data
|
||||
},
|
||||
|
||||
async getRegistrationSettings(): Promise<RegistrationSettingsResponse> {
|
||||
const response = await apiClient.get<RegistrationSettingsResponse>(
|
||||
'/api/auth/registration-settings'
|
||||
)
|
||||
return response.data
|
||||
},
|
||||
|
||||
async getVerificationStatus(email: string): Promise<VerificationStatusResponse> {
|
||||
const response = await apiClient.post<VerificationStatusResponse>(
|
||||
'/api/auth/verification-status',
|
||||
{ email }
|
||||
)
|
||||
return response.data
|
||||
}
|
||||
}
|
||||
|
||||
@@ -87,6 +87,8 @@ export interface DashboardStatsResponse {
|
||||
cache_stats?: CacheStats
|
||||
users?: UserStats
|
||||
token_breakdown?: TokenBreakdown
|
||||
// 普通用户专用字段
|
||||
monthly_cost?: number
|
||||
}
|
||||
|
||||
export interface RecentRequestsResponse {
|
||||
|
||||
@@ -4,7 +4,8 @@ import type {
|
||||
GlobalModelUpdate,
|
||||
GlobalModelResponse,
|
||||
GlobalModelWithStats,
|
||||
GlobalModelListResponse
|
||||
GlobalModelListResponse,
|
||||
ModelCatalogProviderDetail,
|
||||
} from './types'
|
||||
|
||||
/**
|
||||
@@ -83,3 +84,16 @@ export async function batchAssignToProviders(
|
||||
)
|
||||
return response.data
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取 GlobalModel 的所有关联提供商(包括非活跃的)
|
||||
*/
|
||||
export async function getGlobalModelProviders(globalModelId: string): Promise<{
|
||||
providers: ModelCatalogProviderDetail[]
|
||||
total: number
|
||||
}> {
|
||||
const response = await client.get(
|
||||
`/api/admin/models/global/${globalModelId}/providers`
|
||||
)
|
||||
return response.data
|
||||
}
|
||||
|
||||
@@ -110,6 +110,14 @@ export async function updateEndpointKey(
|
||||
return response.data
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取完整的 API Key(用于查看和复制)
|
||||
*/
|
||||
export async function revealEndpointKey(keyId: string): Promise<{ api_key: string }> {
|
||||
const response = await client.get(`/api/admin/endpoints/keys/${keyId}/reveal`)
|
||||
return response.data
|
||||
}
|
||||
|
||||
/**
|
||||
* 删除 Endpoint Key
|
||||
*/
|
||||
|
||||
@@ -58,3 +58,38 @@ export async function deleteProvider(providerId: string): Promise<{ message: str
|
||||
return response.data
|
||||
}
|
||||
|
||||
/**
|
||||
* 测试模型连接性
|
||||
*/
|
||||
export interface TestModelRequest {
|
||||
provider_id: string
|
||||
model_name: string
|
||||
api_key_id?: string
|
||||
message?: string
|
||||
api_format?: string
|
||||
}
|
||||
|
||||
export interface TestModelResponse {
|
||||
success: boolean
|
||||
error?: string
|
||||
data?: {
|
||||
response?: {
|
||||
status_code?: number
|
||||
error?: string | { message?: string }
|
||||
choices?: Array<{ message?: { content?: string } }>
|
||||
}
|
||||
content_preview?: string
|
||||
}
|
||||
provider?: {
|
||||
id: string
|
||||
name: string
|
||||
display_name: string
|
||||
}
|
||||
model?: string
|
||||
}
|
||||
|
||||
export async function testModel(data: TestModelRequest): Promise<TestModelResponse> {
|
||||
const response = await client.post('/api/admin/provider-query/test-model', data)
|
||||
return response.data
|
||||
}
|
||||
|
||||
|
||||
@@ -20,4 +20,5 @@ export {
|
||||
updateGlobalModel,
|
||||
deleteGlobalModel,
|
||||
batchAssignToProviders,
|
||||
getGlobalModelProviders,
|
||||
} from './endpoints/global-models'
|
||||
|
||||
@@ -62,6 +62,11 @@ export interface UsageRecordDetail {
|
||||
cache_creation_price_per_1m?: number
|
||||
cache_read_price_per_1m?: number
|
||||
price_per_request?: number // 按次计费价格
|
||||
api_key?: {
|
||||
id: string
|
||||
name: string
|
||||
display: string
|
||||
}
|
||||
}
|
||||
|
||||
// 模型统计接口
|
||||
@@ -75,6 +80,16 @@ export interface ModelSummary {
|
||||
actual_total_cost_usd?: number // 倍率消耗(仅管理员可见)
|
||||
}
|
||||
|
||||
// 提供商统计接口
|
||||
export interface ProviderSummary {
|
||||
provider: string
|
||||
requests: number
|
||||
total_tokens: number
|
||||
total_cost_usd: number
|
||||
success_rate: number | null
|
||||
avg_response_time_ms: number | null
|
||||
}
|
||||
|
||||
// 使用统计响应接口
|
||||
export interface UsageResponse {
|
||||
total_requests: number
|
||||
@@ -87,6 +102,13 @@ export interface UsageResponse {
|
||||
quota_usd: number | null
|
||||
used_usd: number
|
||||
summary_by_model: ModelSummary[]
|
||||
summary_by_provider?: ProviderSummary[]
|
||||
pagination?: {
|
||||
total: number
|
||||
limit: number
|
||||
offset: number
|
||||
has_more: boolean
|
||||
}
|
||||
records: UsageRecordDetail[]
|
||||
activity_heatmap?: ActivityHeatmap | null
|
||||
}
|
||||
@@ -175,6 +197,9 @@ export const meApi = {
|
||||
async getUsage(params?: {
|
||||
start_date?: string
|
||||
end_date?: string
|
||||
search?: string // 通用搜索:密钥名、模型名
|
||||
limit?: number
|
||||
offset?: number
|
||||
}): Promise<UsageResponse> {
|
||||
const response = await apiClient.get<UsageResponse>('/api/users/me/usage', { params })
|
||||
return response.data
|
||||
@@ -184,11 +209,12 @@ export const meApi = {
|
||||
async getActiveRequests(ids?: string): Promise<{
|
||||
requests: Array<{
|
||||
id: string
|
||||
status: string
|
||||
status: 'pending' | 'streaming' | 'completed' | 'failed'
|
||||
input_tokens: number
|
||||
output_tokens: number
|
||||
cost: number
|
||||
response_time_ms: number | null
|
||||
first_byte_time_ms: number | null
|
||||
}>
|
||||
}> {
|
||||
const params = ids ? { ids } : {}
|
||||
@@ -267,5 +293,14 @@ export const meApi = {
|
||||
}> {
|
||||
const response = await apiClient.get('/api/users/me/usage/interval-timeline', { params })
|
||||
return response.data
|
||||
},
|
||||
|
||||
/**
|
||||
* 获取活跃度热力图数据(用户)
|
||||
* 后端已缓存5分钟
|
||||
*/
|
||||
async getActivityHeatmap(): Promise<ActivityHeatmap> {
|
||||
const response = await apiClient.get<ActivityHeatmap>('/api/users/me/usage/heatmap')
|
||||
return response.data
|
||||
}
|
||||
}
|
||||
|
||||
@@ -192,10 +192,17 @@ export async function getModelsDevList(officialOnly: boolean = true): Promise<Mo
|
||||
}
|
||||
}
|
||||
|
||||
// 按 provider 名称和模型名称排序
|
||||
// 按 provider 名称排序,provider 中的模型按 release_date 从近到远排序
|
||||
items.sort((a, b) => {
|
||||
const providerCompare = a.providerName.localeCompare(b.providerName)
|
||||
if (providerCompare !== 0) return providerCompare
|
||||
|
||||
// 模型按 release_date 从近到远排序(没有日期的排到最后)
|
||||
const aDate = a.releaseDate ? new Date(a.releaseDate).getTime() : 0
|
||||
const bDate = b.releaseDate ? new Date(b.releaseDate).getTime() : 0
|
||||
if (aDate !== bDate) return bDate - aDate // 降序:新的在前
|
||||
|
||||
// 日期相同或都没有日期时,按模型名称排序
|
||||
return a.modelName.localeCompare(b.modelName)
|
||||
})
|
||||
|
||||
|
||||
@@ -164,6 +164,7 @@ export const usageApi = {
|
||||
async getAllUsageRecords(params?: {
|
||||
start_date?: string
|
||||
end_date?: string
|
||||
search?: string // 通用搜索:用户名、密钥名、模型名、提供商名
|
||||
user_id?: string // UUID
|
||||
username?: string
|
||||
model?: string
|
||||
@@ -193,10 +194,22 @@ export const usageApi = {
|
||||
output_tokens: number
|
||||
cost: number
|
||||
response_time_ms: number | null
|
||||
first_byte_time_ms: number | null
|
||||
provider?: string | null
|
||||
api_key_name?: string | null
|
||||
}>
|
||||
}> {
|
||||
const params = ids?.length ? { ids: ids.join(',') } : {}
|
||||
const response = await apiClient.get('/api/admin/usage/active', { params })
|
||||
return response.data
|
||||
},
|
||||
|
||||
/**
|
||||
* 获取活跃度热力图数据(管理员)
|
||||
* 后端已缓存5分钟
|
||||
*/
|
||||
async getActivityHeatmap(): Promise<ActivityHeatmap> {
|
||||
const response = await apiClient.get<ActivityHeatmap>('/api/admin/usage/heatmap')
|
||||
return response.data
|
||||
}
|
||||
}
|
||||
|
||||
192
frontend/src/components/VerificationCodeInput.vue
Normal file
192
frontend/src/components/VerificationCodeInput.vue
Normal file
@@ -0,0 +1,192 @@
|
||||
<template>
|
||||
<div class="verification-code-input">
|
||||
<div class="code-inputs flex gap-2">
|
||||
<input
|
||||
v-for="(digit, index) in digits"
|
||||
:key="index"
|
||||
:ref="(el) => (inputRefs[index] = el as HTMLInputElement)"
|
||||
v-model="digits[index]"
|
||||
type="text"
|
||||
inputmode="numeric"
|
||||
maxlength="1"
|
||||
class="code-digit"
|
||||
:class="{ error: hasError }"
|
||||
@input="handleInput(index, $event)"
|
||||
@keydown="handleKeyDown(index, $event)"
|
||||
@paste="handlePaste"
|
||||
>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, watch } from 'vue'
|
||||
|
||||
interface Props {
|
||||
modelValue?: string
|
||||
length?: number
|
||||
hasError?: boolean
|
||||
}
|
||||
|
||||
interface Emits {
|
||||
(e: 'update:modelValue', value: string): void
|
||||
(e: 'complete', value: string): void
|
||||
}
|
||||
|
||||
const props = withDefaults(defineProps<Props>(), {
|
||||
modelValue: '',
|
||||
length: 6,
|
||||
hasError: false
|
||||
})
|
||||
|
||||
const emit = defineEmits<Emits>()
|
||||
|
||||
const digits = ref<string[]>(Array(props.length).fill(''))
|
||||
const inputRefs = ref<HTMLInputElement[]>([])
|
||||
|
||||
// Watch modelValue changes from parent
|
||||
watch(
|
||||
() => props.modelValue,
|
||||
(newValue) => {
|
||||
if (newValue.length <= props.length) {
|
||||
digits.value = newValue.split('').concat(Array(props.length - newValue.length).fill(''))
|
||||
}
|
||||
},
|
||||
{ immediate: true }
|
||||
)
|
||||
|
||||
const updateValue = () => {
|
||||
const value = digits.value.join('')
|
||||
emit('update:modelValue', value)
|
||||
|
||||
// Emit complete event when all digits are filled
|
||||
if (value.length === props.length && /^\d+$/.test(value)) {
|
||||
emit('complete', value)
|
||||
}
|
||||
}
|
||||
|
||||
const handleInput = (index: number, event: Event) => {
|
||||
const input = event.target as HTMLInputElement
|
||||
const value = input.value
|
||||
|
||||
// Only allow digits
|
||||
if (!/^\d*$/.test(value)) {
|
||||
input.value = digits.value[index]
|
||||
return
|
||||
}
|
||||
|
||||
digits.value[index] = value
|
||||
|
||||
// Auto-focus next input
|
||||
if (value && index < props.length - 1) {
|
||||
inputRefs.value[index + 1]?.focus()
|
||||
}
|
||||
|
||||
updateValue()
|
||||
}
|
||||
|
||||
const handleKeyDown = (index: number, event: KeyboardEvent) => {
|
||||
// Handle backspace
|
||||
if (event.key === 'Backspace') {
|
||||
if (!digits.value[index] && index > 0) {
|
||||
// If current input is empty, move to previous and clear it
|
||||
inputRefs.value[index - 1]?.focus()
|
||||
digits.value[index - 1] = ''
|
||||
updateValue()
|
||||
} else {
|
||||
// Clear current input
|
||||
digits.value[index] = ''
|
||||
updateValue()
|
||||
}
|
||||
}
|
||||
// Handle arrow keys
|
||||
else if (event.key === 'ArrowLeft' && index > 0) {
|
||||
inputRefs.value[index - 1]?.focus()
|
||||
} else if (event.key === 'ArrowRight' && index < props.length - 1) {
|
||||
inputRefs.value[index + 1]?.focus()
|
||||
}
|
||||
}
|
||||
|
||||
const handlePaste = (event: ClipboardEvent) => {
|
||||
event.preventDefault()
|
||||
const pastedData = event.clipboardData?.getData('text') || ''
|
||||
const cleanedData = pastedData.replace(/\D/g, '').slice(0, props.length)
|
||||
|
||||
if (cleanedData) {
|
||||
digits.value = cleanedData.split('').concat(Array(props.length - cleanedData.length).fill(''))
|
||||
updateValue()
|
||||
|
||||
// Focus the next empty input or the last input
|
||||
const nextEmptyIndex = digits.value.findIndex((d) => !d)
|
||||
const focusIndex = nextEmptyIndex >= 0 ? nextEmptyIndex : props.length - 1
|
||||
inputRefs.value[focusIndex]?.focus()
|
||||
}
|
||||
}
|
||||
|
||||
// Expose method to clear inputs
|
||||
const clear = () => {
|
||||
digits.value = Array(props.length).fill('')
|
||||
inputRefs.value[0]?.focus()
|
||||
updateValue()
|
||||
}
|
||||
|
||||
// Expose method to focus first input
|
||||
const focus = () => {
|
||||
inputRefs.value[0]?.focus()
|
||||
}
|
||||
|
||||
defineExpose({
|
||||
clear,
|
||||
focus
|
||||
})
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.code-inputs {
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.code-digit {
|
||||
width: 3rem;
|
||||
height: 3.5rem;
|
||||
text-align: center;
|
||||
font-size: 1.5rem;
|
||||
font-weight: 600;
|
||||
border: 2px solid hsl(var(--border));
|
||||
border-radius: var(--radius);
|
||||
background-color: hsl(var(--background));
|
||||
color: hsl(var(--foreground));
|
||||
transition: all 0.2s;
|
||||
}
|
||||
|
||||
.code-digit:focus {
|
||||
outline: none;
|
||||
border-color: hsl(var(--primary));
|
||||
box-shadow: 0 0 0 3px hsl(var(--primary) / 0.1);
|
||||
}
|
||||
|
||||
.code-digit:hover:not(:focus) {
|
||||
border-color: hsl(var(--primary) / 0.5);
|
||||
}
|
||||
|
||||
.code-digit.error {
|
||||
border-color: hsl(var(--destructive));
|
||||
}
|
||||
|
||||
.code-digit.error:focus {
|
||||
box-shadow: 0 0 0 3px hsl(var(--destructive) / 0.1);
|
||||
}
|
||||
|
||||
/* Prevent spinner buttons on number inputs */
|
||||
.code-digit::-webkit-outer-spin-button,
|
||||
.code-digit::-webkit-inner-spin-button {
|
||||
-webkit-appearance: none;
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.code-digit[type='number'] {
|
||||
-moz-appearance: textfield;
|
||||
}
|
||||
</style>
|
||||
117
frontend/src/components/common/ModelMultiSelect.vue
Normal file
117
frontend/src/components/common/ModelMultiSelect.vue
Normal file
@@ -0,0 +1,117 @@
|
||||
<template>
|
||||
<div class="space-y-2">
|
||||
<Label class="text-sm font-medium">允许的模型</Label>
|
||||
<div class="relative">
|
||||
<button
|
||||
type="button"
|
||||
class="w-full h-10 px-3 border rounded-lg bg-background text-left flex items-center justify-between hover:bg-muted/50 transition-colors"
|
||||
@click="isOpen = !isOpen"
|
||||
>
|
||||
<span :class="modelValue.length ? 'text-foreground' : 'text-muted-foreground'">
|
||||
{{ modelValue.length ? `已选择 ${modelValue.length} 个` : '全部可用' }}
|
||||
<span
|
||||
v-if="invalidModels.length"
|
||||
class="text-destructive"
|
||||
>({{ invalidModels.length }} 个已失效)</span>
|
||||
</span>
|
||||
<ChevronDown
|
||||
class="h-4 w-4 text-muted-foreground transition-transform"
|
||||
:class="isOpen ? 'rotate-180' : ''"
|
||||
/>
|
||||
</button>
|
||||
<div
|
||||
v-if="isOpen"
|
||||
class="fixed inset-0 z-[80]"
|
||||
@click.stop="isOpen = false"
|
||||
/>
|
||||
<div
|
||||
v-if="isOpen"
|
||||
class="absolute z-[90] w-full mt-1 bg-popover border rounded-lg shadow-lg max-h-48 overflow-y-auto"
|
||||
>
|
||||
<!-- 失效模型(置顶显示,只能取消选择) -->
|
||||
<div
|
||||
v-for="modelName in invalidModels"
|
||||
:key="modelName"
|
||||
class="flex items-center gap-2 px-3 py-2 hover:bg-muted/50 cursor-pointer bg-destructive/5"
|
||||
@click="removeModel(modelName)"
|
||||
>
|
||||
<input
|
||||
type="checkbox"
|
||||
:checked="true"
|
||||
class="h-4 w-4 rounded border-gray-300 cursor-pointer"
|
||||
@click.stop
|
||||
@change="removeModel(modelName)"
|
||||
>
|
||||
<span class="text-sm text-destructive">{{ modelName }}</span>
|
||||
<span class="text-xs text-destructive/70">(已失效)</span>
|
||||
</div>
|
||||
<!-- 有效模型 -->
|
||||
<div
|
||||
v-for="model in models"
|
||||
:key="model.name"
|
||||
class="flex items-center gap-2 px-3 py-2 hover:bg-muted/50 cursor-pointer"
|
||||
@click="toggleModel(model.name)"
|
||||
>
|
||||
<input
|
||||
type="checkbox"
|
||||
:checked="modelValue.includes(model.name)"
|
||||
class="h-4 w-4 rounded border-gray-300 cursor-pointer"
|
||||
@click.stop
|
||||
@change="toggleModel(model.name)"
|
||||
>
|
||||
<span class="text-sm">{{ model.name }}</span>
|
||||
</div>
|
||||
<div
|
||||
v-if="models.length === 0 && invalidModels.length === 0"
|
||||
class="px-3 py-2 text-sm text-muted-foreground"
|
||||
>
|
||||
暂无可用模型
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, computed } from 'vue'
|
||||
import { Label } from '@/components/ui'
|
||||
import { ChevronDown } from 'lucide-vue-next'
|
||||
import { useInvalidModels } from '@/composables/useInvalidModels'
|
||||
|
||||
export interface ModelWithName {
|
||||
name: string
|
||||
}
|
||||
|
||||
const props = defineProps<{
|
||||
modelValue: string[]
|
||||
models: ModelWithName[]
|
||||
}>()
|
||||
|
||||
const emit = defineEmits<{
|
||||
'update:modelValue': [value: string[]]
|
||||
}>()
|
||||
|
||||
const isOpen = ref(false)
|
||||
|
||||
// 检测失效模型
|
||||
const { invalidModels } = useInvalidModels(
|
||||
computed(() => props.modelValue),
|
||||
computed(() => props.models)
|
||||
)
|
||||
|
||||
function toggleModel(name: string) {
|
||||
const newValue = [...props.modelValue]
|
||||
const index = newValue.indexOf(name)
|
||||
if (index === -1) {
|
||||
newValue.push(name)
|
||||
} else {
|
||||
newValue.splice(index, 1)
|
||||
}
|
||||
emit('update:modelValue', newValue)
|
||||
}
|
||||
|
||||
function removeModel(name: string) {
|
||||
const newValue = props.modelValue.filter(m => m !== name)
|
||||
emit('update:modelValue', newValue)
|
||||
}
|
||||
</script>
|
||||
@@ -7,3 +7,6 @@
|
||||
export { default as EmptyState } from './EmptyState.vue'
|
||||
export { default as AlertDialog } from './AlertDialog.vue'
|
||||
export { default as LoadingState } from './LoadingState.vue'
|
||||
|
||||
// 表单组件
|
||||
export { default as ModelMultiSelect } from './ModelMultiSelect.vue'
|
||||
|
||||
@@ -71,8 +71,8 @@
|
||||
</div>
|
||||
</slot>
|
||||
|
||||
<!-- 内容区域:统一添加 padding -->
|
||||
<div class="px-6 py-3">
|
||||
<!-- 内容区域:可选添加 padding -->
|
||||
<div :class="noPadding ? '' : 'px-6 py-3'">
|
||||
<slot />
|
||||
</div>
|
||||
|
||||
@@ -105,6 +105,7 @@ const props = defineProps<{
|
||||
icon?: Component // Lucide icon component
|
||||
iconClass?: string // Custom icon color class
|
||||
zIndex?: number // Custom z-index for nested dialogs (default: 60)
|
||||
noPadding?: boolean // Disable default content padding
|
||||
}>()
|
||||
|
||||
// Emits 定义
|
||||
@@ -163,7 +164,9 @@ const contentZIndex = computed(() => (props.zIndex || 60) + 10)
|
||||
useEscapeKey(() => {
|
||||
if (isOpen.value) {
|
||||
handleClose()
|
||||
return true // 阻止其他监听器(如父级抽屉的 ESC 监听器)
|
||||
}
|
||||
return false
|
||||
}, {
|
||||
disableOnInput: true,
|
||||
once: false
|
||||
|
||||
@@ -3,6 +3,9 @@
|
||||
:class="inputClass"
|
||||
:value="modelValue"
|
||||
:autocomplete="autocompleteAttr"
|
||||
:data-lpignore="disableAutofill ? 'true' : undefined"
|
||||
:data-1p-ignore="disableAutofill ? 'true' : undefined"
|
||||
:data-form-type="disableAutofill ? 'other' : undefined"
|
||||
v-bind="$attrs"
|
||||
@input="handleInput"
|
||||
>
|
||||
@@ -16,6 +19,7 @@ interface Props {
|
||||
modelValue?: string | number
|
||||
class?: string
|
||||
autocomplete?: string
|
||||
disableAutofill?: boolean
|
||||
}
|
||||
|
||||
const props = defineProps<Props>()
|
||||
@@ -23,7 +27,12 @@ const emit = defineEmits<{
|
||||
'update:modelValue': [value: string]
|
||||
}>()
|
||||
|
||||
const autocompleteAttr = computed(() => props.autocomplete ?? 'off')
|
||||
const autocompleteAttr = computed(() => {
|
||||
if (props.disableAutofill) {
|
||||
return 'one-time-code'
|
||||
}
|
||||
return props.autocomplete ?? 'off'
|
||||
})
|
||||
|
||||
const inputClass = computed(() =>
|
||||
cn(
|
||||
|
||||
@@ -4,11 +4,11 @@ import { log } from '@/utils/logger'
|
||||
export function useClipboard() {
|
||||
const { success, error: showError } = useToast()
|
||||
|
||||
async function copyToClipboard(text: string): Promise<boolean> {
|
||||
async function copyToClipboard(text: string, showToast = true): Promise<boolean> {
|
||||
try {
|
||||
if (navigator.clipboard && window.isSecureContext) {
|
||||
await navigator.clipboard.writeText(text)
|
||||
success('已复制到剪贴板')
|
||||
if (showToast) success('已复制到剪贴板')
|
||||
return true
|
||||
}
|
||||
|
||||
@@ -25,17 +25,17 @@ export function useClipboard() {
|
||||
try {
|
||||
const successful = document.execCommand('copy')
|
||||
if (successful) {
|
||||
success('已复制到剪贴板')
|
||||
if (showToast) success('已复制到剪贴板')
|
||||
return true
|
||||
}
|
||||
showError('复制失败,请手动复制')
|
||||
if (showToast) showError('复制失败,请手动复制')
|
||||
return false
|
||||
} finally {
|
||||
document.body.removeChild(textArea)
|
||||
}
|
||||
} catch (err) {
|
||||
log.error('复制失败:', err)
|
||||
showError('复制失败,请手动选择文本进行复制')
|
||||
if (showToast) showError('复制失败,请手动选择文本进行复制')
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
@@ -47,11 +47,11 @@ export function useConfirm() {
|
||||
/**
|
||||
* 便捷方法:危险操作确认(红色主题)
|
||||
*/
|
||||
const confirmDanger = (message: string, title?: string): Promise<boolean> => {
|
||||
const confirmDanger = (message: string, title?: string, confirmText?: string): Promise<boolean> => {
|
||||
return confirm({
|
||||
message,
|
||||
title: title || '危险操作',
|
||||
confirmText: '删除',
|
||||
confirmText: confirmText || '删除',
|
||||
variant: 'danger'
|
||||
})
|
||||
}
|
||||
|
||||
@@ -4,11 +4,11 @@ import { onMounted, onUnmounted, ref } from 'vue'
|
||||
* ESC 键监听 Composable(简化版本,直接使用独立监听器)
|
||||
* 用于按 ESC 键关闭弹窗或其他可关闭的组件
|
||||
*
|
||||
* @param callback - 按 ESC 键时执行的回调函数
|
||||
* @param callback - 按 ESC 键时执行的回调函数,返回 true 表示已处理事件,阻止其他监听器执行
|
||||
* @param options - 配置选项
|
||||
*/
|
||||
export function useEscapeKey(
|
||||
callback: () => void,
|
||||
callback: () => void | boolean,
|
||||
options: {
|
||||
/** 是否在输入框获得焦点时禁用 ESC 键,默认 true */
|
||||
disableOnInput?: boolean
|
||||
@@ -42,8 +42,11 @@ export function useEscapeKey(
|
||||
if (isInputElement) return
|
||||
}
|
||||
|
||||
// 执行回调
|
||||
callback()
|
||||
// 执行回调,如果返回 true 则阻止其他监听器
|
||||
const handled = callback()
|
||||
if (handled === true) {
|
||||
event.stopImmediatePropagation()
|
||||
}
|
||||
|
||||
// 移除当前元素的焦点,避免残留样式
|
||||
if (document.activeElement instanceof HTMLElement) {
|
||||
|
||||
34
frontend/src/composables/useInvalidModels.ts
Normal file
34
frontend/src/composables/useInvalidModels.ts
Normal file
@@ -0,0 +1,34 @@
|
||||
import { computed, type Ref, type ComputedRef } from 'vue'
|
||||
|
||||
/**
|
||||
* 检测失效模型的 composable
|
||||
*
|
||||
* 用于检测 allowed_models 中已不存在于 globalModels 的模型名称,
|
||||
* 这些模型可能已被删除但引用未清理。
|
||||
*
|
||||
* @example
|
||||
* ```typescript
|
||||
* const { invalidModels } = useInvalidModels(
|
||||
* computed(() => form.value.allowed_models),
|
||||
* globalModels
|
||||
* )
|
||||
* ```
|
||||
*/
|
||||
export interface ModelWithName {
|
||||
name: string
|
||||
}
|
||||
|
||||
export function useInvalidModels<T extends ModelWithName>(
|
||||
allowedModels: Ref<string[]> | ComputedRef<string[]>,
|
||||
globalModels: Ref<T[]>
|
||||
): { invalidModels: ComputedRef<string[]> } {
|
||||
const validModelNames = computed(() =>
|
||||
new Set(globalModels.value.map(m => m.name))
|
||||
)
|
||||
|
||||
const invalidModels = computed(() =>
|
||||
allowedModels.value.filter(name => !validModelNames.value.has(name))
|
||||
)
|
||||
|
||||
return { invalidModels }
|
||||
}
|
||||
@@ -79,45 +79,45 @@
|
||||
|
||||
<div class="space-y-2">
|
||||
<Label
|
||||
for="form-expire-days"
|
||||
for="form-expires-at"
|
||||
class="text-sm font-medium"
|
||||
>有效期设置</Label>
|
||||
<div class="flex items-center gap-2">
|
||||
<div class="relative flex-1">
|
||||
<Input
|
||||
id="form-expire-days"
|
||||
:model-value="form.expire_days ?? ''"
|
||||
type="number"
|
||||
min="1"
|
||||
max="3650"
|
||||
placeholder="天数"
|
||||
:class="form.never_expire ? 'flex-1 h-9 opacity-50' : 'flex-1 h-9'"
|
||||
:disabled="form.never_expire"
|
||||
@update:model-value="(v) => form.expire_days = parseNumberInput(v, { min: 1, max: 3650 })"
|
||||
id="form-expires-at"
|
||||
:model-value="form.expires_at || ''"
|
||||
type="date"
|
||||
:min="minExpiryDate"
|
||||
class="h-9 pr-8"
|
||||
:placeholder="form.expires_at ? '' : '永不过期'"
|
||||
@update:model-value="(v) => form.expires_at = v || undefined"
|
||||
/>
|
||||
<label class="flex items-center gap-1.5 border rounded-md px-2 py-1.5 bg-muted/50 cursor-pointer text-xs whitespace-nowrap">
|
||||
<input
|
||||
v-model="form.never_expire"
|
||||
type="checkbox"
|
||||
class="h-3.5 w-3.5 rounded border-gray-300 cursor-pointer"
|
||||
@change="onNeverExpireChange"
|
||||
<button
|
||||
v-if="form.expires_at"
|
||||
type="button"
|
||||
class="absolute right-2 top-1/2 -translate-y-1/2 text-muted-foreground hover:text-foreground"
|
||||
title="清空(永不过期)"
|
||||
@click="clearExpiryDate"
|
||||
>
|
||||
永不过期
|
||||
</label>
|
||||
<X class="h-4 w-4" />
|
||||
</button>
|
||||
</div>
|
||||
<label
|
||||
class="flex items-center gap-1.5 border rounded-md px-2 py-1.5 bg-muted/50 cursor-pointer text-xs whitespace-nowrap"
|
||||
:class="form.never_expire ? 'opacity-50' : ''"
|
||||
:class="!form.expires_at ? 'opacity-50 cursor-not-allowed' : ''"
|
||||
>
|
||||
<input
|
||||
v-model="form.auto_delete_on_expiry"
|
||||
type="checkbox"
|
||||
class="h-3.5 w-3.5 rounded border-gray-300 cursor-pointer"
|
||||
:disabled="form.never_expire"
|
||||
:disabled="!form.expires_at"
|
||||
>
|
||||
到期删除
|
||||
</label>
|
||||
</div>
|
||||
<p class="text-xs text-muted-foreground">
|
||||
不勾选"到期删除"则仅禁用
|
||||
{{ form.expires_at ? '到期后' + (form.auto_delete_on_expiry ? '自动删除' : '仅禁用') + '(当天 23:59 失效)' : '留空表示永不过期' }}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
@@ -244,55 +244,10 @@
|
||||
</div>
|
||||
|
||||
<!-- 模型多选下拉框 -->
|
||||
<div class="space-y-2">
|
||||
<Label class="text-sm font-medium">允许的模型</Label>
|
||||
<div class="relative">
|
||||
<button
|
||||
type="button"
|
||||
class="w-full h-10 px-3 border rounded-lg bg-background text-left flex items-center justify-between hover:bg-muted/50 transition-colors"
|
||||
@click="modelDropdownOpen = !modelDropdownOpen"
|
||||
>
|
||||
<span :class="form.allowed_models.length ? 'text-foreground' : 'text-muted-foreground'">
|
||||
{{ form.allowed_models.length ? `已选择 ${form.allowed_models.length} 个` : '全部可用' }}
|
||||
</span>
|
||||
<ChevronDown
|
||||
class="h-4 w-4 text-muted-foreground transition-transform"
|
||||
:class="modelDropdownOpen ? 'rotate-180' : ''"
|
||||
<ModelMultiSelect
|
||||
v-model="form.allowed_models"
|
||||
:models="globalModels"
|
||||
/>
|
||||
</button>
|
||||
<div
|
||||
v-if="modelDropdownOpen"
|
||||
class="fixed inset-0 z-[80]"
|
||||
@click.stop="modelDropdownOpen = false"
|
||||
/>
|
||||
<div
|
||||
v-if="modelDropdownOpen"
|
||||
class="absolute z-[90] w-full mt-1 bg-popover border rounded-lg shadow-lg max-h-48 overflow-y-auto"
|
||||
>
|
||||
<div
|
||||
v-for="model in globalModels"
|
||||
:key="model.name"
|
||||
class="flex items-center gap-2 px-3 py-2 hover:bg-muted/50 cursor-pointer"
|
||||
@click="toggleSelection('allowed_models', model.name)"
|
||||
>
|
||||
<input
|
||||
type="checkbox"
|
||||
:checked="form.allowed_models.includes(model.name)"
|
||||
class="h-4 w-4 rounded border-gray-300 cursor-pointer"
|
||||
@click.stop
|
||||
@change="toggleSelection('allowed_models', model.name)"
|
||||
>
|
||||
<span class="text-sm">{{ model.name }}</span>
|
||||
</div>
|
||||
<div
|
||||
v-if="globalModels.length === 0"
|
||||
class="px-3 py-2 text-sm text-muted-foreground"
|
||||
>
|
||||
暂无可用模型
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</form>
|
||||
@@ -325,8 +280,9 @@ import {
|
||||
Input,
|
||||
Label,
|
||||
} from '@/components/ui'
|
||||
import { Plus, SquarePen, Key, Shield, ChevronDown } from 'lucide-vue-next'
|
||||
import { Plus, SquarePen, Key, Shield, ChevronDown, X } from 'lucide-vue-next'
|
||||
import { useFormDialog } from '@/composables/useFormDialog'
|
||||
import { ModelMultiSelect } from '@/components/common'
|
||||
import { getProvidersSummary } from '@/api/endpoints/providers'
|
||||
import { getGlobalModels } from '@/api/global-models'
|
||||
import { adminApi } from '@/api/admin'
|
||||
@@ -338,8 +294,7 @@ export interface StandaloneKeyFormData {
|
||||
id?: string
|
||||
name: string
|
||||
initial_balance_usd?: number
|
||||
expire_days?: number
|
||||
never_expire: boolean
|
||||
expires_at?: string // ISO 日期字符串,如 "2025-12-31",undefined = 永不过期
|
||||
rate_limit?: number
|
||||
auto_delete_on_expiry: boolean
|
||||
allowed_providers: string[]
|
||||
@@ -363,7 +318,6 @@ const saving = ref(false)
|
||||
// 下拉框状态
|
||||
const providerDropdownOpen = ref(false)
|
||||
const apiFormatDropdownOpen = ref(false)
|
||||
const modelDropdownOpen = ref(false)
|
||||
|
||||
// 选项数据
|
||||
const providers = ref<ProviderWithEndpointsSummary[]>([])
|
||||
@@ -374,8 +328,7 @@ const allApiFormats = ref<string[]>([])
|
||||
const form = ref<StandaloneKeyFormData>({
|
||||
name: '',
|
||||
initial_balance_usd: 10,
|
||||
expire_days: undefined,
|
||||
never_expire: true,
|
||||
expires_at: undefined,
|
||||
rate_limit: undefined,
|
||||
auto_delete_on_expiry: false,
|
||||
allowed_providers: [],
|
||||
@@ -383,12 +336,18 @@ const form = ref<StandaloneKeyFormData>({
|
||||
allowed_models: []
|
||||
})
|
||||
|
||||
// 计算最小可选日期(明天)
|
||||
const minExpiryDate = computed(() => {
|
||||
const tomorrow = new Date()
|
||||
tomorrow.setDate(tomorrow.getDate() + 1)
|
||||
return tomorrow.toISOString().split('T')[0]
|
||||
})
|
||||
|
||||
function resetForm() {
|
||||
form.value = {
|
||||
name: '',
|
||||
initial_balance_usd: 10,
|
||||
expire_days: undefined,
|
||||
never_expire: true,
|
||||
expires_at: undefined,
|
||||
rate_limit: undefined,
|
||||
auto_delete_on_expiry: false,
|
||||
allowed_providers: [],
|
||||
@@ -397,7 +356,6 @@ function resetForm() {
|
||||
}
|
||||
providerDropdownOpen.value = false
|
||||
apiFormatDropdownOpen.value = false
|
||||
modelDropdownOpen.value = false
|
||||
}
|
||||
|
||||
function loadKeyData() {
|
||||
@@ -406,8 +364,7 @@ function loadKeyData() {
|
||||
id: props.apiKey.id,
|
||||
name: props.apiKey.name || '',
|
||||
initial_balance_usd: props.apiKey.initial_balance_usd,
|
||||
expire_days: props.apiKey.expire_days,
|
||||
never_expire: props.apiKey.never_expire,
|
||||
expires_at: props.apiKey.expires_at,
|
||||
rate_limit: props.apiKey.rate_limit,
|
||||
auto_delete_on_expiry: props.apiKey.auto_delete_on_expiry,
|
||||
allowed_providers: props.apiKey.allowed_providers || [],
|
||||
@@ -452,13 +409,11 @@ function toggleSelection(field: 'allowed_providers' | 'allowed_api_formats' | 'a
|
||||
}
|
||||
}
|
||||
|
||||
// 永不过期切换
|
||||
function onNeverExpireChange() {
|
||||
if (form.value.never_expire) {
|
||||
form.value.expire_days = undefined
|
||||
// 清空过期日期(同时清空到期删除选项)
|
||||
function clearExpiryDate() {
|
||||
form.value.expires_at = undefined
|
||||
form.value.auto_delete_on_expiry = false
|
||||
}
|
||||
}
|
||||
|
||||
// 提交表单
|
||||
function handleSubmit() {
|
||||
|
||||
@@ -98,12 +98,27 @@
|
||||
|
||||
<!-- 提示信息 -->
|
||||
<p
|
||||
v-if="!isDemo"
|
||||
v-if="!isDemo && !allowRegistration"
|
||||
class="text-xs text-slate-400 dark:text-muted-foreground/80"
|
||||
>
|
||||
如需开通账户,请联系管理员配置访问权限
|
||||
</p>
|
||||
</form>
|
||||
|
||||
<!-- 注册链接 -->
|
||||
<div
|
||||
v-if="allowRegistration"
|
||||
class="mt-4 text-center text-sm"
|
||||
>
|
||||
还没有账户?
|
||||
<Button
|
||||
variant="link"
|
||||
class="h-auto p-0"
|
||||
@click="handleSwitchToRegister"
|
||||
>
|
||||
立即注册
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<template #footer>
|
||||
@@ -124,10 +139,18 @@
|
||||
</Button>
|
||||
</template>
|
||||
</Dialog>
|
||||
|
||||
<!-- Register Dialog -->
|
||||
<RegisterDialog
|
||||
v-model:open="showRegisterDialog"
|
||||
:require-email-verification="requireEmailVerification"
|
||||
@success="handleRegisterSuccess"
|
||||
@switch-to-login="handleSwitchToLogin"
|
||||
/>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, watch, computed } from 'vue'
|
||||
import { ref, watch, computed, onMounted } from 'vue'
|
||||
import { useRouter } from 'vue-router'
|
||||
import { Dialog } from '@/components/ui'
|
||||
import Button from '@/components/ui/button.vue'
|
||||
@@ -136,6 +159,8 @@ import Label from '@/components/ui/label.vue'
|
||||
import { useAuthStore } from '@/stores/auth'
|
||||
import { useToast } from '@/composables/useToast'
|
||||
import { isDemoMode, DEMO_ACCOUNTS } from '@/config/demo'
|
||||
import RegisterDialog from './RegisterDialog.vue'
|
||||
import { authApi } from '@/api/auth'
|
||||
|
||||
const props = defineProps<{
|
||||
modelValue: boolean
|
||||
@@ -151,6 +176,9 @@ const { success: showSuccess, warning: showWarning, error: showError } = useToas
|
||||
|
||||
const isOpen = ref(props.modelValue)
|
||||
const isDemo = computed(() => isDemoMode())
|
||||
const showRegisterDialog = ref(false)
|
||||
const requireEmailVerification = ref(false)
|
||||
const allowRegistration = ref(false) // 由系统配置控制,默认关闭
|
||||
|
||||
watch(() => props.modelValue, (val) => {
|
||||
isOpen.value = val
|
||||
@@ -201,4 +229,33 @@ async function handleLogin() {
|
||||
showError(authStore.error || '登录失败,请检查邮箱和密码')
|
||||
}
|
||||
}
|
||||
|
||||
function handleSwitchToRegister() {
|
||||
isOpen.value = false
|
||||
showRegisterDialog.value = true
|
||||
}
|
||||
|
||||
function handleRegisterSuccess() {
|
||||
showRegisterDialog.value = false
|
||||
showSuccess('注册成功!请登录')
|
||||
isOpen.value = true
|
||||
}
|
||||
|
||||
function handleSwitchToLogin() {
|
||||
showRegisterDialog.value = false
|
||||
isOpen.value = true
|
||||
}
|
||||
|
||||
// Load registration settings on mount
|
||||
onMounted(async () => {
|
||||
try {
|
||||
const settings = await authApi.getRegistrationSettings()
|
||||
allowRegistration.value = !!settings.enable_registration
|
||||
requireEmailVerification.value = !!settings.require_email_verification
|
||||
} catch (error) {
|
||||
// If获取失败,保持默认:关闭注册 & 关闭邮箱验证
|
||||
allowRegistration.value = false
|
||||
requireEmailVerification.value = false
|
||||
}
|
||||
})
|
||||
</script>
|
||||
|
||||
640
frontend/src/features/auth/components/RegisterDialog.vue
Normal file
640
frontend/src/features/auth/components/RegisterDialog.vue
Normal file
@@ -0,0 +1,640 @@
|
||||
<template>
|
||||
<Dialog
|
||||
v-model:open="isOpen"
|
||||
size="lg"
|
||||
>
|
||||
<div class="space-y-6">
|
||||
<!-- Logo 和标题 -->
|
||||
<div class="flex flex-col items-center text-center">
|
||||
<div class="mb-4 rounded-3xl border border-primary/30 dark:border-[#cc785c]/30 bg-primary/5 dark:bg-transparent p-4 shadow-inner shadow-white/40 dark:shadow-[#cc785c]/10">
|
||||
<img
|
||||
src="/aether_adaptive.svg"
|
||||
alt="Logo"
|
||||
class="h-16 w-16"
|
||||
>
|
||||
</div>
|
||||
<h2 class="text-2xl font-semibold text-slate-900 dark:text-white">
|
||||
注册新账户
|
||||
</h2>
|
||||
<p class="mt-1 text-sm text-muted-foreground">
|
||||
请填写您的邮箱和个人信息完成注册
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<!-- 注册表单 -->
|
||||
<form
|
||||
class="space-y-4"
|
||||
autocomplete="off"
|
||||
data-form-type="other"
|
||||
@submit.prevent="handleSubmit"
|
||||
>
|
||||
<!-- Email -->
|
||||
<div class="space-y-2">
|
||||
<Label for="reg-email">邮箱 <span class="text-muted-foreground">*</span></Label>
|
||||
<Input
|
||||
id="reg-email"
|
||||
v-model="formData.email"
|
||||
type="email"
|
||||
placeholder="hello@example.com"
|
||||
required
|
||||
disable-autofill
|
||||
:disabled="isLoading || emailVerified"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<!-- Verification Code Section -->
|
||||
<div
|
||||
v-if="requireEmailVerification"
|
||||
class="space-y-3"
|
||||
>
|
||||
<div class="flex items-center justify-between">
|
||||
<Label>验证码 <span class="text-muted-foreground">*</span></Label>
|
||||
<Button
|
||||
type="button"
|
||||
variant="link"
|
||||
size="sm"
|
||||
class="h-auto p-0 text-xs"
|
||||
:disabled="isSendingCode || !canSendCode || emailVerified"
|
||||
@click="handleSendCode"
|
||||
>
|
||||
{{ sendCodeButtonText }}
|
||||
</Button>
|
||||
</div>
|
||||
<div class="flex justify-center gap-2">
|
||||
<!-- 发送中显示 loading -->
|
||||
<div
|
||||
v-if="isSendingCode"
|
||||
class="flex items-center justify-center gap-2 h-14 text-muted-foreground"
|
||||
>
|
||||
<svg
|
||||
class="animate-spin h-5 w-5"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
fill="none"
|
||||
viewBox="0 0 24 24"
|
||||
>
|
||||
<circle
|
||||
class="opacity-25"
|
||||
cx="12"
|
||||
cy="12"
|
||||
r="10"
|
||||
stroke="currentColor"
|
||||
stroke-width="4"
|
||||
/>
|
||||
<path
|
||||
class="opacity-75"
|
||||
fill="currentColor"
|
||||
d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"
|
||||
/>
|
||||
</svg>
|
||||
<span class="text-sm">正在发送验证码...</span>
|
||||
</div>
|
||||
<!-- 验证码输入框 -->
|
||||
<template v-else>
|
||||
<input
|
||||
v-for="(_, index) in 6"
|
||||
:key="index"
|
||||
:ref="(el) => setCodeInputRef(index, el as HTMLInputElement)"
|
||||
v-model="codeDigits[index]"
|
||||
type="text"
|
||||
inputmode="numeric"
|
||||
maxlength="1"
|
||||
autocomplete="off"
|
||||
data-form-type="other"
|
||||
class="w-12 h-14 text-center text-xl font-semibold border-2 rounded-lg bg-background transition-all focus:outline-none focus:ring-2 focus:ring-primary/20"
|
||||
:class="verificationError ? 'border-destructive' : 'border-border focus:border-primary'"
|
||||
:disabled="emailVerified"
|
||||
@input="handleCodeInput(index, $event)"
|
||||
@keydown="handleCodeKeyDown(index, $event)"
|
||||
@paste="handleCodePaste"
|
||||
>
|
||||
</template>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Username -->
|
||||
<div class="space-y-2">
|
||||
<Label for="reg-uname">用户名 <span class="text-muted-foreground">*</span></Label>
|
||||
<Input
|
||||
id="reg-uname"
|
||||
v-model="formData.username"
|
||||
type="text"
|
||||
placeholder="请输入用户名"
|
||||
required
|
||||
disable-autofill
|
||||
:disabled="isLoading"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<!-- Password -->
|
||||
<div class="space-y-2">
|
||||
<Label :for="`pwd-${formNonce}`">密码 <span class="text-muted-foreground">*</span></Label>
|
||||
<Input
|
||||
:id="`pwd-${formNonce}`"
|
||||
v-model="formData.password"
|
||||
type="text"
|
||||
autocomplete="one-time-code"
|
||||
data-form-type="other"
|
||||
data-lpignore="true"
|
||||
data-1p-ignore="true"
|
||||
:name="`pwd-${formNonce}`"
|
||||
placeholder="至少 6 个字符"
|
||||
required
|
||||
class="-webkit-text-security-disc"
|
||||
:disabled="isLoading"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<!-- Confirm Password -->
|
||||
<div class="space-y-2">
|
||||
<Label :for="`pwd-confirm-${formNonce}`">确认密码 <span class="text-muted-foreground">*</span></Label>
|
||||
<Input
|
||||
:id="`pwd-confirm-${formNonce}`"
|
||||
v-model="formData.confirmPassword"
|
||||
type="text"
|
||||
autocomplete="one-time-code"
|
||||
data-form-type="other"
|
||||
data-lpignore="true"
|
||||
data-1p-ignore="true"
|
||||
:name="`pwd-confirm-${formNonce}`"
|
||||
placeholder="再次输入密码"
|
||||
required
|
||||
class="-webkit-text-security-disc"
|
||||
:disabled="isLoading"
|
||||
/>
|
||||
</div>
|
||||
</form>
|
||||
|
||||
<!-- 登录链接 -->
|
||||
<div class="text-center text-sm">
|
||||
已有账户?
|
||||
<Button
|
||||
variant="link"
|
||||
class="h-auto p-0"
|
||||
@click="handleSwitchToLogin"
|
||||
>
|
||||
立即登录
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<template #footer>
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
class="w-full sm:w-auto border-slate-200 dark:border-slate-600 text-slate-500 dark:text-slate-400 hover:text-primary hover:border-primary/50 hover:bg-primary/5 dark:hover:text-primary dark:hover:border-primary/50 dark:hover:bg-primary/10"
|
||||
:disabled="isLoading"
|
||||
@click="handleCancel"
|
||||
>
|
||||
取消
|
||||
</Button>
|
||||
<Button
|
||||
class="w-full sm:w-auto bg-primary hover:bg-primary/90 text-white border-0"
|
||||
:disabled="isLoading || !canSubmit"
|
||||
@click="handleSubmit"
|
||||
>
|
||||
{{ isLoading ? loadingText : '注册' }}
|
||||
</Button>
|
||||
</template>
|
||||
</Dialog>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, computed, watch, onUnmounted, nextTick } from 'vue'
|
||||
import { authApi } from '@/api/auth'
|
||||
import { useToast } from '@/composables/useToast'
|
||||
import { Dialog } from '@/components/ui'
|
||||
import Button from '@/components/ui/button.vue'
|
||||
import Input from '@/components/ui/input.vue'
|
||||
import Label from '@/components/ui/label.vue'
|
||||
|
||||
interface Props {
|
||||
open?: boolean
|
||||
requireEmailVerification?: boolean
|
||||
}
|
||||
|
||||
interface Emits {
|
||||
(e: 'update:open', value: boolean): void
|
||||
(e: 'success'): void
|
||||
(e: 'switchToLogin'): void
|
||||
}
|
||||
|
||||
const props = withDefaults(defineProps<Props>(), {
|
||||
open: false,
|
||||
requireEmailVerification: false
|
||||
})
|
||||
|
||||
const emit = defineEmits<Emits>()
|
||||
const { success, error: showError } = useToast()
|
||||
|
||||
// Form nonce for password fields (prevent autofill)
|
||||
const formNonce = ref(createFormNonce())
|
||||
|
||||
function createFormNonce(): string {
|
||||
return Math.random().toString(36).slice(2, 10)
|
||||
}
|
||||
|
||||
// Verification code inputs
|
||||
const codeInputRefs = ref<(HTMLInputElement | null)[]>([])
|
||||
const codeDigits = ref<string[]>(['', '', '', '', '', ''])
|
||||
|
||||
const setCodeInputRef = (index: number, el: HTMLInputElement | null) => {
|
||||
codeInputRefs.value[index] = el
|
||||
}
|
||||
|
||||
// Handle verification code input
|
||||
const handleCodeInput = (index: number, event: Event) => {
|
||||
const input = event.target as HTMLInputElement
|
||||
const value = input.value
|
||||
|
||||
// Only allow digits
|
||||
if (!/^\d*$/.test(value)) {
|
||||
input.value = codeDigits.value[index]
|
||||
return
|
||||
}
|
||||
|
||||
codeDigits.value[index] = value
|
||||
|
||||
// Auto-focus next input
|
||||
if (value && index < 5) {
|
||||
codeInputRefs.value[index + 1]?.focus()
|
||||
}
|
||||
|
||||
// Check if all digits are filled
|
||||
const fullCode = codeDigits.value.join('')
|
||||
if (fullCode.length === 6 && /^\d+$/.test(fullCode)) {
|
||||
handleCodeComplete(fullCode)
|
||||
}
|
||||
}
|
||||
|
||||
const handleCodeKeyDown = (index: number, event: KeyboardEvent) => {
|
||||
// Handle backspace
|
||||
if (event.key === 'Backspace') {
|
||||
if (!codeDigits.value[index] && index > 0) {
|
||||
// If current input is empty, move to previous and clear it
|
||||
codeInputRefs.value[index - 1]?.focus()
|
||||
codeDigits.value[index - 1] = ''
|
||||
} else {
|
||||
// Clear current input
|
||||
codeDigits.value[index] = ''
|
||||
}
|
||||
}
|
||||
// Handle arrow keys
|
||||
else if (event.key === 'ArrowLeft' && index > 0) {
|
||||
codeInputRefs.value[index - 1]?.focus()
|
||||
} else if (event.key === 'ArrowRight' && index < 5) {
|
||||
codeInputRefs.value[index + 1]?.focus()
|
||||
}
|
||||
}
|
||||
|
||||
const handleCodePaste = (event: ClipboardEvent) => {
|
||||
event.preventDefault()
|
||||
const pastedData = event.clipboardData?.getData('text') || ''
|
||||
const cleanedData = pastedData.replace(/\D/g, '').slice(0, 6)
|
||||
|
||||
if (cleanedData) {
|
||||
// Fill digits
|
||||
for (let i = 0; i < 6; i++) {
|
||||
codeDigits.value[i] = cleanedData[i] || ''
|
||||
}
|
||||
|
||||
// Focus the next empty input or the last input
|
||||
const nextEmptyIndex = codeDigits.value.findIndex((d) => !d)
|
||||
const focusIndex = nextEmptyIndex >= 0 ? nextEmptyIndex : 5
|
||||
codeInputRefs.value[focusIndex]?.focus()
|
||||
|
||||
// Check if all digits are filled
|
||||
if (cleanedData.length === 6) {
|
||||
handleCodeComplete(cleanedData)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const clearCodeInputs = () => {
|
||||
codeDigits.value = ['', '', '', '', '', '']
|
||||
codeInputRefs.value[0]?.focus()
|
||||
}
|
||||
|
||||
const isOpen = computed({
|
||||
get: () => props.open,
|
||||
set: (value) => emit('update:open', value)
|
||||
})
|
||||
|
||||
const formData = ref({
|
||||
email: '',
|
||||
username: '',
|
||||
password: '',
|
||||
confirmPassword: '',
|
||||
verificationCode: ''
|
||||
})
|
||||
|
||||
const isLoading = ref(false)
|
||||
const loadingText = ref('注册中...')
|
||||
const isSendingCode = ref(false)
|
||||
const emailVerified = ref(false)
|
||||
const verificationError = ref(false)
|
||||
const codeSentAt = ref<number | null>(null)
|
||||
const cooldownSeconds = ref(0)
|
||||
const expireMinutes = ref(5)
|
||||
const cooldownTimer = ref<number | null>(null)
|
||||
|
||||
// Send code cooldown timer
|
||||
const canSendCode = computed(() => {
|
||||
if (!formData.value.email) return false
|
||||
if (cooldownSeconds.value > 0) return false
|
||||
return true
|
||||
})
|
||||
|
||||
const sendCodeButtonText = computed(() => {
|
||||
if (isSendingCode.value) return '发送中...'
|
||||
if (emailVerified.value) return '验证成功'
|
||||
if (cooldownSeconds.value > 0) return `${cooldownSeconds.value}秒后重试`
|
||||
if (codeSentAt.value) return '重新发送验证码'
|
||||
return '发送验证码'
|
||||
})
|
||||
|
||||
const canSubmit = computed(() => {
|
||||
const hasBasicInfo =
|
||||
formData.value.email &&
|
||||
formData.value.username &&
|
||||
formData.value.password &&
|
||||
formData.value.confirmPassword
|
||||
|
||||
if (!hasBasicInfo) return false
|
||||
|
||||
// If email verification is required, check if verified
|
||||
if (props.requireEmailVerification && !emailVerified.value) {
|
||||
return false
|
||||
}
|
||||
|
||||
// Check password match
|
||||
if (formData.value.password !== formData.value.confirmPassword) {
|
||||
return false
|
||||
}
|
||||
|
||||
// Check password length
|
||||
if (formData.value.password.length < 6) {
|
||||
return false
|
||||
}
|
||||
|
||||
return true
|
||||
})
|
||||
|
||||
// 查询并恢复验证状态
|
||||
const checkAndRestoreVerificationStatus = async (email: string) => {
|
||||
if (!email || !props.requireEmailVerification) return
|
||||
|
||||
try {
|
||||
const status = await authApi.getVerificationStatus(email)
|
||||
|
||||
// 注意:不恢复 is_verified 状态
|
||||
// 刷新页面后需要重新发送验证码并验证,防止验证码被他人使用
|
||||
// 只恢复"有待验证验证码"的状态(冷却时间)
|
||||
if (status.has_pending_code) {
|
||||
codeSentAt.value = Date.now()
|
||||
verificationError.value = false
|
||||
|
||||
// 恢复冷却时间
|
||||
if (status.cooldown_remaining && status.cooldown_remaining > 0) {
|
||||
startCooldown(status.cooldown_remaining)
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
// 查询失败时静默处理,不影响用户体验
|
||||
}
|
||||
}
|
||||
|
||||
// 邮箱查询防抖定时器
|
||||
let emailCheckTimer: number | null = null
|
||||
|
||||
// 监听邮箱变化,查询验证状态
|
||||
watch(
|
||||
() => formData.value.email,
|
||||
(newEmail, oldEmail) => {
|
||||
// 邮箱变化时重置验证状态
|
||||
if (newEmail !== oldEmail) {
|
||||
emailVerified.value = false
|
||||
verificationError.value = false
|
||||
codeSentAt.value = null
|
||||
cooldownSeconds.value = 0
|
||||
if (cooldownTimer.value !== null) {
|
||||
clearInterval(cooldownTimer.value)
|
||||
cooldownTimer.value = null
|
||||
}
|
||||
codeDigits.value = ['', '', '', '', '', '']
|
||||
}
|
||||
|
||||
// 清除之前的定时器
|
||||
if (emailCheckTimer !== null) {
|
||||
clearTimeout(emailCheckTimer)
|
||||
}
|
||||
|
||||
// 验证邮箱格式
|
||||
const emailRegex = /^[^\s@]+@[^\s@]+\.[^\s@]+$/
|
||||
if (!emailRegex.test(newEmail)) return
|
||||
|
||||
// 防抖:500ms 后查询验证状态
|
||||
emailCheckTimer = window.setTimeout(() => {
|
||||
checkAndRestoreVerificationStatus(newEmail)
|
||||
}, 500)
|
||||
}
|
||||
)
|
||||
|
||||
// Reset form when dialog opens
|
||||
watch(isOpen, (newValue) => {
|
||||
if (newValue) {
|
||||
resetForm()
|
||||
}
|
||||
})
|
||||
|
||||
// Start cooldown timer
|
||||
const startCooldown = (seconds: number) => {
|
||||
// Clear existing timer if any
|
||||
if (cooldownTimer.value !== null) {
|
||||
clearInterval(cooldownTimer.value)
|
||||
}
|
||||
|
||||
cooldownSeconds.value = seconds
|
||||
cooldownTimer.value = window.setInterval(() => {
|
||||
cooldownSeconds.value--
|
||||
if (cooldownSeconds.value <= 0) {
|
||||
if (cooldownTimer.value !== null) {
|
||||
clearInterval(cooldownTimer.value)
|
||||
cooldownTimer.value = null
|
||||
}
|
||||
}
|
||||
}, 1000)
|
||||
}
|
||||
|
||||
// Cleanup timer on unmount
|
||||
onUnmounted(() => {
|
||||
if (cooldownTimer.value !== null) {
|
||||
clearInterval(cooldownTimer.value)
|
||||
}
|
||||
if (emailCheckTimer !== null) {
|
||||
clearTimeout(emailCheckTimer)
|
||||
}
|
||||
})
|
||||
|
||||
const resetForm = () => {
|
||||
formData.value = {
|
||||
email: '',
|
||||
username: '',
|
||||
password: '',
|
||||
confirmPassword: '',
|
||||
verificationCode: ''
|
||||
}
|
||||
emailVerified.value = false
|
||||
verificationError.value = false
|
||||
isSendingCode.value = false
|
||||
codeSentAt.value = null
|
||||
cooldownSeconds.value = 0
|
||||
|
||||
// Reset password field nonce
|
||||
formNonce.value = createFormNonce()
|
||||
|
||||
// Clear timer
|
||||
if (cooldownTimer.value !== null) {
|
||||
clearInterval(cooldownTimer.value)
|
||||
cooldownTimer.value = null
|
||||
}
|
||||
|
||||
// Clear verification code inputs
|
||||
codeDigits.value = ['', '', '', '', '', '']
|
||||
}
|
||||
|
||||
const handleSendCode = async () => {
|
||||
if (!formData.value.email) {
|
||||
showError('请输入邮箱')
|
||||
return
|
||||
}
|
||||
|
||||
// Basic email validation
|
||||
const emailRegex = /^[^\s@]+@[^\s@]+\.[^\s@]+$/
|
||||
if (!emailRegex.test(formData.value.email)) {
|
||||
showError('请输入有效的邮箱地址', '邮箱格式错误')
|
||||
return
|
||||
}
|
||||
|
||||
isSendingCode.value = true
|
||||
|
||||
try {
|
||||
const response = await authApi.sendVerificationCode(formData.value.email)
|
||||
|
||||
if (response.success) {
|
||||
codeSentAt.value = Date.now()
|
||||
if (response.expire_minutes) {
|
||||
expireMinutes.value = response.expire_minutes
|
||||
}
|
||||
|
||||
success(`请查收邮件,验证码有效期 ${expireMinutes.value} 分钟`, '验证码已发送')
|
||||
|
||||
// Start 60 second cooldown
|
||||
startCooldown(60)
|
||||
|
||||
// Focus the first verification code input
|
||||
nextTick(() => {
|
||||
codeInputRefs.value[0]?.focus()
|
||||
})
|
||||
} else {
|
||||
showError(response.message || '请稍后重试', '发送失败')
|
||||
}
|
||||
} catch (error: any) {
|
||||
const errorMsg = error.response?.data?.detail
|
||||
|| error.response?.data?.error?.message
|
||||
|| error.message
|
||||
|| '网络错误,请重试'
|
||||
showError(errorMsg, '发送失败')
|
||||
} finally {
|
||||
isSendingCode.value = false
|
||||
}
|
||||
}
|
||||
|
||||
const handleCodeComplete = async (code: string) => {
|
||||
if (!formData.value.email || code.length !== 6) return
|
||||
|
||||
// 如果已经验证成功,不再重复验证
|
||||
if (emailVerified.value) return
|
||||
|
||||
isLoading.value = true
|
||||
loadingText.value = '验证中...'
|
||||
verificationError.value = false
|
||||
|
||||
try {
|
||||
const response = await authApi.verifyEmail(formData.value.email, code)
|
||||
|
||||
if (response.success) {
|
||||
emailVerified.value = true
|
||||
success('邮箱验证通过,请继续完成注册', '验证成功')
|
||||
} else {
|
||||
verificationError.value = true
|
||||
showError(response.message || '验证码错误', '验证失败')
|
||||
// Clear the code input
|
||||
clearCodeInputs()
|
||||
}
|
||||
} catch (error: any) {
|
||||
verificationError.value = true
|
||||
const errorMsg = error.response?.data?.detail
|
||||
|| error.response?.data?.error?.message
|
||||
|| error.message
|
||||
|| '验证码错误,请重试'
|
||||
showError(errorMsg, '验证失败')
|
||||
// Clear the code input
|
||||
clearCodeInputs()
|
||||
} finally {
|
||||
isLoading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
const handleSubmit = async () => {
|
||||
// Validate password match
|
||||
if (formData.value.password !== formData.value.confirmPassword) {
|
||||
showError('两次输入的密码不一致', '密码不匹配')
|
||||
return
|
||||
}
|
||||
|
||||
// Validate password length
|
||||
if (formData.value.password.length < 6) {
|
||||
showError('密码长度至少 6 位', '密码过短')
|
||||
return
|
||||
}
|
||||
|
||||
// Check email verification if required
|
||||
if (props.requireEmailVerification && !emailVerified.value) {
|
||||
showError('请先完成邮箱验证')
|
||||
return
|
||||
}
|
||||
|
||||
isLoading.value = true
|
||||
loadingText.value = '注册中...'
|
||||
|
||||
try {
|
||||
const response = await authApi.register({
|
||||
email: formData.value.email,
|
||||
username: formData.value.username,
|
||||
password: formData.value.password
|
||||
})
|
||||
|
||||
success(response.message || '欢迎加入!请登录以继续', '注册成功')
|
||||
|
||||
emit('success')
|
||||
isOpen.value = false
|
||||
} catch (error: any) {
|
||||
const errorMsg = error.response?.data?.detail
|
||||
|| error.response?.data?.error?.message
|
||||
|| error.message
|
||||
|| '注册失败,请重试'
|
||||
showError(errorMsg, '注册失败')
|
||||
} finally {
|
||||
isLoading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
const handleCancel = () => {
|
||||
isOpen.value = false
|
||||
}
|
||||
|
||||
const handleSwitchToLogin = () => {
|
||||
emit('switchToLogin')
|
||||
isOpen.value = false
|
||||
}
|
||||
</script>
|
||||
@@ -700,6 +700,7 @@ import {
|
||||
} from 'lucide-vue-next'
|
||||
import { useEscapeKey } from '@/composables/useEscapeKey'
|
||||
import { useToast } from '@/composables/useToast'
|
||||
import { useClipboard } from '@/composables/useClipboard'
|
||||
import Card from '@/components/ui/card.vue'
|
||||
import Badge from '@/components/ui/badge.vue'
|
||||
import Button from '@/components/ui/button.vue'
|
||||
@@ -731,6 +732,7 @@ const emit = defineEmits<{
|
||||
'refreshProviders': []
|
||||
}>()
|
||||
const { success: showSuccess, error: showError } = useToast()
|
||||
const { copyToClipboard } = useClipboard()
|
||||
|
||||
interface Props {
|
||||
model: GlobalModelResponse | null
|
||||
@@ -763,16 +765,6 @@ function handleClose() {
|
||||
}
|
||||
}
|
||||
|
||||
// 复制到剪贴板
|
||||
async function copyToClipboard(text: string) {
|
||||
try {
|
||||
await navigator.clipboard.writeText(text)
|
||||
showSuccess('已复制')
|
||||
} catch {
|
||||
showError('复制失败')
|
||||
}
|
||||
}
|
||||
|
||||
// 格式化日期
|
||||
function formatDate(dateStr: string): string {
|
||||
if (!dateStr) return '-'
|
||||
|
||||
@@ -374,8 +374,6 @@ import {
|
||||
} from '@/api/endpoints'
|
||||
import { useUpstreamModelsCache, type UpstreamModel } from '../composables/useUpstreamModelsCache'
|
||||
|
||||
const { fetchModels: fetchCachedModels, clearCache, getCachedModels } = useUpstreamModelsCache()
|
||||
|
||||
const props = defineProps<{
|
||||
open: boolean
|
||||
providerId: string
|
||||
@@ -388,6 +386,8 @@ const emit = defineEmits<{
|
||||
'changed': []
|
||||
}>()
|
||||
|
||||
const { fetchModels: fetchCachedModels, clearCache, getCachedModels } = useUpstreamModelsCache()
|
||||
|
||||
const { error: showError, success } = useToast()
|
||||
|
||||
// 状态
|
||||
@@ -433,11 +433,17 @@ const availableGlobalModels = computed(() => {
|
||||
)
|
||||
})
|
||||
|
||||
// 计算可添加的上游模型(排除已关联的)
|
||||
// 计算可添加的上游模型(排除已关联的,包括主模型名和映射名称)
|
||||
const availableUpstreamModelsBase = computed(() => {
|
||||
const existingModelNames = new Set(
|
||||
existingModels.value.map(m => m.provider_model_name)
|
||||
)
|
||||
const existingModelNames = new Set<string>()
|
||||
for (const m of existingModels.value) {
|
||||
// 主模型名
|
||||
existingModelNames.add(m.provider_model_name)
|
||||
// 映射名称
|
||||
for (const mapping of m.provider_model_mappings ?? []) {
|
||||
if (mapping.name) existingModelNames.add(mapping.name)
|
||||
}
|
||||
}
|
||||
return upstreamModels.value.filter(m => !existingModelNames.has(m.id))
|
||||
})
|
||||
|
||||
|
||||
@@ -177,8 +177,8 @@
|
||||
<Label for="proxy_user">用户名(可选)</Label>
|
||||
<Input
|
||||
:id="`proxy_user_${formId}`"
|
||||
:name="`proxy_user_${formId}`"
|
||||
v-model="form.proxy_username"
|
||||
:name="`proxy_user_${formId}`"
|
||||
placeholder="代理认证用户名"
|
||||
autocomplete="off"
|
||||
data-form-type="other"
|
||||
@@ -191,8 +191,8 @@
|
||||
<Label :for="`proxy_pass_${formId}`">密码(可选)</Label>
|
||||
<Input
|
||||
:id="`proxy_pass_${formId}`"
|
||||
:name="`proxy_pass_${formId}`"
|
||||
v-model="form.proxy_password"
|
||||
:name="`proxy_pass_${formId}`"
|
||||
type="text"
|
||||
:placeholder="passwordPlaceholder"
|
||||
autocomplete="off"
|
||||
|
||||
@@ -116,6 +116,25 @@
|
||||
{{ model.global_model_name }}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 测试按钮 -->
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
class="h-7 w-7 shrink-0"
|
||||
title="测试模型连接"
|
||||
:disabled="testingModelName === model.global_model_name"
|
||||
@click.stop="testModelConnection(model)"
|
||||
>
|
||||
<Loader2
|
||||
v-if="testingModelName === model.global_model_name"
|
||||
class="w-3.5 h-3.5 animate-spin"
|
||||
/>
|
||||
<Play
|
||||
v-else
|
||||
class="w-3.5 h-3.5"
|
||||
/>
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -148,16 +167,17 @@
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, computed, watch } from 'vue'
|
||||
import { Box, Loader2, Settings2 } from 'lucide-vue-next'
|
||||
import { Box, Loader2, Settings2, Play } from 'lucide-vue-next'
|
||||
import { Dialog } from '@/components/ui'
|
||||
import Button from '@/components/ui/button.vue'
|
||||
import Badge from '@/components/ui/badge.vue'
|
||||
import Checkbox from '@/components/ui/checkbox.vue'
|
||||
import { useToast } from '@/composables/useToast'
|
||||
import { parseApiError } from '@/utils/errorParser'
|
||||
import { parseApiError, parseTestModelError } from '@/utils/errorParser'
|
||||
import {
|
||||
updateEndpointKey,
|
||||
getProviderAvailableSourceModels,
|
||||
testModel,
|
||||
type EndpointAPIKey,
|
||||
type ProviderAvailableSourceModel
|
||||
} from '@/api/endpoints'
|
||||
@@ -181,6 +201,7 @@ const loadingModels = ref(false)
|
||||
const availableModels = ref<ProviderAvailableSourceModel[]>([])
|
||||
const selectedModels = ref<string[]>([])
|
||||
const initialModels = ref<string[]>([])
|
||||
const testingModelName = ref<string | null>(null)
|
||||
|
||||
// 监听对话框打开
|
||||
watch(() => props.open, (open) => {
|
||||
@@ -268,6 +289,32 @@ function clearModels() {
|
||||
selectedModels.value = []
|
||||
}
|
||||
|
||||
// 测试模型连接
|
||||
async function testModelConnection(model: ProviderAvailableSourceModel) {
|
||||
if (!props.providerId || !props.apiKey || testingModelName.value) return
|
||||
|
||||
testingModelName.value = model.global_model_name
|
||||
try {
|
||||
const result = await testModel({
|
||||
provider_id: props.providerId,
|
||||
model_name: model.provider_model_name,
|
||||
api_key_id: props.apiKey.id,
|
||||
message: "hello"
|
||||
})
|
||||
|
||||
if (result.success) {
|
||||
success(`模型 "${model.display_name}" 测试成功`)
|
||||
} else {
|
||||
showError(`模型测试失败: ${parseTestModelError(result)}`)
|
||||
}
|
||||
} catch (err: any) {
|
||||
const errorMsg = err.response?.data?.detail || err.message || '测试请求失败'
|
||||
showError(`模型测试失败: ${errorMsg}`)
|
||||
} finally {
|
||||
testingModelName.value = null
|
||||
}
|
||||
}
|
||||
|
||||
function areArraysEqual(a: string[], b: string[]): boolean {
|
||||
if (a.length !== b.length) return false
|
||||
const sortedA = [...a].sort()
|
||||
|
||||
@@ -17,7 +17,7 @@
|
||||
v-model:open="modelSelectOpen"
|
||||
:model-value="formData.modelId"
|
||||
:disabled="!!editingGroup"
|
||||
@update:model-value="formData.modelId = $event"
|
||||
@update:model-value="handleModelChange"
|
||||
>
|
||||
<SelectTrigger class="h-9">
|
||||
<SelectValue placeholder="请选择模型" />
|
||||
@@ -449,7 +449,17 @@ interface UpstreamModelGroup {
|
||||
}
|
||||
|
||||
const groupedAvailableUpstreamModels = computed<UpstreamModelGroup[]>(() => {
|
||||
// 收集当前表单已添加的名称
|
||||
const addedNames = new Set(formData.value.aliases.map(a => a.name.trim()))
|
||||
|
||||
// 收集所有已存在的映射名称(包括主模型名和映射名称)
|
||||
for (const m of props.models) {
|
||||
addedNames.add(m.provider_model_name)
|
||||
for (const mapping of m.provider_model_mappings ?? []) {
|
||||
if (mapping.name) addedNames.add(mapping.name)
|
||||
}
|
||||
}
|
||||
|
||||
const availableModels = filteredUpstreamModels.value.filter(m => !addedNames.has(m.id))
|
||||
|
||||
const groups = new Map<string, UpstreamModelGroup>()
|
||||
@@ -519,6 +529,15 @@ function initForm() {
|
||||
}
|
||||
}
|
||||
|
||||
// 处理模型选择变更
|
||||
function handleModelChange(value: string) {
|
||||
formData.value.modelId = value
|
||||
const selectedModel = props.models.find(m => m.id === value)
|
||||
if (selectedModel) {
|
||||
upstreamModelSearch.value = selectedModel.provider_model_name
|
||||
}
|
||||
}
|
||||
|
||||
// 切换 API 格式
|
||||
function toggleApiFormat(format: string) {
|
||||
const index = formData.value.apiFormats.indexOf(format)
|
||||
|
||||
@@ -337,8 +337,40 @@
|
||||
{{ key.is_active ? '活跃' : '禁用' }}
|
||||
</Badge>
|
||||
</div>
|
||||
<div class="text-[10px] font-mono text-muted-foreground truncate">
|
||||
{{ key.api_key_masked }}
|
||||
<div class="flex items-center gap-1">
|
||||
<span class="text-[10px] font-mono text-muted-foreground truncate max-w-[180px]">
|
||||
{{ revealedKeys.has(key.id) ? revealedKeys.get(key.id) : key.api_key_masked }}
|
||||
</span>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
class="h-5 w-5 shrink-0"
|
||||
:title="revealedKeys.has(key.id) ? '隐藏密钥' : '显示密钥'"
|
||||
:disabled="revealingKeyId === key.id"
|
||||
@click.stop="toggleKeyReveal(key)"
|
||||
>
|
||||
<Loader2
|
||||
v-if="revealingKeyId === key.id"
|
||||
class="w-3 h-3 animate-spin"
|
||||
/>
|
||||
<EyeOff
|
||||
v-else-if="revealedKeys.has(key.id)"
|
||||
class="w-3 h-3"
|
||||
/>
|
||||
<Eye
|
||||
v-else
|
||||
class="w-3 h-3"
|
||||
/>
|
||||
</Button>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
class="h-5 w-5 shrink-0"
|
||||
title="复制密钥"
|
||||
@click.stop="copyFullKey(key)"
|
||||
>
|
||||
<Copy class="w-3 h-3" />
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
<div class="flex items-center gap-1.5 ml-auto shrink-0">
|
||||
@@ -531,6 +563,7 @@
|
||||
<!-- 模型名称映射 -->
|
||||
<ModelAliasesTab
|
||||
v-if="provider"
|
||||
ref="modelAliasesTabRef"
|
||||
:key="`aliases-${provider.id}`"
|
||||
:provider="provider"
|
||||
@refresh="handleRelatedDataRefresh"
|
||||
@@ -653,13 +686,16 @@ import {
|
||||
Power,
|
||||
Layers,
|
||||
GripVertical,
|
||||
Copy
|
||||
Copy,
|
||||
Eye,
|
||||
EyeOff
|
||||
} from 'lucide-vue-next'
|
||||
import { useEscapeKey } from '@/composables/useEscapeKey'
|
||||
import Button from '@/components/ui/button.vue'
|
||||
import Badge from '@/components/ui/badge.vue'
|
||||
import Card from '@/components/ui/card.vue'
|
||||
import { useToast } from '@/composables/useToast'
|
||||
import { useClipboard } from '@/composables/useClipboard'
|
||||
import { getProvider, getProviderEndpoints } from '@/api/endpoints'
|
||||
import {
|
||||
KeyFormDialog,
|
||||
@@ -679,6 +715,7 @@ import {
|
||||
updateEndpoint,
|
||||
updateEndpointKey,
|
||||
batchUpdateKeyPriority,
|
||||
revealEndpointKey,
|
||||
type ProviderEndpoint,
|
||||
type EndpointAPIKey,
|
||||
type Model
|
||||
@@ -705,6 +742,7 @@ const emit = defineEmits<{
|
||||
}>()
|
||||
|
||||
const { error: showError, success: showSuccess } = useToast()
|
||||
const { copyToClipboard } = useClipboard()
|
||||
|
||||
const loading = ref(false)
|
||||
const provider = ref<any>(null)
|
||||
@@ -728,6 +766,10 @@ const recoveringEndpointId = ref<string | null>(null)
|
||||
const togglingEndpointId = ref<string | null>(null)
|
||||
const togglingKeyId = ref<string | null>(null)
|
||||
|
||||
// 密钥显示状态:key_id -> 完整密钥
|
||||
const revealedKeys = ref<Map<string, string>>(new Map())
|
||||
const revealingKeyId = ref<string | null>(null)
|
||||
|
||||
// 模型相关状态
|
||||
const modelFormDialogOpen = ref(false)
|
||||
const editingModel = ref<Model | null>(null)
|
||||
@@ -735,6 +777,9 @@ const deleteModelConfirmOpen = ref(false)
|
||||
const modelToDelete = ref<Model | null>(null)
|
||||
const batchAssignDialogOpen = ref(false)
|
||||
|
||||
// ModelAliasesTab 组件引用
|
||||
const modelAliasesTabRef = ref<InstanceType<typeof ModelAliasesTab> | null>(null)
|
||||
|
||||
// 拖动排序相关状态
|
||||
const dragState = ref({
|
||||
isDragging: false,
|
||||
@@ -756,7 +801,9 @@ const hasBlockingDialogOpen = computed(() =>
|
||||
deleteKeyConfirmOpen.value ||
|
||||
modelFormDialogOpen.value ||
|
||||
deleteModelConfirmOpen.value ||
|
||||
batchAssignDialogOpen.value
|
||||
batchAssignDialogOpen.value ||
|
||||
// 检测 ModelAliasesTab 子组件的 Dialog 是否打开
|
||||
modelAliasesTabRef.value?.dialogOpen
|
||||
)
|
||||
|
||||
// 监听 providerId 变化
|
||||
@@ -792,6 +839,9 @@ watch(() => props.open, (newOpen) => {
|
||||
currentEndpoint.value = null
|
||||
editingKey.value = null
|
||||
keyToDelete.value = null
|
||||
|
||||
// 清除已显示的密钥(安全考虑)
|
||||
revealedKeys.value.clear()
|
||||
}
|
||||
})
|
||||
|
||||
@@ -880,6 +930,43 @@ function handleConfigKeyModels(key: EndpointAPIKey) {
|
||||
keyAllowedModelsDialogOpen.value = true
|
||||
}
|
||||
|
||||
// 切换密钥显示/隐藏
|
||||
async function toggleKeyReveal(key: EndpointAPIKey) {
|
||||
if (revealedKeys.value.has(key.id)) {
|
||||
// 已显示,隐藏它
|
||||
revealedKeys.value.delete(key.id)
|
||||
return
|
||||
}
|
||||
|
||||
// 未显示,调用 API 获取完整密钥
|
||||
revealingKeyId.value = key.id
|
||||
try {
|
||||
const result = await revealEndpointKey(key.id)
|
||||
revealedKeys.value.set(key.id, result.api_key)
|
||||
} catch (err: any) {
|
||||
showError(err.response?.data?.detail || '获取密钥失败', '错误')
|
||||
} finally {
|
||||
revealingKeyId.value = null
|
||||
}
|
||||
}
|
||||
|
||||
// 复制完整密钥
|
||||
async function copyFullKey(key: EndpointAPIKey) {
|
||||
// 如果已经显示了,直接复制
|
||||
if (revealedKeys.value.has(key.id)) {
|
||||
copyToClipboard(revealedKeys.value.get(key.id)!)
|
||||
return
|
||||
}
|
||||
|
||||
// 否则先获取再复制
|
||||
try {
|
||||
const result = await revealEndpointKey(key.id)
|
||||
copyToClipboard(result.api_key)
|
||||
} catch (err: any) {
|
||||
showError(err.response?.data?.detail || '获取密钥失败', '错误')
|
||||
}
|
||||
}
|
||||
|
||||
function handleDeleteKey(key: EndpointAPIKey) {
|
||||
keyToDelete.value = key
|
||||
deleteKeyConfirmOpen.value = true
|
||||
@@ -1244,16 +1331,6 @@ function getHealthScoreBarColor(score: number): string {
|
||||
return 'bg-red-500 dark:bg-red-400'
|
||||
}
|
||||
|
||||
// 复制到剪贴板
|
||||
async function copyToClipboard(text: string) {
|
||||
try {
|
||||
await navigator.clipboard.writeText(text)
|
||||
showSuccess('已复制到剪贴板')
|
||||
} catch {
|
||||
showError('复制失败', '错误')
|
||||
}
|
||||
}
|
||||
|
||||
// 加载 Provider 信息
|
||||
async function loadProvider() {
|
||||
if (!props.providerId) return
|
||||
|
||||
@@ -110,8 +110,9 @@
|
||||
<div
|
||||
v-for="mapping in group.aliases"
|
||||
:key="mapping.name"
|
||||
class="flex items-center gap-2 py-1"
|
||||
class="flex items-center justify-between gap-2 py-1"
|
||||
>
|
||||
<div class="flex items-center gap-2 flex-1 min-w-0">
|
||||
<!-- 优先级标签 -->
|
||||
<span class="inline-flex items-center justify-center w-5 h-5 rounded bg-background border text-xs font-medium shrink-0">
|
||||
{{ mapping.priority }}
|
||||
@@ -121,6 +122,25 @@
|
||||
{{ mapping.name }}
|
||||
</span>
|
||||
</div>
|
||||
<!-- 测试按钮 -->
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
class="h-7 w-7 shrink-0"
|
||||
title="测试映射"
|
||||
:disabled="testingMapping === `${group.model.id}-${group.apiFormatsKey}-${mapping.name}`"
|
||||
@click="testMapping(group, mapping)"
|
||||
>
|
||||
<Loader2
|
||||
v-if="testingMapping === `${group.model.id}-${group.apiFormatsKey}-${mapping.name}`"
|
||||
class="w-3 h-3 animate-spin"
|
||||
/>
|
||||
<Play
|
||||
v-else
|
||||
class="w-3 h-3"
|
||||
/>
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -166,18 +186,20 @@
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, computed, onMounted, watch } from 'vue'
|
||||
import { Tag, Plus, Edit, Trash2, ChevronRight } from 'lucide-vue-next'
|
||||
import { Tag, Plus, Edit, Trash2, ChevronRight, Loader2, Play } from 'lucide-vue-next'
|
||||
import { Card, Button, Badge } from '@/components/ui'
|
||||
import AlertDialog from '@/components/common/AlertDialog.vue'
|
||||
import ModelMappingDialog, { type AliasGroup } from '../ModelMappingDialog.vue'
|
||||
import { useToast } from '@/composables/useToast'
|
||||
import {
|
||||
getProviderModels,
|
||||
testModel,
|
||||
API_FORMAT_LABELS,
|
||||
type Model,
|
||||
type ProviderModelAlias
|
||||
} from '@/api/endpoints'
|
||||
import { updateModel } from '@/api/endpoints/models'
|
||||
import { parseTestModelError } from '@/utils/errorParser'
|
||||
|
||||
const props = defineProps<{
|
||||
provider: any
|
||||
@@ -196,6 +218,7 @@ const dialogOpen = ref(false)
|
||||
const deleteConfirmOpen = ref(false)
|
||||
const editingGroup = ref<AliasGroup | null>(null)
|
||||
const deletingGroup = ref<AliasGroup | null>(null)
|
||||
const testingMapping = ref<string | null>(null)
|
||||
|
||||
// 列表展开状态
|
||||
const expandedAliasGroups = ref<Set<string>>(new Set())
|
||||
@@ -337,6 +360,49 @@ async function onDialogSaved() {
|
||||
emit('refresh')
|
||||
}
|
||||
|
||||
// 测试模型映射
|
||||
async function testMapping(group: any, mapping: any) {
|
||||
const testingKey = `${group.model.id}-${group.apiFormatsKey}-${mapping.name}`
|
||||
testingMapping.value = testingKey
|
||||
|
||||
try {
|
||||
// 根据分组的 API 格式来确定应该使用的格式
|
||||
let apiFormat = null
|
||||
if (group.apiFormats.length === 1) {
|
||||
apiFormat = group.apiFormats[0]
|
||||
} else if (group.apiFormats.length === 0) {
|
||||
// 如果没有指定格式,但分组显示为"全部",则使用模型的默认格式
|
||||
apiFormat = group.model.effective_api_format || group.model.api_format
|
||||
}
|
||||
|
||||
const result = await testModel({
|
||||
provider_id: props.provider.id,
|
||||
model_name: mapping.name, // 使用映射名称进行测试
|
||||
message: "hello",
|
||||
api_format: apiFormat
|
||||
})
|
||||
|
||||
if (result.success) {
|
||||
showSuccess(`映射 "${mapping.name}" 测试成功`)
|
||||
|
||||
// 如果有响应内容,可以显示更多信息
|
||||
if (result.data?.response?.choices?.[0]?.message?.content) {
|
||||
const content = result.data.response.choices[0].message.content
|
||||
showSuccess(`测试成功,响应: ${content.substring(0, 100)}${content.length > 100 ? '...' : ''}`)
|
||||
} else if (result.data?.content_preview) {
|
||||
showSuccess(`流式测试成功,预览: ${result.data.content_preview}`)
|
||||
}
|
||||
} else {
|
||||
showError(`映射测试失败: ${parseTestModelError(result)}`)
|
||||
}
|
||||
} catch (err: any) {
|
||||
const errorMsg = err.response?.data?.detail || err.message || '测试请求失败'
|
||||
showError(`映射测试失败: ${errorMsg}`)
|
||||
} finally {
|
||||
testingMapping.value = null
|
||||
}
|
||||
}
|
||||
|
||||
// 监听 provider 变化
|
||||
watch(() => props.provider?.id, (newId) => {
|
||||
if (newId) {
|
||||
@@ -349,4 +415,9 @@ onMounted(() => {
|
||||
loadModels()
|
||||
}
|
||||
})
|
||||
|
||||
// 暴露给父组件,用于检测是否有弹窗打开
|
||||
defineExpose({
|
||||
dialogOpen: computed(() => dialogOpen.value || deleteConfirmOpen.value)
|
||||
})
|
||||
</script>
|
||||
|
||||
@@ -213,6 +213,7 @@ import { Box, Edit, Trash2, Layers, Eye, Wrench, Zap, Brain, Power, Copy, Image
|
||||
import Card from '@/components/ui/card.vue'
|
||||
import Button from '@/components/ui/button.vue'
|
||||
import { useToast } from '@/composables/useToast'
|
||||
import { useClipboard } from '@/composables/useClipboard'
|
||||
import { getProviderModels, type Model } from '@/api/endpoints'
|
||||
import { updateModel } from '@/api/endpoints/models'
|
||||
|
||||
@@ -227,6 +228,7 @@ const emit = defineEmits<{
|
||||
}>()
|
||||
|
||||
const { error: showError, success: showSuccess } = useToast()
|
||||
const { copyToClipboard } = useClipboard()
|
||||
|
||||
// 状态
|
||||
const loading = ref(false)
|
||||
@@ -244,12 +246,7 @@ const sortedModels = computed(() => {
|
||||
|
||||
// 复制模型 ID 到剪贴板
|
||||
async function copyModelId(modelId: string) {
|
||||
try {
|
||||
await navigator.clipboard.writeText(modelId)
|
||||
showSuccess('已复制到剪贴板')
|
||||
} catch {
|
||||
showError('复制失败', '错误')
|
||||
}
|
||||
await copyToClipboard(modelId)
|
||||
}
|
||||
|
||||
// 加载模型
|
||||
|
||||
@@ -18,8 +18,22 @@
|
||||
<span class="flex-shrink-0">多</span>
|
||||
</div>
|
||||
</div>
|
||||
<div
|
||||
v-if="isLoading"
|
||||
class="h-full min-h-[160px] flex items-center justify-center text-sm text-muted-foreground"
|
||||
>
|
||||
<Loader2 class="h-5 w-5 animate-spin mr-2" />
|
||||
加载中...
|
||||
</div>
|
||||
<div
|
||||
v-else-if="hasError"
|
||||
class="h-full min-h-[160px] flex items-center justify-center text-sm text-destructive"
|
||||
>
|
||||
<AlertCircle class="h-4 w-4 mr-1.5" />
|
||||
加载失败
|
||||
</div>
|
||||
<ActivityHeatmap
|
||||
v-if="hasData"
|
||||
v-else-if="hasData"
|
||||
:data="data"
|
||||
:show-header="false"
|
||||
/>
|
||||
@@ -34,6 +48,7 @@
|
||||
|
||||
<script setup lang="ts">
|
||||
import { computed } from 'vue'
|
||||
import { Loader2, AlertCircle } from 'lucide-vue-next'
|
||||
import Card from '@/components/ui/card.vue'
|
||||
import ActivityHeatmap from '@/components/stats/ActivityHeatmap.vue'
|
||||
import type { ActivityHeatmap as ActivityHeatmapData } from '@/types/activity'
|
||||
@@ -41,6 +56,8 @@ import type { ActivityHeatmap as ActivityHeatmapData } from '@/types/activity'
|
||||
const props = defineProps<{
|
||||
data: ActivityHeatmapData | null
|
||||
title: string
|
||||
isLoading?: boolean
|
||||
hasError?: boolean
|
||||
}>()
|
||||
|
||||
const legendLevels = [0.08, 0.25, 0.45, 0.65, 0.85]
|
||||
|
||||
@@ -473,6 +473,7 @@
|
||||
import { ref, watch, computed } from 'vue'
|
||||
import Button from '@/components/ui/button.vue'
|
||||
import { useEscapeKey } from '@/composables/useEscapeKey'
|
||||
import { useClipboard } from '@/composables/useClipboard'
|
||||
import Card from '@/components/ui/card.vue'
|
||||
import Badge from '@/components/ui/badge.vue'
|
||||
import Separator from '@/components/ui/separator.vue'
|
||||
@@ -505,6 +506,7 @@ const copiedStates = ref<Record<string, boolean>>({})
|
||||
const viewMode = ref<'compare' | 'formatted' | 'raw'>('compare')
|
||||
const currentExpandDepth = ref(1)
|
||||
const dataSource = ref<'client' | 'provider'>('client')
|
||||
const { copyToClipboard } = useClipboard()
|
||||
const historicalPricing = ref<{
|
||||
input_price: string
|
||||
output_price: string
|
||||
@@ -784,7 +786,7 @@ function copyJsonToClipboard(tabName: string) {
|
||||
}
|
||||
|
||||
if (data) {
|
||||
navigator.clipboard.writeText(JSON.stringify(data, null, 2))
|
||||
copyToClipboard(JSON.stringify(data, null, 2), false)
|
||||
copiedStates.value[tabName] = true
|
||||
setTimeout(() => {
|
||||
copiedStates.value[tabName] = false
|
||||
|
||||
@@ -32,6 +32,17 @@
|
||||
<!-- 分隔线 -->
|
||||
<div class="hidden sm:block h-4 w-px bg-border" />
|
||||
|
||||
<!-- 通用搜索 -->
|
||||
<div class="relative">
|
||||
<Search class="absolute left-2.5 top-1/2 -translate-y-1/2 h-3.5 w-3.5 text-muted-foreground z-10 pointer-events-none" />
|
||||
<Input
|
||||
id="usage-records-search"
|
||||
v-model="localSearch"
|
||||
:placeholder="isAdmin ? '搜索用户/密钥/模型/提供商' : '搜索密钥/模型'"
|
||||
class="w-32 sm:w-48 h-8 text-xs border-border/60 pl-8"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<!-- 用户筛选(仅管理员可见) -->
|
||||
<Select
|
||||
v-if="isAdmin && availableUsers.length > 0"
|
||||
@@ -164,6 +175,12 @@
|
||||
>
|
||||
用户
|
||||
</TableHead>
|
||||
<TableHead
|
||||
v-if="!isAdmin"
|
||||
class="h-12 font-semibold w-[100px]"
|
||||
>
|
||||
密钥
|
||||
</TableHead>
|
||||
<TableHead class="h-12 font-semibold w-[140px]">
|
||||
模型
|
||||
</TableHead>
|
||||
@@ -196,7 +213,7 @@
|
||||
<TableBody>
|
||||
<TableRow v-if="records.length === 0">
|
||||
<TableCell
|
||||
:colspan="isAdmin ? 9 : 7"
|
||||
:colspan="isAdmin ? 9 : 8"
|
||||
class="text-center py-12 text-muted-foreground"
|
||||
>
|
||||
暂无请求记录
|
||||
@@ -218,7 +235,34 @@
|
||||
class="py-4 w-[100px] truncate"
|
||||
:title="record.username || record.user_email || (record.user_id ? `User ${record.user_id}` : '已删除用户')"
|
||||
>
|
||||
<div class="flex flex-col text-xs gap-0.5">
|
||||
<span class="truncate">
|
||||
{{ record.username || record.user_email || (record.user_id ? `User ${record.user_id}` : '已删除用户') }}
|
||||
</span>
|
||||
<span
|
||||
v-if="record.api_key?.name"
|
||||
class="text-muted-foreground truncate"
|
||||
:title="record.api_key.name"
|
||||
>
|
||||
{{ record.api_key.name }}
|
||||
</span>
|
||||
</div>
|
||||
</TableCell>
|
||||
<!-- 用户页面的密钥列 -->
|
||||
<TableCell
|
||||
v-if="!isAdmin"
|
||||
class="py-4 w-[100px]"
|
||||
:title="record.api_key?.name || '-'"
|
||||
>
|
||||
<div class="flex flex-col text-xs gap-0.5">
|
||||
<span class="truncate">{{ record.api_key?.name || '-' }}</span>
|
||||
<span
|
||||
v-if="record.api_key?.display"
|
||||
class="text-muted-foreground truncate"
|
||||
>
|
||||
{{ record.api_key.display }}
|
||||
</span>
|
||||
</div>
|
||||
</TableCell>
|
||||
<TableCell
|
||||
class="font-medium py-4 w-[140px]"
|
||||
@@ -438,6 +482,7 @@ import {
|
||||
TableCard,
|
||||
Badge,
|
||||
Button,
|
||||
Input,
|
||||
Select,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
@@ -451,7 +496,7 @@ import {
|
||||
TableCell,
|
||||
Pagination,
|
||||
} from '@/components/ui'
|
||||
import { RefreshCcw } from 'lucide-vue-next'
|
||||
import { RefreshCcw, Search } from 'lucide-vue-next'
|
||||
import { formatTokens, formatCurrency } from '@/utils/format'
|
||||
import { formatDateTime } from '../composables'
|
||||
import { useRowClick } from '@/composables/useRowClick'
|
||||
@@ -471,6 +516,7 @@ const props = defineProps<{
|
||||
// 时间段
|
||||
selectedPeriod: string
|
||||
// 筛选
|
||||
filterSearch: string
|
||||
filterUser: string
|
||||
filterModel: string
|
||||
filterProvider: string
|
||||
@@ -489,6 +535,7 @@ const props = defineProps<{
|
||||
|
||||
const emit = defineEmits<{
|
||||
'update:selectedPeriod': [value: string]
|
||||
'update:filterSearch': [value: string]
|
||||
'update:filterUser': [value: string]
|
||||
'update:filterModel': [value: string]
|
||||
'update:filterProvider': [value: string]
|
||||
@@ -507,6 +554,23 @@ const filterModelSelectOpen = ref(false)
|
||||
const filterProviderSelectOpen = ref(false)
|
||||
const filterStatusSelectOpen = ref(false)
|
||||
|
||||
// 通用搜索(输入防抖)
|
||||
const localSearch = ref(props.filterSearch)
|
||||
let searchDebounceTimer: ReturnType<typeof setTimeout> | null = null
|
||||
|
||||
watch(() => props.filterSearch, (value) => {
|
||||
if (value !== localSearch.value) {
|
||||
localSearch.value = value
|
||||
}
|
||||
})
|
||||
|
||||
watch(localSearch, (value) => {
|
||||
if (searchDebounceTimer) clearTimeout(searchDebounceTimer)
|
||||
searchDebounceTimer = setTimeout(() => {
|
||||
emit('update:filterSearch', value)
|
||||
}, 300)
|
||||
})
|
||||
|
||||
// 动态计时器相关
|
||||
const now = ref(Date.now())
|
||||
let timerInterval: ReturnType<typeof setInterval> | null = null
|
||||
@@ -574,6 +638,10 @@ function handleRowClick(event: MouseEvent, id: string) {
|
||||
// 组件卸载时清理
|
||||
onUnmounted(() => {
|
||||
stopTimer()
|
||||
if (searchDebounceTimer) {
|
||||
clearTimeout(searchDebounceTimer)
|
||||
searchDebounceTimer = null
|
||||
}
|
||||
})
|
||||
|
||||
// 格式化 API 格式显示名称
|
||||
|
||||
@@ -23,6 +23,7 @@ export interface PaginationParams {
|
||||
}
|
||||
|
||||
export interface FilterParams {
|
||||
search?: string
|
||||
user_id?: string
|
||||
model?: string
|
||||
provider?: string
|
||||
@@ -64,9 +65,6 @@ export function useUsageData(options: UseUsageDataOptions) {
|
||||
}))
|
||||
})
|
||||
|
||||
// 活跃度热图数据
|
||||
const activityHeatmapData = computed(() => stats.value.activity_heatmap)
|
||||
|
||||
// 加载统计数据(不加载记录)
|
||||
async function loadStats(dateRange?: DateRangeParams) {
|
||||
isLoadingStats.value = true
|
||||
@@ -93,7 +91,7 @@ export function useUsageData(options: UseUsageDataOptions) {
|
||||
cache_stats: (statsData as any).cache_stats,
|
||||
period_start: '',
|
||||
period_end: '',
|
||||
activity_heatmap: statsData.activity_heatmap || null
|
||||
activity_heatmap: null
|
||||
}
|
||||
|
||||
modelStats.value = modelData.map(item => ({
|
||||
@@ -143,7 +141,7 @@ export function useUsageData(options: UseUsageDataOptions) {
|
||||
avg_response_time: userData.avg_response_time || 0,
|
||||
period_start: '',
|
||||
period_end: '',
|
||||
activity_heatmap: userData.activity_heatmap || null
|
||||
activity_heatmap: null
|
||||
}
|
||||
|
||||
modelStats.value = (userData.summary_by_model || []).map((item: any) => ({
|
||||
@@ -237,11 +235,6 @@ export function useUsageData(options: UseUsageDataOptions) {
|
||||
pagination: PaginationParams,
|
||||
filters?: FilterParams
|
||||
): Promise<void> {
|
||||
if (!isAdminPage.value) {
|
||||
// 用户页面不需要分页加载,记录已在 loadStats 中获取
|
||||
return
|
||||
}
|
||||
|
||||
isLoadingRecords.value = true
|
||||
|
||||
try {
|
||||
@@ -255,6 +248,12 @@ export function useUsageData(options: UseUsageDataOptions) {
|
||||
}
|
||||
|
||||
// 添加筛选条件
|
||||
if (filters?.search?.trim()) {
|
||||
params.search = filters.search.trim()
|
||||
}
|
||||
|
||||
if (isAdminPage.value) {
|
||||
// 管理员页面:使用管理员 API
|
||||
if (filters?.user_id) {
|
||||
params.user_id = filters.user_id
|
||||
}
|
||||
@@ -269,10 +268,14 @@ export function useUsageData(options: UseUsageDataOptions) {
|
||||
}
|
||||
|
||||
const response = await usageApi.getAllUsageRecords(params)
|
||||
|
||||
currentRecords.value = (response.records || []) as UsageRecord[]
|
||||
totalRecords.value = response.total || 0
|
||||
|
||||
} else {
|
||||
// 用户页面:使用用户 API
|
||||
const userData = await meApi.getUsage(params)
|
||||
currentRecords.value = (userData.records || []) as UsageRecord[]
|
||||
totalRecords.value = userData.pagination?.total || currentRecords.value.length
|
||||
}
|
||||
} catch (error) {
|
||||
log.error('加载记录失败:', error)
|
||||
currentRecords.value = []
|
||||
@@ -305,7 +308,6 @@ export function useUsageData(options: UseUsageDataOptions) {
|
||||
|
||||
// 计算属性
|
||||
enhancedModelStats,
|
||||
activityHeatmapData,
|
||||
|
||||
// 方法
|
||||
loadStats,
|
||||
|
||||
@@ -1,5 +1,3 @@
|
||||
import type { ActivityHeatmap } from '@/types/activity'
|
||||
|
||||
// 统计数据状态
|
||||
export interface UsageStatsState {
|
||||
total_requests: number
|
||||
@@ -17,7 +15,6 @@ export interface UsageStatsState {
|
||||
}
|
||||
period_start: string
|
||||
period_end: string
|
||||
activity_heatmap: ActivityHeatmap | null
|
||||
}
|
||||
|
||||
// 模型统计
|
||||
@@ -64,6 +61,11 @@ export interface UsageRecord {
|
||||
user_id?: string
|
||||
username?: string
|
||||
user_email?: string
|
||||
api_key?: {
|
||||
id: string | null
|
||||
name: string | null
|
||||
display: string | null
|
||||
} | null
|
||||
provider: string
|
||||
api_key_name?: string
|
||||
rate_multiplier?: number
|
||||
@@ -115,7 +117,6 @@ export function createDefaultStats(): UsageStatsState {
|
||||
error_rate: undefined,
|
||||
cache_stats: undefined,
|
||||
period_start: '',
|
||||
period_end: '',
|
||||
activity_heatmap: null
|
||||
period_end: ''
|
||||
}
|
||||
}
|
||||
|
||||
@@ -86,6 +86,34 @@
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div
|
||||
v-if="isEditMode && form.password.length > 0"
|
||||
class="space-y-2"
|
||||
>
|
||||
<Label class="text-sm font-medium">
|
||||
确认新密码 <span class="text-muted-foreground">*</span>
|
||||
</Label>
|
||||
<Input
|
||||
:id="`pwd-confirm-${formNonce}`"
|
||||
v-model="form.confirmPassword"
|
||||
type="password"
|
||||
autocomplete="new-password"
|
||||
data-form-type="other"
|
||||
data-lpignore="true"
|
||||
:name="`confirm-${formNonce}`"
|
||||
required
|
||||
minlength="6"
|
||||
placeholder="再次输入新密码"
|
||||
class="h-10"
|
||||
/>
|
||||
<p
|
||||
v-if="form.confirmPassword.length > 0 && form.password !== form.confirmPassword"
|
||||
class="text-xs text-destructive"
|
||||
>
|
||||
两次输入的密码不一致
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div class="space-y-2">
|
||||
<Label
|
||||
for="form-email"
|
||||
@@ -288,55 +316,10 @@
|
||||
</div>
|
||||
|
||||
<!-- 模型多选下拉框 -->
|
||||
<div class="space-y-2">
|
||||
<Label class="text-sm font-medium">允许的模型</Label>
|
||||
<div class="relative">
|
||||
<button
|
||||
type="button"
|
||||
class="w-full h-10 px-3 border rounded-lg bg-background text-left flex items-center justify-between hover:bg-muted/50 transition-colors"
|
||||
@click="modelDropdownOpen = !modelDropdownOpen"
|
||||
>
|
||||
<span :class="form.allowed_models.length ? 'text-foreground' : 'text-muted-foreground'">
|
||||
{{ form.allowed_models.length ? `已选择 ${form.allowed_models.length} 个` : '全部可用' }}
|
||||
</span>
|
||||
<ChevronDown
|
||||
class="h-4 w-4 text-muted-foreground transition-transform"
|
||||
:class="modelDropdownOpen ? 'rotate-180' : ''"
|
||||
<ModelMultiSelect
|
||||
v-model="form.allowed_models"
|
||||
:models="globalModels"
|
||||
/>
|
||||
</button>
|
||||
<div
|
||||
v-if="modelDropdownOpen"
|
||||
class="fixed inset-0 z-[80]"
|
||||
@click.stop="modelDropdownOpen = false"
|
||||
/>
|
||||
<div
|
||||
v-if="modelDropdownOpen"
|
||||
class="absolute z-[90] w-full mt-1 bg-popover border rounded-lg shadow-lg max-h-48 overflow-y-auto"
|
||||
>
|
||||
<div
|
||||
v-for="model in globalModels"
|
||||
:key="model.name"
|
||||
class="flex items-center gap-2 px-3 py-2 hover:bg-muted/50 cursor-pointer"
|
||||
@click="toggleSelection('allowed_models', model.name)"
|
||||
>
|
||||
<input
|
||||
type="checkbox"
|
||||
:checked="form.allowed_models.includes(model.name)"
|
||||
class="h-4 w-4 rounded border-gray-300 cursor-pointer"
|
||||
@click.stop
|
||||
@change="toggleSelection('allowed_models', model.name)"
|
||||
>
|
||||
<span class="text-sm">{{ model.name }}</span>
|
||||
</div>
|
||||
<div
|
||||
v-if="globalModels.length === 0"
|
||||
class="px-3 py-2 text-sm text-muted-foreground"
|
||||
>
|
||||
暂无可用模型
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</form>
|
||||
@@ -376,10 +359,12 @@ import {
|
||||
} from '@/components/ui'
|
||||
import { UserPlus, SquarePen, ChevronDown } from 'lucide-vue-next'
|
||||
import { useFormDialog } from '@/composables/useFormDialog'
|
||||
import { ModelMultiSelect } from '@/components/common'
|
||||
import { getProvidersSummary } from '@/api/endpoints/providers'
|
||||
import { getGlobalModels } from '@/api/global-models'
|
||||
import { adminApi } from '@/api/admin'
|
||||
import { log } from '@/utils/logger'
|
||||
import type { ProviderWithEndpointsSummary, GlobalModelResponse } from '@/api/endpoints/types'
|
||||
|
||||
export interface UserFormData {
|
||||
id?: string
|
||||
@@ -412,17 +397,17 @@ const roleSelectOpen = ref(false)
|
||||
// 下拉框状态
|
||||
const providerDropdownOpen = ref(false)
|
||||
const endpointDropdownOpen = ref(false)
|
||||
const modelDropdownOpen = ref(false)
|
||||
|
||||
// 选项数据
|
||||
const providers = ref<any[]>([])
|
||||
const globalModels = ref<any[]>([])
|
||||
const providers = ref<ProviderWithEndpointsSummary[]>([])
|
||||
const globalModels = ref<GlobalModelResponse[]>([])
|
||||
const apiFormats = ref<Array<{ value: string; label: string }>>([])
|
||||
|
||||
// 表单数据
|
||||
const form = ref({
|
||||
username: '',
|
||||
password: '',
|
||||
confirmPassword: '',
|
||||
email: '',
|
||||
quota: 10,
|
||||
role: 'user' as 'admin' | 'user',
|
||||
@@ -443,6 +428,7 @@ function resetForm() {
|
||||
form.value = {
|
||||
username: '',
|
||||
password: '',
|
||||
confirmPassword: '',
|
||||
email: '',
|
||||
quota: 10,
|
||||
role: 'user',
|
||||
@@ -461,6 +447,7 @@ function loadUserData() {
|
||||
form.value = {
|
||||
username: props.user.username,
|
||||
password: '',
|
||||
confirmPassword: '',
|
||||
email: props.user.email || '',
|
||||
quota: props.user.quota_usd == null ? 10 : props.user.quota_usd,
|
||||
role: props.user.role,
|
||||
@@ -486,7 +473,9 @@ const isFormValid = computed(() => {
|
||||
const hasUsername = form.value.username.trim().length > 0
|
||||
const hasEmail = form.value.email.trim().length > 0
|
||||
const hasPassword = isEditMode.value || form.value.password.length >= 6
|
||||
return hasUsername && hasEmail && hasPassword
|
||||
// 编辑模式下如果填写了密码,必须确认密码一致
|
||||
const passwordConfirmed = !isEditMode.value || form.value.password.length === 0 || form.value.password === form.value.confirmPassword
|
||||
return hasUsername && hasEmail && hasPassword && passwordConfirmed
|
||||
})
|
||||
|
||||
// 加载访问控制选项
|
||||
|
||||
@@ -320,6 +320,7 @@ import {
|
||||
Megaphone,
|
||||
Menu,
|
||||
X,
|
||||
Mail,
|
||||
} from 'lucide-vue-next'
|
||||
|
||||
const router = useRouter()
|
||||
@@ -421,6 +422,7 @@ const navigation = computed(() => {
|
||||
{ name: '缓存监控', href: '/admin/cache-monitoring', icon: Gauge },
|
||||
{ name: 'IP 安全', href: '/admin/ip-security', icon: Shield },
|
||||
{ name: '审计日志', href: '/admin/audit-logs', icon: AlertTriangle },
|
||||
{ name: '邮件配置', href: '/admin/email', icon: Mail },
|
||||
{ name: '系统设置', href: '/admin/system', icon: Cog },
|
||||
]
|
||||
}
|
||||
|
||||
@@ -5,7 +5,7 @@
|
||||
|
||||
import type { User, LoginResponse } from '@/api/auth'
|
||||
import type { DashboardStatsResponse, RecentRequest, ProviderStatus, DailyStatsResponse } from '@/api/dashboard'
|
||||
import type { User as AdminUser, ApiKey } from '@/api/users'
|
||||
import type { User as AdminUser } from '@/api/users'
|
||||
import type { AdminApiKeysResponse } from '@/api/admin'
|
||||
import type { Profile, UsageResponse } from '@/api/me'
|
||||
import type { ProviderWithEndpointsSummary, GlobalModelResponse } from '@/api/endpoints/types'
|
||||
@@ -185,18 +185,20 @@ export const MOCK_DASHBOARD_STATS: DashboardStatsResponse = {
|
||||
output: 700000,
|
||||
cache_creation: 50000,
|
||||
cache_read: 200000
|
||||
}
|
||||
},
|
||||
// 普通用户专用字段
|
||||
monthly_cost: 45.67
|
||||
}
|
||||
|
||||
export const MOCK_RECENT_REQUESTS: RecentRequest[] = [
|
||||
{ id: 'req-001', user: 'alice', model: 'claude-sonnet-4-20250514', tokens: 15234, time: '2 分钟前' },
|
||||
{ id: 'req-002', user: 'bob', model: 'gpt-4o', tokens: 8765, time: '5 分钟前' },
|
||||
{ id: 'req-003', user: 'charlie', model: 'claude-opus-4-20250514', tokens: 32100, time: '8 分钟前' },
|
||||
{ id: 'req-004', user: 'diana', model: 'gemini-2.0-flash', tokens: 4521, time: '12 分钟前' },
|
||||
{ id: 'req-005', user: 'eve', model: 'claude-sonnet-4-20250514', tokens: 9876, time: '15 分钟前' },
|
||||
{ id: 'req-006', user: 'frank', model: 'gpt-4o-mini', tokens: 2345, time: '18 分钟前' },
|
||||
{ id: 'req-007', user: 'grace', model: 'claude-haiku-3-5-20241022', tokens: 6789, time: '22 分钟前' },
|
||||
{ id: 'req-008', user: 'henry', model: 'gemini-2.5-pro', tokens: 12345, time: '25 分钟前' }
|
||||
{ id: 'req-001', user: 'alice', model: 'claude-sonnet-4-5-20250929', tokens: 15234, time: '2 分钟前' },
|
||||
{ id: 'req-002', user: 'bob', model: 'gpt-5.1', tokens: 8765, time: '5 分钟前' },
|
||||
{ id: 'req-003', user: 'charlie', model: 'claude-opus-4-5-20251101', tokens: 32100, time: '8 分钟前' },
|
||||
{ id: 'req-004', user: 'diana', model: 'gemini-3-pro-preview', tokens: 4521, time: '12 分钟前' },
|
||||
{ id: 'req-005', user: 'eve', model: 'claude-sonnet-4-5-20250929', tokens: 9876, time: '15 分钟前' },
|
||||
{ id: 'req-006', user: 'frank', model: 'gpt-5.1-codex-mini', tokens: 2345, time: '18 分钟前' },
|
||||
{ id: 'req-007', user: 'grace', model: 'claude-haiku-4-5-20251001', tokens: 6789, time: '22 分钟前' },
|
||||
{ id: 'req-008', user: 'henry', model: 'gemini-3-pro-preview', tokens: 12345, time: '25 分钟前' }
|
||||
]
|
||||
|
||||
export const MOCK_PROVIDER_STATUS: ProviderStatus[] = [
|
||||
@@ -231,11 +233,11 @@ function generateDailyStats(): DailyStatsResponse {
|
||||
unique_models: 8 + Math.floor(Math.random() * 5),
|
||||
unique_providers: 4 + Math.floor(Math.random() * 3),
|
||||
model_breakdown: [
|
||||
{ model: 'claude-sonnet-4-20250514', requests: Math.floor(baseRequests * 0.35), tokens: Math.floor(baseTokens * 0.35), cost: Number((baseCost * 0.35).toFixed(2)) },
|
||||
{ model: 'gpt-4o', requests: Math.floor(baseRequests * 0.25), tokens: Math.floor(baseTokens * 0.25), cost: Number((baseCost * 0.25).toFixed(2)) },
|
||||
{ model: 'claude-opus-4-20250514', requests: Math.floor(baseRequests * 0.15), tokens: Math.floor(baseTokens * 0.15), cost: Number((baseCost * 0.20).toFixed(2)) },
|
||||
{ model: 'gemini-2.0-flash', requests: Math.floor(baseRequests * 0.15), tokens: Math.floor(baseTokens * 0.15), cost: Number((baseCost * 0.10).toFixed(2)) },
|
||||
{ model: 'claude-haiku-3-5-20241022', requests: Math.floor(baseRequests * 0.10), tokens: Math.floor(baseTokens * 0.10), cost: Number((baseCost * 0.10).toFixed(2)) }
|
||||
{ model: 'claude-sonnet-4-5-20250929', requests: Math.floor(baseRequests * 0.35), tokens: Math.floor(baseTokens * 0.35), cost: Number((baseCost * 0.35).toFixed(2)) },
|
||||
{ model: 'gpt-5.1', requests: Math.floor(baseRequests * 0.25), tokens: Math.floor(baseTokens * 0.25), cost: Number((baseCost * 0.25).toFixed(2)) },
|
||||
{ model: 'claude-opus-4-5-20251101', requests: Math.floor(baseRequests * 0.15), tokens: Math.floor(baseTokens * 0.15), cost: Number((baseCost * 0.20).toFixed(2)) },
|
||||
{ model: 'gemini-3-pro-preview', requests: Math.floor(baseRequests * 0.15), tokens: Math.floor(baseTokens * 0.15), cost: Number((baseCost * 0.10).toFixed(2)) },
|
||||
{ model: 'claude-haiku-4-5-20251001', requests: Math.floor(baseRequests * 0.10), tokens: Math.floor(baseTokens * 0.10), cost: Number((baseCost * 0.10).toFixed(2)) }
|
||||
]
|
||||
})
|
||||
}
|
||||
@@ -243,11 +245,11 @@ function generateDailyStats(): DailyStatsResponse {
|
||||
return {
|
||||
daily_stats: dailyStats,
|
||||
model_summary: [
|
||||
{ model: 'claude-sonnet-4-20250514', requests: 2456, tokens: 8500000, cost: 125.45, avg_response_time: 1.2, cost_per_request: 0.051, tokens_per_request: 3461 },
|
||||
{ model: 'gpt-4o', requests: 1823, tokens: 6200000, cost: 98.32, avg_response_time: 0.9, cost_per_request: 0.054, tokens_per_request: 3401 },
|
||||
{ model: 'claude-opus-4-20250514', requests: 987, tokens: 4100000, cost: 156.78, avg_response_time: 2.1, cost_per_request: 0.159, tokens_per_request: 4154 },
|
||||
{ model: 'gemini-2.0-flash', requests: 1234, tokens: 3800000, cost: 28.56, avg_response_time: 0.6, cost_per_request: 0.023, tokens_per_request: 3079 },
|
||||
{ model: 'claude-haiku-3-5-20241022', requests: 2100, tokens: 5200000, cost: 32.10, avg_response_time: 0.5, cost_per_request: 0.015, tokens_per_request: 2476 }
|
||||
{ model: 'claude-sonnet-4-5-20250929', requests: 2456, tokens: 8500000, cost: 125.45, avg_response_time: 1.2, cost_per_request: 0.051, tokens_per_request: 3461 },
|
||||
{ model: 'gpt-5.1', requests: 1823, tokens: 6200000, cost: 98.32, avg_response_time: 0.9, cost_per_request: 0.054, tokens_per_request: 3401 },
|
||||
{ model: 'claude-opus-4-5-20251101', requests: 987, tokens: 4100000, cost: 156.78, avg_response_time: 2.1, cost_per_request: 0.159, tokens_per_request: 4154 },
|
||||
{ model: 'gemini-3-pro-preview', requests: 1234, tokens: 3800000, cost: 28.56, avg_response_time: 0.6, cost_per_request: 0.023, tokens_per_request: 3079 },
|
||||
{ model: 'claude-haiku-4-5-20251001', requests: 2100, tokens: 5200000, cost: 32.10, avg_response_time: 0.5, cost_per_request: 0.015, tokens_per_request: 2476 }
|
||||
],
|
||||
period: {
|
||||
start_date: dailyStats[0].date,
|
||||
@@ -336,7 +338,7 @@ export const MOCK_ALL_USERS: AdminUser[] = [
|
||||
|
||||
// ========== API Key 数据 ==========
|
||||
|
||||
export const MOCK_USER_API_KEYS: ApiKey[] = [
|
||||
export const MOCK_USER_API_KEYS = [
|
||||
{
|
||||
id: 'key-uuid-001',
|
||||
key_display: 'sk-ae...x7f9',
|
||||
@@ -346,7 +348,8 @@ export const MOCK_USER_API_KEYS: ApiKey[] = [
|
||||
is_active: true,
|
||||
is_standalone: false,
|
||||
total_requests: 1234,
|
||||
total_cost_usd: 45.67
|
||||
total_cost_usd: 45.67,
|
||||
force_capabilities: null
|
||||
},
|
||||
{
|
||||
id: 'key-uuid-002',
|
||||
@@ -357,7 +360,8 @@ export const MOCK_USER_API_KEYS: ApiKey[] = [
|
||||
is_active: true,
|
||||
is_standalone: false,
|
||||
total_requests: 5678,
|
||||
total_cost_usd: 123.45
|
||||
total_cost_usd: 123.45,
|
||||
force_capabilities: { cache_1h: true }
|
||||
},
|
||||
{
|
||||
id: 'key-uuid-003',
|
||||
@@ -367,7 +371,8 @@ export const MOCK_USER_API_KEYS: ApiKey[] = [
|
||||
is_active: false,
|
||||
is_standalone: false,
|
||||
total_requests: 100,
|
||||
total_cost_usd: 2.34
|
||||
total_cost_usd: 2.34,
|
||||
force_capabilities: null
|
||||
}
|
||||
]
|
||||
|
||||
@@ -813,16 +818,16 @@ export const MOCK_USAGE_RESPONSE: UsageResponse = {
|
||||
quota_usd: 100,
|
||||
used_usd: 45.32,
|
||||
summary_by_model: [
|
||||
{ model: 'claude-sonnet-4-20250514', requests: 456, input_tokens: 650000, output_tokens: 250000, total_tokens: 900000, total_cost_usd: 18.50, actual_total_cost_usd: 13.50 },
|
||||
{ model: 'gpt-4o', requests: 312, input_tokens: 480000, output_tokens: 180000, total_tokens: 660000, total_cost_usd: 12.30, actual_total_cost_usd: 9.20 },
|
||||
{ model: 'claude-haiku-3-5-20241022', requests: 289, input_tokens: 420000, output_tokens: 170000, total_tokens: 590000, total_cost_usd: 8.50, actual_total_cost_usd: 6.30 },
|
||||
{ model: 'gemini-2.0-flash', requests: 177, input_tokens: 250000, output_tokens: 100000, total_tokens: 350000, total_cost_usd: 6.37, actual_total_cost_usd: 4.33 }
|
||||
{ model: 'claude-sonnet-4-5-20250929', requests: 456, input_tokens: 650000, output_tokens: 250000, total_tokens: 900000, total_cost_usd: 18.50, actual_total_cost_usd: 13.50 },
|
||||
{ model: 'gpt-5.1', requests: 312, input_tokens: 480000, output_tokens: 180000, total_tokens: 660000, total_cost_usd: 12.30, actual_total_cost_usd: 9.20 },
|
||||
{ model: 'claude-haiku-4-5-20251001', requests: 289, input_tokens: 420000, output_tokens: 170000, total_tokens: 590000, total_cost_usd: 8.50, actual_total_cost_usd: 6.30 },
|
||||
{ model: 'gemini-3-pro-preview', requests: 177, input_tokens: 250000, output_tokens: 100000, total_tokens: 350000, total_cost_usd: 6.37, actual_total_cost_usd: 4.33 }
|
||||
],
|
||||
records: [
|
||||
{
|
||||
id: 'usage-001',
|
||||
provider: 'anthropic',
|
||||
model: 'claude-sonnet-4-20250514',
|
||||
model: 'claude-sonnet-4-5-20250929',
|
||||
input_tokens: 1500,
|
||||
output_tokens: 800,
|
||||
total_tokens: 2300,
|
||||
@@ -837,7 +842,7 @@ export const MOCK_USAGE_RESPONSE: UsageResponse = {
|
||||
{
|
||||
id: 'usage-002',
|
||||
provider: 'openai',
|
||||
model: 'gpt-4o',
|
||||
model: 'gpt-5.1',
|
||||
input_tokens: 2000,
|
||||
output_tokens: 500,
|
||||
total_tokens: 2500,
|
||||
|
||||
@@ -367,6 +367,11 @@ function generateMockUsageRecords(count: number = 100) {
|
||||
user_id: user.id,
|
||||
username: user.username,
|
||||
user_email: user.email,
|
||||
api_key: {
|
||||
id: `key-${user.id}-${Math.ceil(Math.random() * 2)}`,
|
||||
name: `${user.username} Key ${Math.ceil(Math.random() * 3)}`,
|
||||
display: `sk-ae...${String(1000 + Math.floor(Math.random() * 9000))}`
|
||||
},
|
||||
provider: model.provider,
|
||||
api_key_name: `${model.provider}-key-${Math.ceil(Math.random() * 3)}`,
|
||||
rate_multiplier: 1.0,
|
||||
@@ -405,10 +410,10 @@ function getUsageRecords() {
|
||||
|
||||
// Mock 映射数据
|
||||
const MOCK_ALIASES = [
|
||||
{ id: 'alias-001', source_model: 'claude-4-sonnet', target_global_model_id: 'gm-001', target_global_model_name: 'claude-sonnet-4-20250514', target_global_model_display_name: 'Claude Sonnet 4', provider_id: null, provider_name: null, scope: 'global', mapping_type: 'alias', is_active: true, created_at: '2024-01-01T00:00:00Z', updated_at: '2024-01-01T00:00:00Z' },
|
||||
{ id: 'alias-002', source_model: 'claude-4-opus', target_global_model_id: 'gm-002', target_global_model_name: 'claude-opus-4-20250514', target_global_model_display_name: 'Claude Opus 4', provider_id: null, provider_name: null, scope: 'global', mapping_type: 'alias', is_active: true, created_at: '2024-01-01T00:00:00Z', updated_at: '2024-01-01T00:00:00Z' },
|
||||
{ id: 'alias-003', source_model: 'gpt4o', target_global_model_id: 'gm-004', target_global_model_name: 'gpt-4o', target_global_model_display_name: 'GPT-4o', provider_id: null, provider_name: null, scope: 'global', mapping_type: 'alias', is_active: true, created_at: '2024-01-01T00:00:00Z', updated_at: '2024-01-01T00:00:00Z' },
|
||||
{ id: 'alias-004', source_model: 'gemini-flash', target_global_model_id: 'gm-005', target_global_model_name: 'gemini-2.0-flash', target_global_model_display_name: 'Gemini 2.0 Flash', provider_id: null, provider_name: null, scope: 'global', mapping_type: 'alias', is_active: true, created_at: '2024-01-01T00:00:00Z', updated_at: '2024-01-01T00:00:00Z' }
|
||||
{ id: 'alias-001', source_model: 'claude-4-sonnet', target_global_model_id: 'gm-003', target_global_model_name: 'claude-sonnet-4-5-20250929', target_global_model_display_name: 'Claude Sonnet 4.5', provider_id: null, provider_name: null, scope: 'global', mapping_type: 'alias', is_active: true, created_at: '2024-01-01T00:00:00Z', updated_at: '2024-01-01T00:00:00Z' },
|
||||
{ id: 'alias-002', source_model: 'claude-4-opus', target_global_model_id: 'gm-002', target_global_model_name: 'claude-opus-4-5-20251101', target_global_model_display_name: 'Claude Opus 4.5', provider_id: null, provider_name: null, scope: 'global', mapping_type: 'alias', is_active: true, created_at: '2024-01-01T00:00:00Z', updated_at: '2024-01-01T00:00:00Z' },
|
||||
{ id: 'alias-003', source_model: 'gpt5', target_global_model_id: 'gm-006', target_global_model_name: 'gpt-5.1', target_global_model_display_name: 'GPT-5.1', provider_id: null, provider_name: null, scope: 'global', mapping_type: 'alias', is_active: true, created_at: '2024-01-01T00:00:00Z', updated_at: '2024-01-01T00:00:00Z' },
|
||||
{ id: 'alias-004', source_model: 'gemini-pro', target_global_model_id: 'gm-005', target_global_model_name: 'gemini-3-pro-preview', target_global_model_display_name: 'Gemini 3 Pro Preview', provider_id: null, provider_name: null, scope: 'global', mapping_type: 'alias', is_active: true, created_at: '2024-01-01T00:00:00Z', updated_at: '2024-01-01T00:00:00Z' }
|
||||
]
|
||||
|
||||
// Mock Endpoint Keys
|
||||
@@ -835,10 +840,26 @@ const mockHandlers: Record<string, (config: AxiosRequestConfig) => Promise<Axios
|
||||
'GET /api/admin/usage/records': async (config) => {
|
||||
await delay()
|
||||
requireAdmin()
|
||||
const records = getUsageRecords()
|
||||
let records = getUsageRecords()
|
||||
const params = config.params || {}
|
||||
const limit = parseInt(params.limit) || 20
|
||||
const offset = parseInt(params.offset) || 0
|
||||
|
||||
// 通用搜索:用户名、密钥名、模型名、提供商名
|
||||
// 支持空格分隔的组合搜索,多个关键词之间是 AND 关系
|
||||
if (typeof params.search === 'string' && params.search.trim()) {
|
||||
const keywords = params.search.trim().toLowerCase().split(/\s+/)
|
||||
records = records.filter(r => {
|
||||
// 每个关键词都要匹配至少一个字段
|
||||
return keywords.every((keyword: string) =>
|
||||
(r.username || '').toLowerCase().includes(keyword) ||
|
||||
(r.api_key?.name || '').toLowerCase().includes(keyword) ||
|
||||
(r.model || '').toLowerCase().includes(keyword) ||
|
||||
(r.provider || '').toLowerCase().includes(keyword)
|
||||
)
|
||||
})
|
||||
}
|
||||
|
||||
return createMockResponse({
|
||||
records: records.slice(offset, offset + limit),
|
||||
total: records.length,
|
||||
@@ -2172,10 +2193,10 @@ function generateIntervalTimelineData(
|
||||
|
||||
// 模型列表(用于按模型区分颜色)
|
||||
const models = [
|
||||
'claude-sonnet-4-20250514',
|
||||
'claude-3-5-sonnet-20241022',
|
||||
'claude-3-5-haiku-20241022',
|
||||
'claude-opus-4-20250514'
|
||||
'claude-sonnet-4-5-20250929',
|
||||
'claude-haiku-4-5-20251001',
|
||||
'claude-opus-4-5-20251101',
|
||||
'gpt-5.1'
|
||||
]
|
||||
|
||||
// 生成模拟的请求间隔数据
|
||||
|
||||
@@ -106,6 +106,11 @@ const routes: RouteRecordRaw[] = [
|
||||
name: 'SystemSettings',
|
||||
component: () => importWithRetry(() => import('@/views/admin/SystemSettings.vue'))
|
||||
},
|
||||
{
|
||||
path: 'email',
|
||||
name: 'EmailSettings',
|
||||
component: () => importWithRetry(() => import('@/views/admin/EmailSettings.vue'))
|
||||
},
|
||||
{
|
||||
path: 'audit-logs',
|
||||
name: 'AuditLogs',
|
||||
|
||||
@@ -14,7 +14,7 @@ export const useUsersStore = defineStore('users', () => {
|
||||
try {
|
||||
users.value = await usersApi.getAllUsers()
|
||||
} catch (err: any) {
|
||||
error.value = err.response?.data?.detail || '获取用户列表失败'
|
||||
error.value = err.response?.data?.error?.message || err.response?.data?.detail || '获取用户列表失败'
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
@@ -29,7 +29,7 @@ export const useUsersStore = defineStore('users', () => {
|
||||
users.value.push(newUser)
|
||||
return newUser
|
||||
} catch (err: any) {
|
||||
error.value = err.response?.data?.detail || '创建用户失败'
|
||||
error.value = err.response?.data?.error?.message || err.response?.data?.detail || '创建用户失败'
|
||||
throw err
|
||||
} finally {
|
||||
loading.value = false
|
||||
@@ -52,7 +52,7 @@ export const useUsersStore = defineStore('users', () => {
|
||||
}
|
||||
return updatedUser
|
||||
} catch (err: any) {
|
||||
error.value = err.response?.data?.detail || '更新用户失败'
|
||||
error.value = err.response?.data?.error?.message || err.response?.data?.detail || '更新用户失败'
|
||||
throw err
|
||||
} finally {
|
||||
loading.value = false
|
||||
@@ -67,7 +67,7 @@ export const useUsersStore = defineStore('users', () => {
|
||||
await usersApi.deleteUser(userId)
|
||||
users.value = users.value.filter(u => u.id !== userId)
|
||||
} catch (err: any) {
|
||||
error.value = err.response?.data?.detail || '删除用户失败'
|
||||
error.value = err.response?.data?.error?.message || err.response?.data?.detail || '删除用户失败'
|
||||
throw err
|
||||
} finally {
|
||||
loading.value = false
|
||||
@@ -78,7 +78,7 @@ export const useUsersStore = defineStore('users', () => {
|
||||
try {
|
||||
return await usersApi.getUserApiKeys(userId)
|
||||
} catch (err: any) {
|
||||
error.value = err.response?.data?.detail || '获取 API Keys 失败'
|
||||
error.value = err.response?.data?.error?.message || err.response?.data?.detail || '获取 API Keys 失败'
|
||||
throw err
|
||||
}
|
||||
}
|
||||
@@ -87,7 +87,7 @@ export const useUsersStore = defineStore('users', () => {
|
||||
try {
|
||||
return await usersApi.createApiKey(userId, name)
|
||||
} catch (err: any) {
|
||||
error.value = err.response?.data?.detail || '创建 API Key 失败'
|
||||
error.value = err.response?.data?.error?.message || err.response?.data?.detail || '创建 API Key 失败'
|
||||
throw err
|
||||
}
|
||||
}
|
||||
@@ -96,7 +96,7 @@ export const useUsersStore = defineStore('users', () => {
|
||||
try {
|
||||
await usersApi.deleteApiKey(userId, keyId)
|
||||
} catch (err: any) {
|
||||
error.value = err.response?.data?.detail || '删除 API Key 失败'
|
||||
error.value = err.response?.data?.error?.message || err.response?.data?.detail || '删除 API Key 失败'
|
||||
throw err
|
||||
}
|
||||
}
|
||||
@@ -110,7 +110,7 @@ export const useUsersStore = defineStore('users', () => {
|
||||
// 刷新用户列表以获取最新数据
|
||||
await fetchUsers()
|
||||
} catch (err: any) {
|
||||
error.value = err.response?.data?.detail || '重置配额失败'
|
||||
error.value = err.response?.data?.error?.message || err.response?.data?.detail || '重置配额失败'
|
||||
throw err
|
||||
} finally {
|
||||
loading.value = false
|
||||
|
||||
@@ -1191,4 +1191,11 @@ body[theme-mode='dark'] .literary-annotation {
|
||||
.scrollbar-thin::-webkit-scrollbar-thumb:hover {
|
||||
background-color: hsl(var(--muted-foreground) / 0.5);
|
||||
}
|
||||
|
||||
/* Password masking without type="password" to prevent browser autofill */
|
||||
.-webkit-text-security-disc {
|
||||
-webkit-text-security: disc;
|
||||
-moz-text-security: disc;
|
||||
text-security: disc;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -198,3 +198,49 @@ export function parseApiErrorShort(err: unknown, defaultMessage: string = '操
|
||||
const lines = fullError.split('\n')
|
||||
return lines[0] || defaultMessage
|
||||
}
|
||||
|
||||
/**
|
||||
* 解析模型测试响应的错误信息
|
||||
* @param result 测试响应结果
|
||||
* @returns 格式化的错误信息
|
||||
*/
|
||||
export function parseTestModelError(result: {
|
||||
error?: string
|
||||
data?: {
|
||||
response?: {
|
||||
status_code?: number
|
||||
error?: string | { message?: string }
|
||||
}
|
||||
}
|
||||
}): string {
|
||||
let errorMsg = result.error || '测试失败'
|
||||
|
||||
// 检查HTTP状态码错误
|
||||
if (result.data?.response?.status_code) {
|
||||
const status = result.data.response.status_code
|
||||
if (status === 403) {
|
||||
errorMsg = '认证失败: API密钥无效或客户端类型不被允许'
|
||||
} else if (status === 401) {
|
||||
errorMsg = '认证失败: API密钥无效或已过期'
|
||||
} else if (status === 404) {
|
||||
errorMsg = '模型不存在: 请检查模型名称是否正确'
|
||||
} else if (status === 429) {
|
||||
errorMsg = '请求频率过高: 请稍后重试'
|
||||
} else if (status >= 500) {
|
||||
errorMsg = `服务器错误: HTTP ${status}`
|
||||
} else {
|
||||
errorMsg = `请求失败: HTTP ${status}`
|
||||
}
|
||||
}
|
||||
|
||||
// 尝试从错误响应中提取更多信息
|
||||
if (result.data?.response?.error) {
|
||||
if (typeof result.data.response.error === 'string') {
|
||||
errorMsg = result.data.response.error
|
||||
} else if (result.data.response.error?.message) {
|
||||
errorMsg = result.data.response.error.message
|
||||
}
|
||||
}
|
||||
|
||||
return errorMsg
|
||||
}
|
||||
|
||||
@@ -650,6 +650,7 @@
|
||||
import { ref, computed, onMounted } from 'vue'
|
||||
import { useToast } from '@/composables/useToast'
|
||||
import { useConfirm } from '@/composables/useConfirm'
|
||||
import { useClipboard } from '@/composables/useClipboard'
|
||||
import { adminApi, type AdminApiKey, type CreateStandaloneApiKeyRequest } from '@/api/admin'
|
||||
|
||||
import {
|
||||
@@ -693,6 +694,7 @@ import { log } from '@/utils/logger'
|
||||
|
||||
const { success, error } = useToast()
|
||||
const { confirmDanger } = useConfirm()
|
||||
const { copyToClipboard } = useClipboard()
|
||||
|
||||
const apiKeys = ref<AdminApiKey[]>([])
|
||||
const loading = ref(false)
|
||||
@@ -848,28 +850,20 @@ async function deleteApiKey(apiKey: AdminApiKey) {
|
||||
}
|
||||
|
||||
function editApiKey(apiKey: AdminApiKey) {
|
||||
// 计算过期天数
|
||||
let expireDays: number | undefined = undefined
|
||||
let neverExpire = true
|
||||
// 解析过期日期为 YYYY-MM-DD 格式
|
||||
// 保留原始日期,不做时间过滤(避免编辑当天过期的 Key 时意外清空)
|
||||
let expiresAt: string | undefined = undefined
|
||||
|
||||
if (apiKey.expires_at) {
|
||||
const expiresDate = new Date(apiKey.expires_at)
|
||||
const now = new Date()
|
||||
const diffMs = expiresDate.getTime() - now.getTime()
|
||||
const diffDays = Math.ceil(diffMs / (1000 * 60 * 60 * 24))
|
||||
|
||||
if (diffDays > 0) {
|
||||
expireDays = diffDays
|
||||
neverExpire = false
|
||||
}
|
||||
expiresAt = expiresDate.toISOString().split('T')[0]
|
||||
}
|
||||
|
||||
editingKeyData.value = {
|
||||
id: apiKey.id,
|
||||
name: apiKey.name || '',
|
||||
expire_days: expireDays,
|
||||
never_expire: neverExpire,
|
||||
rate_limit: apiKey.rate_limit || 100,
|
||||
expires_at: expiresAt,
|
||||
rate_limit: apiKey.rate_limit ?? undefined,
|
||||
auto_delete_on_expiry: apiKey.auto_delete_on_expiry || false,
|
||||
allowed_providers: apiKey.allowed_providers || [],
|
||||
allowed_api_formats: apiKey.allowed_api_formats || [],
|
||||
@@ -927,20 +921,14 @@ function selectKey() {
|
||||
}
|
||||
|
||||
async function copyKey() {
|
||||
try {
|
||||
await navigator.clipboard.writeText(newKeyValue.value)
|
||||
success('API Key 已复制到剪贴板')
|
||||
} catch {
|
||||
error('复制失败,请手动复制')
|
||||
}
|
||||
await copyToClipboard(newKeyValue.value)
|
||||
}
|
||||
|
||||
async function copyKeyPrefix(apiKey: AdminApiKey) {
|
||||
try {
|
||||
// 调用后端 API 获取完整密钥
|
||||
const response = await adminApi.getFullApiKey(apiKey.id)
|
||||
await navigator.clipboard.writeText(response.key)
|
||||
success('完整密钥已复制到剪贴板')
|
||||
await copyToClipboard(response.key)
|
||||
} catch (err) {
|
||||
log.error('复制密钥失败:', err)
|
||||
error('复制失败,请重试')
|
||||
@@ -1037,18 +1025,30 @@ function closeKeyFormDialog() {
|
||||
|
||||
// 统一处理表单提交
|
||||
async function handleKeyFormSubmit(data: StandaloneKeyFormData) {
|
||||
// 验证过期日期(如果设置了,必须晚于今天)
|
||||
if (data.expires_at) {
|
||||
const selectedDate = new Date(data.expires_at)
|
||||
const today = new Date()
|
||||
today.setHours(0, 0, 0, 0)
|
||||
if (selectedDate <= today) {
|
||||
error('过期日期必须晚于今天')
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
keyFormDialogRef.value?.setSaving(true)
|
||||
try {
|
||||
if (data.id) {
|
||||
// 更新
|
||||
const updateData: Partial<CreateStandaloneApiKeyRequest> = {
|
||||
name: data.name || undefined,
|
||||
rate_limit: data.rate_limit,
|
||||
expire_days: data.never_expire ? null : (data.expire_days || null),
|
||||
rate_limit: data.rate_limit ?? null, // undefined = 无限制,显式传 null
|
||||
expires_at: data.expires_at || null, // undefined/空 = 永不过期
|
||||
auto_delete_on_expiry: data.auto_delete_on_expiry,
|
||||
allowed_providers: data.allowed_providers.length > 0 ? data.allowed_providers : undefined,
|
||||
allowed_api_formats: data.allowed_api_formats.length > 0 ? data.allowed_api_formats : undefined,
|
||||
allowed_models: data.allowed_models.length > 0 ? data.allowed_models : undefined
|
||||
// 空数组表示清除限制(允许全部),后端会将空数组存为 NULL
|
||||
allowed_providers: data.allowed_providers,
|
||||
allowed_api_formats: data.allowed_api_formats,
|
||||
allowed_models: data.allowed_models
|
||||
}
|
||||
await adminApi.updateApiKey(data.id, updateData)
|
||||
success('API Key 更新成功')
|
||||
@@ -1061,12 +1061,13 @@ async function handleKeyFormSubmit(data: StandaloneKeyFormData) {
|
||||
const createData: CreateStandaloneApiKeyRequest = {
|
||||
name: data.name || undefined,
|
||||
initial_balance_usd: data.initial_balance_usd,
|
||||
rate_limit: data.rate_limit,
|
||||
expire_days: data.never_expire ? null : (data.expire_days || null),
|
||||
rate_limit: data.rate_limit ?? null, // undefined = 无限制,显式传 null
|
||||
expires_at: data.expires_at || null, // undefined/空 = 永不过期
|
||||
auto_delete_on_expiry: data.auto_delete_on_expiry,
|
||||
allowed_providers: data.allowed_providers.length > 0 ? data.allowed_providers : undefined,
|
||||
allowed_api_formats: data.allowed_api_formats.length > 0 ? data.allowed_api_formats : undefined,
|
||||
allowed_models: data.allowed_models.length > 0 ? data.allowed_models : undefined
|
||||
// 空数组表示不设置限制(允许全部),后端会将空数组存为 NULL
|
||||
allowed_providers: data.allowed_providers,
|
||||
allowed_api_formats: data.allowed_api_formats,
|
||||
allowed_models: data.allowed_models
|
||||
}
|
||||
const response = await adminApi.createStandaloneApiKey(createData)
|
||||
newKeyValue.value = response.key
|
||||
|
||||
@@ -46,6 +46,7 @@ const clearingRowAffinityKey = ref<string | null>(null)
|
||||
const currentPage = ref(1)
|
||||
const pageSize = ref(20)
|
||||
const currentTime = ref(Math.floor(Date.now() / 1000))
|
||||
const analysisHoursSelectOpen = ref(false)
|
||||
|
||||
// ==================== 模型映射缓存 ====================
|
||||
|
||||
@@ -1056,7 +1057,10 @@ onBeforeUnmount(() => {
|
||||
<span class="text-xs text-muted-foreground hidden sm:inline">分析用户请求间隔,推荐合适的缓存 TTL</span>
|
||||
</div>
|
||||
<div class="flex flex-wrap items-center gap-2">
|
||||
<Select v-model="analysisHours">
|
||||
<Select
|
||||
v-model="analysisHours"
|
||||
v-model:open="analysisHoursSelectOpen"
|
||||
>
|
||||
<SelectTrigger class="w-24 sm:w-28 h-8">
|
||||
<SelectValue placeholder="时间段" />
|
||||
</SelectTrigger>
|
||||
|
||||
856
frontend/src/views/admin/EmailSettings.vue
Normal file
856
frontend/src/views/admin/EmailSettings.vue
Normal file
@@ -0,0 +1,856 @@
|
||||
<template>
|
||||
<PageContainer>
|
||||
<PageHeader
|
||||
title="邮件配置"
|
||||
description="配置邮件发送服务和注册邮箱限制"
|
||||
/>
|
||||
|
||||
<div class="mt-6 space-y-6">
|
||||
<!-- SMTP 邮件配置 -->
|
||||
<CardSection
|
||||
title="SMTP 邮件配置"
|
||||
description="配置 SMTP 服务用于发送验证码邮件"
|
||||
>
|
||||
<template #actions>
|
||||
<div class="flex gap-2">
|
||||
<Button
|
||||
size="sm"
|
||||
variant="outline"
|
||||
:disabled="testSmtpLoading"
|
||||
@click="handleTestSmtp"
|
||||
>
|
||||
{{ testSmtpLoading ? '测试中...' : '测试连接' }}
|
||||
</Button>
|
||||
<Button
|
||||
size="sm"
|
||||
:disabled="smtpSaveLoading"
|
||||
@click="saveSmtpConfig"
|
||||
>
|
||||
{{ smtpSaveLoading ? '保存中...' : '保存' }}
|
||||
</Button>
|
||||
</div>
|
||||
</template>
|
||||
<div class="grid grid-cols-1 md:grid-cols-2 gap-6">
|
||||
<div>
|
||||
<Label
|
||||
for="smtp-host"
|
||||
class="block text-sm font-medium"
|
||||
>
|
||||
SMTP 服务器地址
|
||||
</Label>
|
||||
<Input
|
||||
id="smtp-host"
|
||||
v-model="emailConfig.smtp_host"
|
||||
type="text"
|
||||
placeholder="smtp.gmail.com"
|
||||
class="mt-1"
|
||||
/>
|
||||
<p class="mt-1 text-xs text-muted-foreground">
|
||||
邮件服务器地址
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<Label
|
||||
for="smtp-port"
|
||||
class="block text-sm font-medium"
|
||||
>
|
||||
SMTP 端口
|
||||
</Label>
|
||||
<Input
|
||||
id="smtp-port"
|
||||
v-model.number="emailConfig.smtp_port"
|
||||
type="number"
|
||||
placeholder="587"
|
||||
class="mt-1"
|
||||
/>
|
||||
<p class="mt-1 text-xs text-muted-foreground">
|
||||
常用端口: 587 (TLS), 465 (SSL), 25 (无加密)
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<Label
|
||||
for="smtp-user"
|
||||
class="block text-sm font-medium"
|
||||
>
|
||||
SMTP 用户名
|
||||
</Label>
|
||||
<Input
|
||||
id="smtp-user"
|
||||
v-model="emailConfig.smtp_user"
|
||||
type="text"
|
||||
placeholder="your-email@example.com"
|
||||
class="mt-1"
|
||||
autocomplete="off"
|
||||
data-lpignore="true"
|
||||
data-1p-ignore="true"
|
||||
data-form-type="other"
|
||||
/>
|
||||
<p class="mt-1 text-xs text-muted-foreground">
|
||||
通常是您的邮箱地址
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<Label
|
||||
for="smtp-password"
|
||||
class="block text-sm font-medium"
|
||||
>
|
||||
SMTP 密码
|
||||
</Label>
|
||||
<div class="relative mt-1">
|
||||
<Input
|
||||
id="smtp-password"
|
||||
v-model="emailConfig.smtp_password"
|
||||
type="text"
|
||||
:placeholder="smtpPasswordIsSet ? '已设置(留空保持不变)' : '请输入密码'"
|
||||
class="-webkit-text-security-disc"
|
||||
:class="smtpPasswordIsSet ? 'pr-8' : ''"
|
||||
autocomplete="one-time-code"
|
||||
data-lpignore="true"
|
||||
data-1p-ignore="true"
|
||||
data-form-type="other"
|
||||
/>
|
||||
<button
|
||||
v-if="smtpPasswordIsSet"
|
||||
type="button"
|
||||
class="absolute right-2 top-1/2 -translate-y-1/2 text-muted-foreground hover:text-foreground transition-colors"
|
||||
title="清除已保存的密码"
|
||||
@click="handleClearSmtpPassword"
|
||||
>
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
width="16"
|
||||
height="16"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
stroke-width="2"
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
>
|
||||
<path d="M18 6 6 18" /><path d="m6 6 12 12" />
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
<p class="mt-1 text-xs text-muted-foreground">
|
||||
邮箱密码或应用专用密码
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<Label
|
||||
for="smtp-from-email"
|
||||
class="block text-sm font-medium"
|
||||
>
|
||||
发件人邮箱
|
||||
</Label>
|
||||
<Input
|
||||
id="smtp-from-email"
|
||||
v-model="emailConfig.smtp_from_email"
|
||||
type="email"
|
||||
placeholder="noreply@example.com"
|
||||
class="mt-1"
|
||||
/>
|
||||
<p class="mt-1 text-xs text-muted-foreground">
|
||||
显示为发件人的邮箱地址
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<Label
|
||||
for="smtp-from-name"
|
||||
class="block text-sm font-medium"
|
||||
>
|
||||
发件人名称
|
||||
</Label>
|
||||
<Input
|
||||
id="smtp-from-name"
|
||||
v-model="emailConfig.smtp_from_name"
|
||||
type="text"
|
||||
placeholder="Aether"
|
||||
class="mt-1"
|
||||
/>
|
||||
<p class="mt-1 text-xs text-muted-foreground">
|
||||
显示为发件人的名称
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<Label
|
||||
for="smtp-encryption"
|
||||
class="block text-sm font-medium mb-2"
|
||||
>
|
||||
加密方式
|
||||
</Label>
|
||||
<Select
|
||||
v-model="smtpEncryption"
|
||||
v-model:open="smtpEncryptionSelectOpen"
|
||||
>
|
||||
<SelectTrigger
|
||||
id="smtp-encryption"
|
||||
class="mt-1"
|
||||
>
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="ssl">
|
||||
SSL (隐式加密)
|
||||
</SelectItem>
|
||||
<SelectItem value="tls">
|
||||
TLS / STARTTLS
|
||||
</SelectItem>
|
||||
<SelectItem value="none">
|
||||
无加密
|
||||
</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
<p class="mt-1 text-xs text-muted-foreground">
|
||||
Gmail 等服务推荐使用 SSL
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</CardSection>
|
||||
|
||||
<!-- 邮件模板配置 -->
|
||||
<CardSection
|
||||
title="邮件模板"
|
||||
description="配置不同类型邮件的 HTML 模板"
|
||||
>
|
||||
<template #actions>
|
||||
<Button
|
||||
size="sm"
|
||||
:disabled="templateSaveLoading"
|
||||
@click="handleSaveTemplate"
|
||||
>
|
||||
{{ templateSaveLoading ? '保存中...' : '保存' }}
|
||||
</Button>
|
||||
</template>
|
||||
<!-- 模板类型选择 -->
|
||||
<div class="flex items-center gap-2 mb-4">
|
||||
<button
|
||||
v-for="tpl in templateTypes"
|
||||
:key="tpl.type"
|
||||
class="px-3 py-1.5 text-sm font-medium rounded-md transition-colors"
|
||||
:class="activeTemplateType === tpl.type
|
||||
? 'bg-primary text-primary-foreground'
|
||||
: 'bg-muted text-muted-foreground hover:text-foreground'"
|
||||
@click="handleTemplateTypeChange(tpl.type)"
|
||||
>
|
||||
{{ tpl.name }}
|
||||
<span
|
||||
v-if="tpl.is_custom"
|
||||
class="ml-1 text-xs opacity-70"
|
||||
>(已自定义)</span>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!-- 当前模板编辑区 -->
|
||||
<div
|
||||
v-if="currentTemplate"
|
||||
class="space-y-4"
|
||||
>
|
||||
<!-- 可用变量提示 -->
|
||||
<div class="text-xs text-muted-foreground bg-muted/50 rounded-md px-3 py-2">
|
||||
可用变量:
|
||||
<code
|
||||
v-for="(v, i) in currentTemplate.variables"
|
||||
:key="v"
|
||||
class="mx-1 px-1.5 py-0.5 bg-background rounded text-foreground"
|
||||
>{{ formatVariable(v) }}<span v-if="i < currentTemplate.variables.length - 1">,</span></code>
|
||||
</div>
|
||||
|
||||
<!-- 邮件主题 -->
|
||||
<div>
|
||||
<Label
|
||||
for="template-subject"
|
||||
class="block text-sm font-medium"
|
||||
>
|
||||
邮件主题
|
||||
</Label>
|
||||
<Input
|
||||
id="template-subject"
|
||||
v-model="templateSubject"
|
||||
type="text"
|
||||
:placeholder="currentTemplate.default_subject || '验证码'"
|
||||
class="mt-1"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<!-- HTML 模板编辑 -->
|
||||
<div>
|
||||
<Label
|
||||
for="template-html"
|
||||
class="block text-sm font-medium"
|
||||
>
|
||||
HTML 模板
|
||||
</Label>
|
||||
<textarea
|
||||
id="template-html"
|
||||
v-model="templateHtml"
|
||||
rows="16"
|
||||
class="mt-1 w-full font-mono text-sm bg-muted/30 border border-border rounded-md p-3 focus:outline-none focus:ring-2 focus:ring-primary focus:border-transparent resize-y"
|
||||
:placeholder="currentTemplate.default_html || '<!DOCTYPE html>...'"
|
||||
spellcheck="false"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<!-- 操作按钮 -->
|
||||
<div class="flex gap-2">
|
||||
<Button
|
||||
variant="outline"
|
||||
:disabled="previewLoading"
|
||||
@click="handlePreviewTemplate"
|
||||
>
|
||||
{{ previewLoading ? '加载中...' : '预览' }}
|
||||
</Button>
|
||||
<Button
|
||||
variant="outline"
|
||||
:disabled="!currentTemplate.is_custom"
|
||||
@click="handleResetTemplate"
|
||||
>
|
||||
重置为默认
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 加载中状态 -->
|
||||
<div
|
||||
v-else-if="templateLoading"
|
||||
class="py-8 text-center text-muted-foreground"
|
||||
>
|
||||
正在加载模板...
|
||||
</div>
|
||||
</CardSection>
|
||||
|
||||
<!-- 预览对话框 -->
|
||||
<Dialog
|
||||
v-model:open="previewDialogOpen"
|
||||
no-padding
|
||||
max-width="xl"
|
||||
>
|
||||
<!-- 自定义窗口布局 -->
|
||||
<div class="flex flex-col max-h-[80vh]">
|
||||
<!-- 窗口标题栏 -->
|
||||
<div class="flex items-center justify-between px-4 py-2.5 bg-muted/50 border-b border-border/50 flex-shrink-0">
|
||||
<div class="flex items-center gap-3">
|
||||
<button
|
||||
type="button"
|
||||
class="flex gap-1.5 group"
|
||||
title="关闭"
|
||||
@click="previewDialogOpen = false"
|
||||
>
|
||||
<div class="w-2.5 h-2.5 rounded-full bg-red-400/80 group-hover:bg-red-500" />
|
||||
<div class="w-2.5 h-2.5 rounded-full bg-yellow-400/80" />
|
||||
<div class="w-2.5 h-2.5 rounded-full bg-green-400/80" />
|
||||
</button>
|
||||
<span class="text-sm font-medium text-foreground/80">邮件预览</span>
|
||||
</div>
|
||||
<div class="text-xs text-muted-foreground font-mono">
|
||||
{{ currentTemplate?.name || '模板' }}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 邮件头部信息 -->
|
||||
<div class="px-4 py-3 bg-muted/30 border-b border-border/30 space-y-1.5 flex-shrink-0">
|
||||
<div class="flex items-center gap-2 text-sm">
|
||||
<span class="text-muted-foreground w-14">主题:</span>
|
||||
<span class="font-medium text-foreground">{{ templateSubject || '(无主题)' }}</span>
|
||||
</div>
|
||||
<div class="flex items-center gap-2 text-sm">
|
||||
<span class="text-muted-foreground w-14">收件人:</span>
|
||||
<span class="text-foreground/80">example@example.com</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 邮件内容区域 - 直接显示邮件模板 -->
|
||||
<div class="flex-1 overflow-auto">
|
||||
<iframe
|
||||
v-if="previewHtml"
|
||||
ref="previewIframe"
|
||||
:srcdoc="previewHtml"
|
||||
class="w-full border-0"
|
||||
style="min-height: 400px;"
|
||||
sandbox="allow-same-origin"
|
||||
@load="adjustIframeHeight"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</Dialog>
|
||||
|
||||
<!-- 注册邮箱限制 -->
|
||||
<CardSection
|
||||
title="注册邮箱限制"
|
||||
description="控制允许注册的邮箱后缀,支持白名单或黑名单模式"
|
||||
>
|
||||
<template #actions>
|
||||
<Button
|
||||
size="sm"
|
||||
:disabled="emailSuffixSaveLoading"
|
||||
@click="saveEmailSuffixConfig"
|
||||
>
|
||||
{{ emailSuffixSaveLoading ? '保存中...' : '保存' }}
|
||||
</Button>
|
||||
</template>
|
||||
<div class="space-y-4">
|
||||
<div>
|
||||
<Label
|
||||
for="email-suffix-mode"
|
||||
class="block text-sm font-medium mb-2"
|
||||
>
|
||||
限制模式
|
||||
</Label>
|
||||
<Select
|
||||
v-model="emailConfig.email_suffix_mode"
|
||||
v-model:open="emailSuffixModeSelectOpen"
|
||||
>
|
||||
<SelectTrigger
|
||||
id="email-suffix-mode"
|
||||
class="mt-1"
|
||||
>
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="none">
|
||||
不限制 - 允许所有邮箱
|
||||
</SelectItem>
|
||||
<SelectItem value="whitelist">
|
||||
白名单 - 仅允许列出的后缀
|
||||
</SelectItem>
|
||||
<SelectItem value="blacklist">
|
||||
黑名单 - 拒绝列出的后缀
|
||||
</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
<p class="mt-1 text-xs text-muted-foreground">
|
||||
<template v-if="emailConfig.email_suffix_mode === 'none'">
|
||||
不限制邮箱后缀,所有邮箱均可注册
|
||||
</template>
|
||||
<template v-else-if="emailConfig.email_suffix_mode === 'whitelist'">
|
||||
仅允许下方列出后缀的邮箱注册
|
||||
</template>
|
||||
<template v-else>
|
||||
拒绝下方列出后缀的邮箱注册
|
||||
</template>
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div v-if="emailConfig.email_suffix_mode !== 'none'">
|
||||
<Label
|
||||
for="email-suffix-list"
|
||||
class="block text-sm font-medium"
|
||||
>
|
||||
邮箱后缀列表
|
||||
</Label>
|
||||
<Input
|
||||
id="email-suffix-list"
|
||||
v-model="emailSuffixListStr"
|
||||
placeholder="gmail.com, outlook.com, qq.com"
|
||||
class="mt-1"
|
||||
/>
|
||||
<p class="mt-1 text-xs text-muted-foreground">
|
||||
逗号分隔,例如: gmail.com, outlook.com, qq.com
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</CardSection>
|
||||
</div>
|
||||
</PageContainer>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, computed, onMounted } from 'vue'
|
||||
import Button from '@/components/ui/button.vue'
|
||||
import Input from '@/components/ui/input.vue'
|
||||
import Label from '@/components/ui/label.vue'
|
||||
import Select from '@/components/ui/select.vue'
|
||||
import SelectTrigger from '@/components/ui/select-trigger.vue'
|
||||
import SelectValue from '@/components/ui/select-value.vue'
|
||||
import SelectContent from '@/components/ui/select-content.vue'
|
||||
import SelectItem from '@/components/ui/select-item.vue'
|
||||
import Dialog from '@/components/ui/dialog/Dialog.vue'
|
||||
import { PageHeader, PageContainer, CardSection } from '@/components/layout'
|
||||
import { useToast } from '@/composables/useToast'
|
||||
import { adminApi, type EmailTemplateInfo } from '@/api/admin'
|
||||
import { log } from '@/utils/logger'
|
||||
|
||||
const { success, error } = useToast()
|
||||
|
||||
interface EmailConfig {
|
||||
// SMTP 邮件配置
|
||||
smtp_host: string | null
|
||||
smtp_port: number
|
||||
smtp_user: string | null
|
||||
smtp_password: string | null
|
||||
smtp_use_tls: boolean
|
||||
smtp_use_ssl: boolean
|
||||
smtp_from_email: string | null
|
||||
smtp_from_name: string
|
||||
// 注册邮箱限制
|
||||
email_suffix_mode: 'none' | 'whitelist' | 'blacklist'
|
||||
email_suffix_list: string[]
|
||||
}
|
||||
|
||||
const smtpSaveLoading = ref(false)
|
||||
const emailSuffixSaveLoading = ref(false)
|
||||
const smtpEncryptionSelectOpen = ref(false)
|
||||
const emailSuffixModeSelectOpen = ref(false)
|
||||
const testSmtpLoading = ref(false)
|
||||
const smtpPasswordIsSet = ref(false)
|
||||
|
||||
// 邮件模板相关状态
|
||||
const templateLoading = ref(false)
|
||||
const templateSaveLoading = ref(false)
|
||||
const previewLoading = ref(false)
|
||||
const previewDialogOpen = ref(false)
|
||||
const previewHtml = ref('')
|
||||
const templateTypes = ref<EmailTemplateInfo[]>([])
|
||||
const activeTemplateType = ref('verification')
|
||||
const templateSubject = ref('')
|
||||
const templateHtml = ref('')
|
||||
const previewIframe = ref<HTMLIFrameElement | null>(null)
|
||||
|
||||
// 当前选中的模板
|
||||
const currentTemplate = computed(() => {
|
||||
return templateTypes.value.find(t => t.type === activeTemplateType.value)
|
||||
})
|
||||
|
||||
// 格式化变量显示(避免 Vue 模板中的双花括号语法冲突)
|
||||
function formatVariable(name: string): string {
|
||||
return `{{${name}}}`
|
||||
}
|
||||
|
||||
// 调整 iframe 高度以适应内容
|
||||
function adjustIframeHeight() {
|
||||
if (previewIframe.value) {
|
||||
try {
|
||||
const doc = previewIframe.value.contentDocument || previewIframe.value.contentWindow?.document
|
||||
if (doc && doc.body) {
|
||||
// 获取内容实际高度,添加一点余量
|
||||
const height = doc.body.scrollHeight + 20
|
||||
// 限制最大高度为视口的 70%
|
||||
const maxHeight = window.innerHeight * 0.7
|
||||
previewIframe.value.style.height = `${Math.min(height, maxHeight)}px`
|
||||
}
|
||||
} catch {
|
||||
// 跨域限制时使用默认高度
|
||||
previewIframe.value.style.height = '500px'
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const emailConfig = ref<EmailConfig>({
|
||||
// SMTP 邮件配置
|
||||
smtp_host: null,
|
||||
smtp_port: 587,
|
||||
smtp_user: null,
|
||||
smtp_password: null,
|
||||
smtp_use_tls: true,
|
||||
smtp_use_ssl: false,
|
||||
smtp_from_email: null,
|
||||
smtp_from_name: 'Aether',
|
||||
// 注册邮箱限制
|
||||
email_suffix_mode: 'none',
|
||||
email_suffix_list: [],
|
||||
})
|
||||
|
||||
// 计算属性:邮箱后缀列表数组和字符串之间的转换
|
||||
const emailSuffixListStr = computed({
|
||||
get: () => emailConfig.value.email_suffix_list.join(', '),
|
||||
set: (val: string) => {
|
||||
emailConfig.value.email_suffix_list = val
|
||||
.split(',')
|
||||
.map(s => s.trim().toLowerCase())
|
||||
.filter(s => s.length > 0)
|
||||
}
|
||||
})
|
||||
|
||||
// 计算属性:SMTP 加密方式(ssl/tls/none)
|
||||
const smtpEncryption = computed({
|
||||
get: () => {
|
||||
if (emailConfig.value.smtp_use_ssl) return 'ssl'
|
||||
if (emailConfig.value.smtp_use_tls) return 'tls'
|
||||
return 'none'
|
||||
},
|
||||
set: (val: string) => {
|
||||
emailConfig.value.smtp_use_ssl = val === 'ssl'
|
||||
emailConfig.value.smtp_use_tls = val === 'tls'
|
||||
}
|
||||
})
|
||||
|
||||
onMounted(async () => {
|
||||
await Promise.all([
|
||||
loadEmailConfig(),
|
||||
loadEmailTemplates()
|
||||
])
|
||||
})
|
||||
|
||||
async function loadEmailTemplates() {
|
||||
templateLoading.value = true
|
||||
try {
|
||||
const response = await adminApi.getEmailTemplates()
|
||||
templateTypes.value = response.templates
|
||||
|
||||
// 设置第一个模板为当前模板
|
||||
if (response.templates.length > 0) {
|
||||
const firstTemplate = response.templates[0]
|
||||
activeTemplateType.value = firstTemplate.type
|
||||
templateSubject.value = firstTemplate.subject
|
||||
templateHtml.value = firstTemplate.html
|
||||
}
|
||||
} catch (err) {
|
||||
error('加载邮件模板失败')
|
||||
log.error('加载邮件模板失败:', err)
|
||||
} finally {
|
||||
templateLoading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
function handleTemplateTypeChange(type: string) {
|
||||
activeTemplateType.value = type
|
||||
const template = templateTypes.value.find(t => t.type === type)
|
||||
if (template) {
|
||||
templateSubject.value = template.subject
|
||||
templateHtml.value = template.html
|
||||
}
|
||||
}
|
||||
|
||||
async function handleSaveTemplate() {
|
||||
templateSaveLoading.value = true
|
||||
try {
|
||||
await adminApi.updateEmailTemplate(activeTemplateType.value, {
|
||||
subject: templateSubject.value,
|
||||
html: templateHtml.value
|
||||
})
|
||||
|
||||
// 更新本地状态
|
||||
const idx = templateTypes.value.findIndex(t => t.type === activeTemplateType.value)
|
||||
if (idx !== -1) {
|
||||
templateTypes.value[idx].subject = templateSubject.value
|
||||
templateTypes.value[idx].html = templateHtml.value
|
||||
templateTypes.value[idx].is_custom = true
|
||||
}
|
||||
|
||||
success('模板保存成功')
|
||||
} catch (err) {
|
||||
error('保存模板失败')
|
||||
log.error('保存模板失败:', err)
|
||||
} finally {
|
||||
templateSaveLoading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
async function handlePreviewTemplate() {
|
||||
previewLoading.value = true
|
||||
try {
|
||||
const response = await adminApi.previewEmailTemplate(activeTemplateType.value, {
|
||||
html: templateHtml.value
|
||||
})
|
||||
previewHtml.value = response.html
|
||||
previewDialogOpen.value = true
|
||||
} catch (err) {
|
||||
error('预览模板失败')
|
||||
log.error('预览模板失败:', err)
|
||||
} finally {
|
||||
previewLoading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
async function handleResetTemplate() {
|
||||
try {
|
||||
const response = await adminApi.resetEmailTemplate(activeTemplateType.value)
|
||||
|
||||
// 更新本地状态
|
||||
const idx = templateTypes.value.findIndex(t => t.type === activeTemplateType.value)
|
||||
if (idx !== -1) {
|
||||
templateTypes.value[idx].subject = response.template.subject
|
||||
templateTypes.value[idx].html = response.template.html
|
||||
templateTypes.value[idx].is_custom = false
|
||||
}
|
||||
|
||||
templateSubject.value = response.template.subject
|
||||
templateHtml.value = response.template.html
|
||||
|
||||
success('模板已重置为默认值')
|
||||
} catch (err) {
|
||||
error('重置模板失败')
|
||||
log.error('重置模板失败:', err)
|
||||
}
|
||||
}
|
||||
|
||||
async function loadEmailConfig() {
|
||||
try {
|
||||
const configs = [
|
||||
// SMTP 邮件配置
|
||||
'smtp_host',
|
||||
'smtp_port',
|
||||
'smtp_user',
|
||||
'smtp_password',
|
||||
'smtp_use_tls',
|
||||
'smtp_use_ssl',
|
||||
'smtp_from_email',
|
||||
'smtp_from_name',
|
||||
// 注册邮箱限制
|
||||
'email_suffix_mode',
|
||||
'email_suffix_list',
|
||||
]
|
||||
|
||||
for (const key of configs) {
|
||||
try {
|
||||
const response = await adminApi.getSystemConfig(key)
|
||||
// 特殊处理敏感字段:只记录是否已设置,不填充值
|
||||
if (key === 'smtp_password') {
|
||||
smtpPasswordIsSet.value = response.is_set === true
|
||||
// 不设置 smtp_password 的值,保持为 null
|
||||
} else if (response.value !== null && response.value !== undefined) {
|
||||
(emailConfig.value as any)[key] = response.value
|
||||
}
|
||||
} catch {
|
||||
// 配置不存在时使用默认值,无需处理
|
||||
}
|
||||
}
|
||||
} catch (err) {
|
||||
error('加载邮件配置失败')
|
||||
log.error('加载邮件配置失败:', err)
|
||||
}
|
||||
}
|
||||
|
||||
// 保存 SMTP 配置
|
||||
async function saveSmtpConfig() {
|
||||
smtpSaveLoading.value = true
|
||||
try {
|
||||
const configItems = [
|
||||
{
|
||||
key: 'smtp_host',
|
||||
value: emailConfig.value.smtp_host,
|
||||
description: 'SMTP 服务器地址'
|
||||
},
|
||||
{
|
||||
key: 'smtp_port',
|
||||
value: emailConfig.value.smtp_port,
|
||||
description: 'SMTP 端口'
|
||||
},
|
||||
{
|
||||
key: 'smtp_user',
|
||||
value: emailConfig.value.smtp_user,
|
||||
description: 'SMTP 用户名'
|
||||
},
|
||||
// 只有输入了新密码才提交(空值表示保持原密码)
|
||||
...(emailConfig.value.smtp_password
|
||||
? [{
|
||||
key: 'smtp_password',
|
||||
value: emailConfig.value.smtp_password,
|
||||
description: 'SMTP 密码'
|
||||
}]
|
||||
: []),
|
||||
{
|
||||
key: 'smtp_use_tls',
|
||||
value: emailConfig.value.smtp_use_tls,
|
||||
description: '是否使用 TLS 加密'
|
||||
},
|
||||
{
|
||||
key: 'smtp_use_ssl',
|
||||
value: emailConfig.value.smtp_use_ssl,
|
||||
description: '是否使用 SSL 加密'
|
||||
},
|
||||
{
|
||||
key: 'smtp_from_email',
|
||||
value: emailConfig.value.smtp_from_email,
|
||||
description: '发件人邮箱'
|
||||
},
|
||||
{
|
||||
key: 'smtp_from_name',
|
||||
value: emailConfig.value.smtp_from_name,
|
||||
description: '发件人名称'
|
||||
},
|
||||
]
|
||||
|
||||
const promises = configItems.map(item =>
|
||||
adminApi.updateSystemConfig(item.key, item.value, item.description)
|
||||
)
|
||||
|
||||
await Promise.all(promises)
|
||||
success('SMTP 配置已保存')
|
||||
} catch (err) {
|
||||
error('保存配置失败')
|
||||
log.error('保存 SMTP 配置失败:', err)
|
||||
} finally {
|
||||
smtpSaveLoading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
// 保存邮箱后缀限制配置
|
||||
async function saveEmailSuffixConfig() {
|
||||
emailSuffixSaveLoading.value = true
|
||||
try {
|
||||
const configItems = [
|
||||
{
|
||||
key: 'email_suffix_mode',
|
||||
value: emailConfig.value.email_suffix_mode,
|
||||
description: '邮箱后缀限制模式(none/whitelist/blacklist)'
|
||||
},
|
||||
{
|
||||
key: 'email_suffix_list',
|
||||
value: emailConfig.value.email_suffix_list,
|
||||
description: '邮箱后缀列表'
|
||||
},
|
||||
]
|
||||
|
||||
const promises = configItems.map(item =>
|
||||
adminApi.updateSystemConfig(item.key, item.value, item.description)
|
||||
)
|
||||
|
||||
await Promise.all(promises)
|
||||
success('邮箱限制配置已保存')
|
||||
} catch (err) {
|
||||
error('保存配置失败')
|
||||
log.error('保存邮箱限制配置失败:', err)
|
||||
} finally {
|
||||
emailSuffixSaveLoading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
// 清除 SMTP 密码
|
||||
async function handleClearSmtpPassword() {
|
||||
try {
|
||||
await adminApi.deleteSystemConfig('smtp_password')
|
||||
smtpPasswordIsSet.value = false
|
||||
emailConfig.value.smtp_password = null
|
||||
success('SMTP 密码已清除')
|
||||
} catch (err) {
|
||||
error('清除密码失败')
|
||||
log.error('清除 SMTP 密码失败:', err)
|
||||
}
|
||||
}
|
||||
|
||||
// 测试 SMTP 连接
|
||||
async function handleTestSmtp() {
|
||||
testSmtpLoading.value = true
|
||||
|
||||
try {
|
||||
// 如果没有输入新密码,不发送(后端会使用数据库中的密码)
|
||||
const result = await adminApi.testSmtpConnection({
|
||||
smtp_host: emailConfig.value.smtp_host,
|
||||
smtp_port: emailConfig.value.smtp_port,
|
||||
smtp_user: emailConfig.value.smtp_user,
|
||||
smtp_password: emailConfig.value.smtp_password || undefined,
|
||||
smtp_use_tls: emailConfig.value.smtp_use_tls,
|
||||
smtp_use_ssl: emailConfig.value.smtp_use_ssl,
|
||||
smtp_from_email: emailConfig.value.smtp_from_email,
|
||||
smtp_from_name: emailConfig.value.smtp_from_name
|
||||
})
|
||||
if (result.success) {
|
||||
success('SMTP 连接测试成功')
|
||||
} else {
|
||||
error(result.message || '未知错误', 'SMTP 连接测试失败')
|
||||
}
|
||||
} catch (err: any) {
|
||||
log.error('SMTP 连接测试失败:', err)
|
||||
const errMsg = err.response?.data?.detail || err.message || '未知错误'
|
||||
error(errMsg, 'SMTP 连接测试失败')
|
||||
} finally {
|
||||
testSmtpLoading.value = false
|
||||
}
|
||||
}
|
||||
</script>
|
||||
@@ -713,6 +713,7 @@ import ProviderModelFormDialog from '@/features/providers/components/ProviderMod
|
||||
import type { Model } from '@/api/endpoints'
|
||||
import { useToast } from '@/composables/useToast'
|
||||
import { useConfirm } from '@/composables/useConfirm'
|
||||
import { useClipboard } from '@/composables/useClipboard'
|
||||
import { useRowClick } from '@/composables/useRowClick'
|
||||
import { parseApiError } from '@/utils/errorParser'
|
||||
import {
|
||||
@@ -736,6 +737,7 @@ import {
|
||||
updateGlobalModel,
|
||||
deleteGlobalModel,
|
||||
batchAssignToProviders,
|
||||
getGlobalModelProviders,
|
||||
type GlobalModelResponse,
|
||||
} from '@/api/global-models'
|
||||
import { log } from '@/utils/logger'
|
||||
@@ -743,6 +745,7 @@ import { getProvidersSummary } from '@/api/endpoints/providers'
|
||||
import { getAllCapabilities, type CapabilityDefinition } from '@/api/endpoints'
|
||||
|
||||
const { success, error: showError } = useToast()
|
||||
const { copyToClipboard } = useClipboard()
|
||||
|
||||
// 状态
|
||||
const loading = ref(false)
|
||||
@@ -1066,16 +1069,6 @@ function handleRowClick(event: MouseEvent, model: GlobalModelResponse) {
|
||||
selectModel(model)
|
||||
}
|
||||
|
||||
// 复制到剪贴板
|
||||
async function copyToClipboard(text: string) {
|
||||
try {
|
||||
await navigator.clipboard.writeText(text)
|
||||
success('已复制')
|
||||
} catch {
|
||||
showError('复制失败')
|
||||
}
|
||||
}
|
||||
|
||||
async function selectModel(model: GlobalModelResponse) {
|
||||
selectedModel.value = model
|
||||
detailTab.value = 'basic'
|
||||
@@ -1088,18 +1081,11 @@ async function selectModel(model: GlobalModelResponse) {
|
||||
async function loadModelProviders(_globalModelId: string) {
|
||||
loadingModelProviders.value = true
|
||||
try {
|
||||
// 使用 ModelCatalog API 获取详细的关联提供商信息
|
||||
const { getModelCatalog } = await import('@/api/endpoints')
|
||||
const catalogResponse = await getModelCatalog()
|
||||
// 使用新的 API 获取所有关联提供商(包括非活跃的)
|
||||
const response = await getGlobalModelProviders(_globalModelId)
|
||||
|
||||
// 查找当前 GlobalModel 对应的 catalog item
|
||||
const catalogItem = catalogResponse.models.find(
|
||||
m => m.global_model_name === selectedModel.value?.name
|
||||
)
|
||||
|
||||
if (catalogItem) {
|
||||
// 转换为展示格式,包含完整的模型实现信息
|
||||
selectedModelProviders.value = catalogItem.providers.map(p => ({
|
||||
// 转换为展示格式
|
||||
selectedModelProviders.value = response.providers.map(p => ({
|
||||
id: p.provider_id,
|
||||
model_id: p.model_id,
|
||||
display_name: p.provider_display_name || p.provider_name,
|
||||
@@ -1121,9 +1107,6 @@ async function loadModelProviders(_globalModelId: string) {
|
||||
supports_function_calling: p.supports_function_calling,
|
||||
supports_streaming: p.supports_streaming
|
||||
}))
|
||||
} else {
|
||||
selectedModelProviders.value = []
|
||||
}
|
||||
} catch (err: any) {
|
||||
log.error('加载关联提供商失败:', err)
|
||||
showError(parseApiError(err, '加载关联提供商失败'), '错误')
|
||||
|
||||
@@ -723,9 +723,19 @@ async function handleDeleteProvider(provider: ProviderWithEndpointsSummary) {
|
||||
// 切换提供商状态
|
||||
async function toggleProviderStatus(provider: ProviderWithEndpointsSummary) {
|
||||
try {
|
||||
await updateProvider(provider.id, { is_active: !provider.is_active })
|
||||
provider.is_active = !provider.is_active
|
||||
showSuccess(provider.is_active ? '提供商已启用' : '提供商已停用')
|
||||
const newStatus = !provider.is_active
|
||||
await updateProvider(provider.id, { is_active: newStatus })
|
||||
|
||||
// 更新抽屉内部的 provider 对象
|
||||
provider.is_active = newStatus
|
||||
|
||||
// 同时更新主页面 providers 数组中的对象,实现无感更新
|
||||
const targetProvider = providers.value.find(p => p.id === provider.id)
|
||||
if (targetProvider) {
|
||||
targetProvider.is_active = newStatus
|
||||
}
|
||||
|
||||
showSuccess(newStatus ? '提供商已启用' : '提供商已停用')
|
||||
} catch (err: any) {
|
||||
showError(err.response?.data?.detail || '操作失败', '错误')
|
||||
}
|
||||
|
||||
@@ -465,6 +465,29 @@
|
||||
</div>
|
||||
</CardSection>
|
||||
|
||||
<!-- 系统版本信息 -->
|
||||
<CardSection
|
||||
title="系统信息"
|
||||
description="当前系统版本和构建信息"
|
||||
>
|
||||
<div class="flex items-center gap-4">
|
||||
<div class="flex items-center gap-2">
|
||||
<Label class="text-sm font-medium text-muted-foreground">版本:</Label>
|
||||
<span
|
||||
v-if="systemVersion"
|
||||
class="text-sm font-mono"
|
||||
>
|
||||
{{ systemVersion }}
|
||||
</span>
|
||||
<span
|
||||
v-else
|
||||
class="text-sm text-muted-foreground"
|
||||
>
|
||||
加载中...
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</CardSection>
|
||||
</div>
|
||||
|
||||
<!-- 导入配置对话框 -->
|
||||
@@ -476,7 +499,7 @@
|
||||
<div class="space-y-4">
|
||||
<div
|
||||
v-if="importPreview"
|
||||
class="p-3 bg-muted rounded-lg text-sm"
|
||||
class="text-sm"
|
||||
>
|
||||
<p class="font-medium mb-2">
|
||||
配置预览
|
||||
@@ -558,7 +581,7 @@
|
||||
class="space-y-4"
|
||||
>
|
||||
<div class="grid grid-cols-2 gap-4 text-sm">
|
||||
<div class="p-3 bg-muted rounded-lg">
|
||||
<div>
|
||||
<p class="font-medium">
|
||||
全局模型
|
||||
</p>
|
||||
@@ -568,7 +591,7 @@
|
||||
跳过: {{ importResult.stats.global_models.skipped }}
|
||||
</p>
|
||||
</div>
|
||||
<div class="p-3 bg-muted rounded-lg">
|
||||
<div>
|
||||
<p class="font-medium">
|
||||
提供商
|
||||
</p>
|
||||
@@ -578,7 +601,7 @@
|
||||
跳过: {{ importResult.stats.providers.skipped }}
|
||||
</p>
|
||||
</div>
|
||||
<div class="p-3 bg-muted rounded-lg">
|
||||
<div>
|
||||
<p class="font-medium">
|
||||
端点
|
||||
</p>
|
||||
@@ -588,7 +611,7 @@
|
||||
跳过: {{ importResult.stats.endpoints.skipped }}
|
||||
</p>
|
||||
</div>
|
||||
<div class="p-3 bg-muted rounded-lg">
|
||||
<div>
|
||||
<p class="font-medium">
|
||||
API Keys
|
||||
</p>
|
||||
@@ -597,7 +620,7 @@
|
||||
跳过: {{ importResult.stats.keys.skipped }}
|
||||
</p>
|
||||
</div>
|
||||
<div class="p-3 bg-muted rounded-lg col-span-2">
|
||||
<div class="col-span-2">
|
||||
<p class="font-medium">
|
||||
模型配置
|
||||
</p>
|
||||
@@ -643,7 +666,7 @@
|
||||
<div class="space-y-4">
|
||||
<div
|
||||
v-if="importUsersPreview"
|
||||
class="p-3 bg-muted rounded-lg text-sm"
|
||||
class="text-sm"
|
||||
>
|
||||
<p class="font-medium mb-2">
|
||||
数据预览
|
||||
@@ -653,6 +676,9 @@
|
||||
<li>
|
||||
API Keys: {{ importUsersPreview.users?.reduce((sum: number, u: any) => sum + (u.api_keys?.length || 0), 0) }} 个
|
||||
</li>
|
||||
<li v-if="importUsersPreview.standalone_keys?.length">
|
||||
独立余额 Keys: {{ importUsersPreview.standalone_keys.length }} 个
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
|
||||
@@ -721,7 +747,7 @@
|
||||
class="space-y-4"
|
||||
>
|
||||
<div class="grid grid-cols-2 gap-4 text-sm">
|
||||
<div class="p-3 bg-muted rounded-lg">
|
||||
<div>
|
||||
<p class="font-medium">
|
||||
用户
|
||||
</p>
|
||||
@@ -731,7 +757,7 @@
|
||||
跳过: {{ importUsersResult.stats.users.skipped }}
|
||||
</p>
|
||||
</div>
|
||||
<div class="p-3 bg-muted rounded-lg">
|
||||
<div>
|
||||
<p class="font-medium">
|
||||
API Keys
|
||||
</p>
|
||||
@@ -740,6 +766,18 @@
|
||||
跳过: {{ importUsersResult.stats.api_keys.skipped }}
|
||||
</p>
|
||||
</div>
|
||||
<div
|
||||
v-if="importUsersResult.stats.standalone_keys"
|
||||
class="col-span-2"
|
||||
>
|
||||
<p class="font-medium">
|
||||
独立余额 Keys
|
||||
</p>
|
||||
<p class="text-muted-foreground">
|
||||
创建: {{ importUsersResult.stats.standalone_keys.created }},
|
||||
跳过: {{ importUsersResult.stats.standalone_keys.skipped }}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div
|
||||
@@ -770,7 +808,7 @@
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, computed, onMounted } from 'vue'
|
||||
import { ref, computed, onMounted, watch } from 'vue'
|
||||
import { Download, Upload } from 'lucide-vue-next'
|
||||
import Button from '@/components/ui/button.vue'
|
||||
import Input from '@/components/ui/input.vue'
|
||||
@@ -840,6 +878,9 @@ const importUsersResult = ref<UsersImportResponse | null>(null)
|
||||
const usersMergeMode = ref<'skip' | 'overwrite' | 'error'>('skip')
|
||||
const usersMergeModeSelectOpen = ref(false)
|
||||
|
||||
// 系统版本信息
|
||||
const systemVersion = ref<string>('')
|
||||
|
||||
const systemConfig = ref<SystemConfig>({
|
||||
// 基础配置
|
||||
default_user_quota_usd: 10.0,
|
||||
@@ -891,9 +932,21 @@ const sensitiveHeadersStr = computed({
|
||||
})
|
||||
|
||||
onMounted(async () => {
|
||||
await loadSystemConfig()
|
||||
await Promise.all([
|
||||
loadSystemConfig(),
|
||||
loadSystemVersion()
|
||||
])
|
||||
})
|
||||
|
||||
async function loadSystemVersion() {
|
||||
try {
|
||||
const data = await adminApi.getSystemVersion()
|
||||
systemVersion.value = data.version
|
||||
} catch (err) {
|
||||
log.error('加载系统版本失败:', err)
|
||||
}
|
||||
}
|
||||
|
||||
async function loadSystemConfig() {
|
||||
try {
|
||||
const configs = [
|
||||
@@ -1179,12 +1232,6 @@ function handleUsersFileSelect(event: Event) {
|
||||
const content = e.target?.result as string
|
||||
const data = JSON.parse(content) as UsersExportData
|
||||
|
||||
// 验证版本
|
||||
if (data.version !== '1.0') {
|
||||
error(`不支持的配置版本: ${data.version}`)
|
||||
return
|
||||
}
|
||||
|
||||
importUsersPreview.value = data
|
||||
usersMergeMode.value = 'skip'
|
||||
importUsersDialogOpen.value = true
|
||||
|
||||
@@ -701,6 +701,7 @@ import { ref, computed, onMounted, watch } from 'vue'
|
||||
import { useUsersStore } from '@/stores/users'
|
||||
import { useToast } from '@/composables/useToast'
|
||||
import { useConfirm } from '@/composables/useConfirm'
|
||||
import { useClipboard } from '@/composables/useClipboard'
|
||||
import { usageApi, type UsageByUser } from '@/api/usage'
|
||||
import { adminApi } from '@/api/admin'
|
||||
|
||||
@@ -748,6 +749,7 @@ import { log } from '@/utils/logger'
|
||||
|
||||
const { success, error } = useToast()
|
||||
const { confirmDanger, confirmWarning } = useConfirm()
|
||||
const { copyToClipboard } = useClipboard()
|
||||
const usersStore = useUsersStore()
|
||||
|
||||
// 用户表单对话框状态
|
||||
@@ -875,7 +877,8 @@ async function toggleUserStatus(user: any) {
|
||||
const action = user.is_active ? '禁用' : '启用'
|
||||
const confirmed = await confirmDanger(
|
||||
`确定要${action}用户 ${user.username} 吗?`,
|
||||
`${action}用户`
|
||||
`${action}用户`,
|
||||
action
|
||||
)
|
||||
|
||||
if (!confirmed) return
|
||||
@@ -884,7 +887,7 @@ async function toggleUserStatus(user: any) {
|
||||
await usersStore.updateUser(user.id, { is_active: !user.is_active })
|
||||
success(`用户已${action}`)
|
||||
} catch (err: any) {
|
||||
error(err.response?.data?.detail || '未知错误', `${action}用户失败`)
|
||||
error(err.response?.data?.error?.message || err.response?.data?.detail || '未知错误', `${action}用户失败`)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -955,7 +958,7 @@ async function handleUserFormSubmit(data: UserFormData & { password?: string })
|
||||
closeUserFormDialog()
|
||||
} catch (err: any) {
|
||||
const title = data.id ? '更新用户失败' : '创建用户失败'
|
||||
error(err.response?.data?.detail || '未知错误', title)
|
||||
error(err.response?.data?.error?.message || err.response?.data?.detail || '未知错误', title)
|
||||
} finally {
|
||||
userFormDialogRef.value?.setSaving(false)
|
||||
}
|
||||
@@ -989,7 +992,7 @@ async function createApiKey() {
|
||||
showNewApiKeyDialog.value = true
|
||||
await loadUserApiKeys(selectedUser.value.id)
|
||||
} catch (err: any) {
|
||||
error(err.response?.data?.detail || '未知错误', '创建 API Key 失败')
|
||||
error(err.response?.data?.error?.message || err.response?.data?.detail || '未知错误', '创建 API Key 失败')
|
||||
} finally {
|
||||
creatingApiKey.value = false
|
||||
}
|
||||
@@ -1000,12 +1003,7 @@ function selectApiKey() {
|
||||
}
|
||||
|
||||
async function copyApiKey() {
|
||||
try {
|
||||
await navigator.clipboard.writeText(newApiKey.value)
|
||||
success('API Key已复制到剪贴板')
|
||||
} catch {
|
||||
error('复制失败,请手动复制')
|
||||
}
|
||||
await copyToClipboard(newApiKey.value)
|
||||
}
|
||||
|
||||
async function closeNewApiKeyDialog() {
|
||||
@@ -1026,7 +1024,7 @@ async function deleteApiKey(apiKey: any) {
|
||||
await loadUserApiKeys(selectedUser.value.id)
|
||||
success('API Key已删除')
|
||||
} catch (err: any) {
|
||||
error(err.response?.data?.detail || '未知错误', '删除 API Key 失败')
|
||||
error(err.response?.data?.error?.message || err.response?.data?.detail || '未知错误', '删除 API Key 失败')
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1034,11 +1032,10 @@ async function copyFullKey(apiKey: any) {
|
||||
try {
|
||||
// 调用后端 API 获取完整密钥
|
||||
const response = await adminApi.getFullApiKey(apiKey.id)
|
||||
await navigator.clipboard.writeText(response.key)
|
||||
success('完整密钥已复制到剪贴板')
|
||||
await copyToClipboard(response.key)
|
||||
} catch (err: any) {
|
||||
log.error('复制密钥失败:', err)
|
||||
error(err.response?.data?.detail || '未知错误', '复制密钥失败')
|
||||
error(err.response?.data?.error?.message || err.response?.data?.detail || '未知错误', '复制密钥失败')
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1054,7 +1051,7 @@ async function resetQuota(user: any) {
|
||||
await usersStore.resetUserQuota(user.id)
|
||||
success('配额已重置')
|
||||
} catch (err: any) {
|
||||
error(err.response?.data?.detail || '未知错误', '重置配额失败')
|
||||
error(err.response?.data?.error?.message || err.response?.data?.detail || '未知错误', '重置配额失败')
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1070,7 +1067,7 @@ async function deleteUser(user: any) {
|
||||
await usersStore.deleteUser(user.id)
|
||||
success('用户已删除')
|
||||
} catch (err: any) {
|
||||
error(err.response?.data?.detail || '未知错误', '删除用户失败')
|
||||
error(err.response?.data?.error?.message || err.response?.data?.detail || '未知错误', '删除用户失败')
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
@@ -102,9 +102,9 @@
|
||||
<!-- Main Content -->
|
||||
<main class="relative z-10">
|
||||
<!-- Fixed Logo Container -->
|
||||
<div class="fixed inset-0 z-20 pointer-events-none flex items-center justify-center overflow-hidden">
|
||||
<div class="mt-4 fixed inset-0 z-20 pointer-events-none flex items-center justify-center overflow-hidden">
|
||||
<div
|
||||
class="transform-gpu logo-container"
|
||||
class="mt-16 transform-gpu logo-container"
|
||||
:class="[currentSection === SECTIONS.HOME ? 'home-section' : '', `logo-transition-${scrollDirection}`]"
|
||||
:style="fixedLogoStyle"
|
||||
>
|
||||
@@ -151,7 +151,7 @@
|
||||
class="min-h-screen snap-start flex items-center justify-center px-16 lg:px-20 py-20"
|
||||
>
|
||||
<div class="max-w-4xl mx-auto text-center">
|
||||
<div class="h-80 w-full mb-16" />
|
||||
<div class="h-80 w-full mb-16 mt-8" />
|
||||
<h1
|
||||
class="mb-6 text-5xl md:text-7xl font-bold text-[#191919] dark:text-white leading-tight transition-all duration-700"
|
||||
:style="getTitleStyle(SECTIONS.HOME)"
|
||||
@@ -166,7 +166,7 @@
|
||||
整合 Claude Code、Codex CLI、Gemini CLI 等多个 AI 编程助手
|
||||
</p>
|
||||
<button
|
||||
class="mt-16 transition-all duration-700 cursor-pointer hover:scale-110"
|
||||
class="mt-8 transition-all duration-700 cursor-pointer hover:scale-110"
|
||||
:style="getScrollIndicatorStyle(SECTIONS.HOME)"
|
||||
@click="scrollToSection(SECTIONS.CLAUDE)"
|
||||
>
|
||||
|
||||
@@ -145,10 +145,10 @@
|
||||
<DollarSign class="absolute top-3 right-3 h-3.5 w-3.5 sm:h-4 sm:w-4 text-muted-foreground" />
|
||||
<div class="pr-6">
|
||||
<p class="text-[9px] sm:text-[10px] font-semibold uppercase tracking-[0.2em] sm:tracking-[0.3em] text-muted-foreground">
|
||||
实际成本
|
||||
本月费用
|
||||
</p>
|
||||
<p class="mt-1.5 sm:mt-2 text-lg sm:text-xl font-semibold text-foreground">
|
||||
{{ formatCurrency(costStats.total_actual_cost) }}
|
||||
{{ formatCurrency(costStats.total_cost) }}
|
||||
</p>
|
||||
<Badge
|
||||
v-if="costStats.cost_savings > 0"
|
||||
@@ -162,14 +162,14 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 普通用户:缓存统计 -->
|
||||
<!-- 普通用户:月度统计 -->
|
||||
<div
|
||||
v-else-if="!isAdmin && cacheStats && cacheStats.total_cache_tokens > 0"
|
||||
v-else-if="!isAdmin && (hasCacheData || (userMonthlyCost !== null && userMonthlyCost > 0))"
|
||||
class="mt-6"
|
||||
>
|
||||
<div class="mb-3 flex items-center justify-between">
|
||||
<h3 class="text-sm font-medium text-foreground">
|
||||
本月缓存使用
|
||||
本月统计
|
||||
</h3>
|
||||
<Badge
|
||||
variant="outline"
|
||||
@@ -178,8 +178,16 @@
|
||||
Monthly
|
||||
</Badge>
|
||||
</div>
|
||||
<div class="grid grid-cols-2 gap-2 sm:gap-3 xl:grid-cols-4">
|
||||
<Card class="relative p-3 sm:p-4 border-book-cloth/30">
|
||||
<div
|
||||
class="grid gap-2 sm:gap-3"
|
||||
:class="[
|
||||
hasCacheData ? 'grid-cols-2 xl:grid-cols-4' : 'grid-cols-1 max-w-xs'
|
||||
]"
|
||||
>
|
||||
<Card
|
||||
v-if="cacheStats"
|
||||
class="relative p-3 sm:p-4 border-book-cloth/30"
|
||||
>
|
||||
<Database class="absolute top-3 right-3 h-3.5 w-3.5 sm:h-4 sm:w-4 text-muted-foreground" />
|
||||
<div class="pr-6">
|
||||
<p class="text-[9px] sm:text-[10px] font-semibold uppercase tracking-[0.2em] sm:tracking-[0.3em] text-muted-foreground">
|
||||
@@ -190,7 +198,10 @@
|
||||
</p>
|
||||
</div>
|
||||
</Card>
|
||||
<Card class="relative p-3 sm:p-4 border-kraft/30">
|
||||
<Card
|
||||
v-if="cacheStats"
|
||||
class="relative p-3 sm:p-4 border-kraft/30"
|
||||
>
|
||||
<Hash class="absolute top-3 right-3 h-3.5 w-3.5 sm:h-4 sm:w-4 text-muted-foreground" />
|
||||
<div class="pr-6">
|
||||
<p class="text-[9px] sm:text-[10px] font-semibold uppercase tracking-[0.2em] sm:tracking-[0.3em] text-muted-foreground">
|
||||
@@ -201,7 +212,10 @@
|
||||
</p>
|
||||
</div>
|
||||
</Card>
|
||||
<Card class="relative p-3 sm:p-4 border-book-cloth/25">
|
||||
<Card
|
||||
v-if="cacheStats"
|
||||
class="relative p-3 sm:p-4 border-book-cloth/25"
|
||||
>
|
||||
<Database class="absolute top-3 right-3 h-3.5 w-3.5 sm:h-4 sm:w-4 text-muted-foreground" />
|
||||
<div class="pr-6">
|
||||
<p class="text-[9px] sm:text-[10px] font-semibold uppercase tracking-[0.2em] sm:tracking-[0.3em] text-muted-foreground">
|
||||
@@ -213,19 +227,16 @@
|
||||
</div>
|
||||
</Card>
|
||||
<Card
|
||||
v-if="tokenBreakdown"
|
||||
v-if="userMonthlyCost !== null"
|
||||
class="relative p-3 sm:p-4 border-manilla/40"
|
||||
>
|
||||
<Hash class="absolute top-3 right-3 h-3.5 w-3.5 sm:h-4 sm:w-4 text-muted-foreground" />
|
||||
<DollarSign class="absolute top-3 right-3 h-3.5 w-3.5 sm:h-4 sm:w-4 text-muted-foreground" />
|
||||
<div class="pr-6">
|
||||
<p class="text-[9px] sm:text-[10px] font-semibold uppercase tracking-[0.2em] sm:tracking-[0.3em] text-muted-foreground">
|
||||
总Token
|
||||
本月费用
|
||||
</p>
|
||||
<p class="mt-1.5 sm:mt-2 text-lg sm:text-xl font-semibold text-foreground">
|
||||
{{ formatTokens((tokenBreakdown.input || 0) + (tokenBreakdown.output || 0)) }}
|
||||
</p>
|
||||
<p class="mt-0.5 sm:mt-1 text-[9px] sm:text-[10px] text-muted-foreground">
|
||||
输入 {{ formatTokens(tokenBreakdown.input || 0) }} / 输出 {{ formatTokens(tokenBreakdown.output || 0) }}
|
||||
{{ formatCurrency(userMonthlyCost) }}
|
||||
</p>
|
||||
</div>
|
||||
</Card>
|
||||
@@ -831,6 +842,12 @@ const cacheStats = ref<{
|
||||
total_cache_tokens: number
|
||||
} | null>(null)
|
||||
|
||||
const userMonthlyCost = ref<number | null>(null)
|
||||
|
||||
const hasCacheData = computed(() =>
|
||||
cacheStats.value && cacheStats.value.total_cache_tokens > 0
|
||||
)
|
||||
|
||||
const tokenBreakdown = ref<{
|
||||
input: number
|
||||
output: number
|
||||
@@ -1086,6 +1103,7 @@ async function loadDashboardData() {
|
||||
} else {
|
||||
if (statsData.cache_stats) cacheStats.value = statsData.cache_stats
|
||||
if (statsData.token_breakdown) tokenBreakdown.value = statsData.token_breakdown
|
||||
if (statsData.monthly_cost !== undefined) userMonthlyCost.value = statsData.monthly_cost
|
||||
}
|
||||
} finally {
|
||||
loading.value = false
|
||||
|
||||
@@ -5,6 +5,8 @@
|
||||
<ActivityHeatmapCard
|
||||
:data="activityHeatmapData"
|
||||
:title="isAdminPage ? '总体活跃天数' : '我的活跃天数'"
|
||||
:is-loading="isLoadingHeatmap"
|
||||
:has-error="heatmapError"
|
||||
/>
|
||||
<IntervalTimelineCard
|
||||
:title="isAdminPage ? '请求间隔时间线' : '我的请求间隔'"
|
||||
@@ -54,6 +56,7 @@
|
||||
:show-actual-cost="authStore.isAdmin"
|
||||
:loading="isLoadingRecords"
|
||||
:selected-period="selectedPeriod"
|
||||
:filter-search="filterSearch"
|
||||
:filter-user="filterUser"
|
||||
:filter-model="filterModel"
|
||||
:filter-provider="filterProvider"
|
||||
@@ -67,6 +70,7 @@
|
||||
:page-size-options="pageSizeOptions"
|
||||
:auto-refresh="globalAutoRefresh"
|
||||
@update:selected-period="handlePeriodChange"
|
||||
@update:filter-search="handleFilterSearchChange"
|
||||
@update:filter-user="handleFilterUserChange"
|
||||
@update:filter-model="handleFilterModelChange"
|
||||
@update:filter-provider="handleFilterProviderChange"
|
||||
@@ -112,8 +116,11 @@ import {
|
||||
import type { PeriodValue, FilterStatusValue } from '@/features/usage/types'
|
||||
import type { UserOption } from '@/features/usage/components/UsageRecordsTable.vue'
|
||||
import { log } from '@/utils/logger'
|
||||
import type { ActivityHeatmap } from '@/types/activity'
|
||||
import { useToast } from '@/composables/useToast'
|
||||
|
||||
const route = useRoute()
|
||||
const { warning } = useToast()
|
||||
const authStore = useAuthStore()
|
||||
|
||||
// 判断是否是管理员页面
|
||||
@@ -128,6 +135,7 @@ const pageSize = ref(20)
|
||||
const pageSizeOptions = [10, 20, 50, 100]
|
||||
|
||||
// 筛选状态
|
||||
const filterSearch = ref('')
|
||||
const filterUser = ref('__all__')
|
||||
const filterModel = ref('__all__')
|
||||
const filterProvider = ref('__all__')
|
||||
@@ -144,13 +152,35 @@ const {
|
||||
currentRecords,
|
||||
totalRecords,
|
||||
enhancedModelStats,
|
||||
activityHeatmapData,
|
||||
availableModels,
|
||||
availableProviders,
|
||||
loadStats,
|
||||
loadRecords
|
||||
} = useUsageData({ isAdminPage })
|
||||
|
||||
// 热力图状态
|
||||
const activityHeatmapData = ref<ActivityHeatmap | null>(null)
|
||||
const isLoadingHeatmap = ref(false)
|
||||
const heatmapError = ref(false)
|
||||
|
||||
// 加载热力图数据
|
||||
async function loadHeatmapData() {
|
||||
isLoadingHeatmap.value = true
|
||||
heatmapError.value = false
|
||||
try {
|
||||
if (isAdminPage.value) {
|
||||
activityHeatmapData.value = await usageApi.getActivityHeatmap()
|
||||
} else {
|
||||
activityHeatmapData.value = await meApi.getActivityHeatmap()
|
||||
}
|
||||
} catch (error) {
|
||||
log.error('加载热力图数据失败:', error)
|
||||
heatmapError.value = true
|
||||
} finally {
|
||||
isLoadingHeatmap.value = false
|
||||
}
|
||||
}
|
||||
|
||||
// 用户页面需要前端筛选
|
||||
const filteredRecords = computed(() => {
|
||||
if (!isAdminPage.value) {
|
||||
@@ -232,27 +262,40 @@ async function pollActiveRequests() {
|
||||
? await usageApi.getActiveRequests(activeRequestIds.value)
|
||||
: await meApi.getActiveRequests(idsParam)
|
||||
|
||||
// 检查是否有状态变化
|
||||
let hasChanges = false
|
||||
let shouldRefresh = false
|
||||
|
||||
for (const update of requests) {
|
||||
const record = currentRecords.value.find(r => r.id === update.id)
|
||||
if (record && record.status !== update.status) {
|
||||
hasChanges = true
|
||||
// 如果状态变为 completed 或 failed,需要刷新获取完整数据
|
||||
if (update.status === 'completed' || update.status === 'failed') {
|
||||
break
|
||||
if (!record) {
|
||||
// 后端返回了未知的活跃请求,触发刷新以获取完整数据
|
||||
shouldRefresh = true
|
||||
continue
|
||||
}
|
||||
// 否则只更新状态和 token 信息
|
||||
|
||||
// 状态变化:completed/failed 需要刷新获取完整数据
|
||||
if (record.status !== update.status) {
|
||||
record.status = update.status
|
||||
}
|
||||
if (update.status === 'completed' || update.status === 'failed') {
|
||||
shouldRefresh = true
|
||||
}
|
||||
|
||||
// 进行中状态也需要持续更新(provider/key/TTFB 可能在 streaming 后才落库)
|
||||
record.input_tokens = update.input_tokens
|
||||
record.output_tokens = update.output_tokens
|
||||
record.cost = update.cost
|
||||
record.response_time_ms = update.response_time_ms ?? undefined
|
||||
record.first_byte_time_ms = update.first_byte_time_ms ?? undefined
|
||||
// 管理员接口返回额外字段
|
||||
if ('provider' in update && typeof update.provider === 'string') {
|
||||
record.provider = update.provider
|
||||
}
|
||||
if ('api_key_name' in update) {
|
||||
record.api_key_name = typeof update.api_key_name === 'string' ? update.api_key_name : undefined
|
||||
}
|
||||
}
|
||||
|
||||
// 如果有请求完成或失败,刷新整个列表获取完整数据
|
||||
if (hasChanges && requests.some(r => r.status === 'completed' || r.status === 'failed')) {
|
||||
if (shouldRefresh) {
|
||||
await refreshData()
|
||||
}
|
||||
} catch (error) {
|
||||
@@ -301,6 +344,7 @@ function stopGlobalAutoRefresh() {
|
||||
function handleAutoRefreshChange(value: boolean) {
|
||||
globalAutoRefresh.value = value
|
||||
if (value) {
|
||||
refreshData() // 立即刷新一次
|
||||
startGlobalAutoRefresh()
|
||||
} else {
|
||||
stopGlobalAutoRefresh()
|
||||
@@ -334,16 +378,34 @@ const selectedRequestId = ref<string | null>(null)
|
||||
// 初始化加载
|
||||
onMounted(async () => {
|
||||
const dateRange = getDateRangeFromPeriod(selectedPeriod.value)
|
||||
await loadStats(dateRange)
|
||||
|
||||
// 管理员页面加载用户列表和第一页记录
|
||||
// 并行加载统计数据和热力图(使用 allSettled 避免其中一个失败影响另一个)
|
||||
const [statsResult, heatmapResult] = await Promise.allSettled([
|
||||
loadStats(dateRange),
|
||||
loadHeatmapData()
|
||||
])
|
||||
|
||||
// 检查加载结果并通知用户
|
||||
if (statsResult.status === 'rejected') {
|
||||
log.error('加载统计数据失败:', statsResult.reason)
|
||||
warning('统计数据加载失败,请刷新重试')
|
||||
}
|
||||
if (heatmapResult.status === 'rejected') {
|
||||
log.error('加载热力图数据失败:', heatmapResult.reason)
|
||||
// 热力图加载失败不提示,因为 UI 已显示占位符
|
||||
}
|
||||
|
||||
// 加载记录和用户列表
|
||||
if (isAdminPage.value) {
|
||||
// 并行加载用户列表和记录
|
||||
// 管理员页面:并行加载用户列表和记录
|
||||
const [users] = await Promise.all([
|
||||
usersApi.getAllUsers(),
|
||||
loadRecords({ page: currentPage.value, pageSize: pageSize.value }, getCurrentFilters())
|
||||
])
|
||||
availableUsers.value = users.map(u => ({ id: u.id, username: u.username, email: u.email }))
|
||||
} else {
|
||||
// 用户页面:加载记录
|
||||
await loadRecords({ page: currentPage.value, pageSize: pageSize.value }, getCurrentFilters())
|
||||
}
|
||||
})
|
||||
|
||||
@@ -354,34 +416,26 @@ async function handlePeriodChange(value: string) {
|
||||
|
||||
const dateRange = getDateRangeFromPeriod(selectedPeriod.value)
|
||||
await loadStats(dateRange)
|
||||
|
||||
if (isAdminPage.value) {
|
||||
await loadRecords({ page: 1, pageSize: pageSize.value }, getCurrentFilters())
|
||||
}
|
||||
}
|
||||
|
||||
// 处理分页变化
|
||||
async function handlePageChange(page: number) {
|
||||
currentPage.value = page
|
||||
|
||||
if (isAdminPage.value) {
|
||||
await loadRecords({ page, pageSize: pageSize.value }, getCurrentFilters())
|
||||
}
|
||||
}
|
||||
|
||||
// 处理每页大小变化
|
||||
async function handlePageSizeChange(size: number) {
|
||||
pageSize.value = size
|
||||
currentPage.value = 1 // 重置到第一页
|
||||
|
||||
if (isAdminPage.value) {
|
||||
await loadRecords({ page: 1, pageSize: size }, getCurrentFilters())
|
||||
}
|
||||
}
|
||||
|
||||
// 获取当前筛选参数
|
||||
function getCurrentFilters() {
|
||||
return {
|
||||
search: filterSearch.value.trim() || undefined,
|
||||
user_id: filterUser.value !== '__all__' ? filterUser.value : undefined,
|
||||
model: filterModel.value !== '__all__' ? filterModel.value : undefined,
|
||||
provider: filterProvider.value !== '__all__' ? filterProvider.value : undefined,
|
||||
@@ -390,6 +444,13 @@ function getCurrentFilters() {
|
||||
}
|
||||
|
||||
// 处理筛选变化
|
||||
async function handleFilterSearchChange(value: string) {
|
||||
filterSearch.value = value
|
||||
currentPage.value = 1
|
||||
|
||||
await loadRecords({ page: 1, pageSize: pageSize.value }, getCurrentFilters())
|
||||
}
|
||||
|
||||
async function handleFilterUserChange(value: string) {
|
||||
filterUser.value = value
|
||||
currentPage.value = 1 // 重置到第一页
|
||||
@@ -430,11 +491,8 @@ async function handleFilterStatusChange(value: string) {
|
||||
async function refreshData() {
|
||||
const dateRange = getDateRangeFromPeriod(selectedPeriod.value)
|
||||
await loadStats(dateRange)
|
||||
|
||||
if (isAdminPage.value) {
|
||||
await loadRecords({ page: currentPage.value, pageSize: pageSize.value }, getCurrentFilters())
|
||||
}
|
||||
}
|
||||
|
||||
// 显示请求详情
|
||||
function showRequestDetail(id: string) {
|
||||
|
||||
@@ -342,6 +342,7 @@ import {
|
||||
Plus,
|
||||
} from 'lucide-vue-next'
|
||||
import { useToast } from '@/composables/useToast'
|
||||
import { useClipboard } from '@/composables/useClipboard'
|
||||
import {
|
||||
Card,
|
||||
Table,
|
||||
@@ -370,6 +371,7 @@ import { useRowClick } from '@/composables/useRowClick'
|
||||
import { log } from '@/utils/logger'
|
||||
|
||||
const { success, error: showError } = useToast()
|
||||
const { copyToClipboard } = useClipboard()
|
||||
|
||||
// 状态
|
||||
const loading = ref(false)
|
||||
@@ -565,16 +567,6 @@ function hasTieredPricing(model: PublicGlobalModel): boolean {
|
||||
return (tiered?.tiers?.length || 0) > 1
|
||||
}
|
||||
|
||||
async function copyToClipboard(text: string) {
|
||||
try {
|
||||
await navigator.clipboard.writeText(text)
|
||||
success('已复制')
|
||||
} catch (err) {
|
||||
log.error('复制失败:', err)
|
||||
showError('复制失败')
|
||||
}
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
refreshData()
|
||||
})
|
||||
|
||||
@@ -477,8 +477,8 @@ async function changePassword() {
|
||||
return
|
||||
}
|
||||
|
||||
if (passwordForm.value.new_password.length < 8) {
|
||||
showError('密码长度至少8位')
|
||||
if (passwordForm.value.new_password.length < 6) {
|
||||
showError('密码长度至少6位')
|
||||
return
|
||||
}
|
||||
|
||||
|
||||
@@ -352,6 +352,7 @@ import {
|
||||
} from 'lucide-vue-next'
|
||||
import { useEscapeKey } from '@/composables/useEscapeKey'
|
||||
import { useToast } from '@/composables/useToast'
|
||||
import { useClipboard } from '@/composables/useClipboard'
|
||||
import Card from '@/components/ui/card.vue'
|
||||
import Badge from '@/components/ui/badge.vue'
|
||||
import Button from '@/components/ui/button.vue'
|
||||
@@ -375,6 +376,7 @@ const emit = defineEmits<{
|
||||
}>()
|
||||
|
||||
const { success: showSuccess, error: showError } = useToast()
|
||||
const { copyToClipboard } = useClipboard()
|
||||
|
||||
interface Props {
|
||||
model: PublicGlobalModel | null
|
||||
@@ -408,15 +410,6 @@ function handleClose() {
|
||||
emit('update:open', false)
|
||||
}
|
||||
|
||||
async function copyToClipboard(text: string) {
|
||||
try {
|
||||
await navigator.clipboard.writeText(text)
|
||||
showSuccess('已复制')
|
||||
} catch {
|
||||
showError('复制失败')
|
||||
}
|
||||
}
|
||||
|
||||
function getFirstTierPrice(
|
||||
tieredPricing: TieredPricingConfig | undefined | null,
|
||||
priceKey: 'input_price_per_1m' | 'output_price_per_1m' | 'cache_creation_price_per_1m' | 'cache_read_price_per_1m'
|
||||
|
||||
@@ -13,7 +13,7 @@ authors = [
|
||||
classifiers = [
|
||||
"Development Status :: 4 - Beta",
|
||||
"Intended Audience :: Developers",
|
||||
"License :: OSI Approved :: MIT License",
|
||||
"License :: Other/Proprietary License",
|
||||
"Programming Language :: Python :: 3",
|
||||
"Programming Language :: Python :: 3.8",
|
||||
"Programming Language :: Python :: 3.9",
|
||||
|
||||
@@ -3,22 +3,64 @@
|
||||
独立余额Key:不关联用户配额,有独立余额限制,用于给非注册用户使用。
|
||||
"""
|
||||
|
||||
from datetime import datetime, timezone
|
||||
import os
|
||||
from datetime import datetime, timedelta, timezone
|
||||
from typing import Optional
|
||||
from zoneinfo import ZoneInfo
|
||||
|
||||
from fastapi import APIRouter, Depends, HTTPException, Query, Request
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from src.api.base.admin_adapter import AdminApiAdapter
|
||||
from src.api.base.pipeline import ApiRequestPipeline
|
||||
from src.core.exceptions import NotFoundException
|
||||
from src.core.exceptions import InvalidRequestException, NotFoundException
|
||||
from src.core.logger import logger
|
||||
from src.database import get_db
|
||||
from src.models.api import CreateApiKeyRequest
|
||||
from src.models.database import ApiKey, User
|
||||
from src.models.database import ApiKey
|
||||
from src.services.user.apikey import ApiKeyService
|
||||
|
||||
|
||||
# 应用时区配置,默认为 Asia/Shanghai
|
||||
APP_TIMEZONE = ZoneInfo(os.getenv("APP_TIMEZONE", "Asia/Shanghai"))
|
||||
|
||||
|
||||
def parse_expiry_date(date_str: Optional[str]) -> Optional[datetime]:
|
||||
"""解析过期日期字符串为 datetime 对象。
|
||||
|
||||
Args:
|
||||
date_str: 日期字符串,支持 "YYYY-MM-DD" 或 ISO 格式
|
||||
|
||||
Returns:
|
||||
datetime 对象(当天 23:59:59.999999,应用时区),或 None 如果输入为空
|
||||
|
||||
Raises:
|
||||
BadRequestException: 日期格式无效
|
||||
"""
|
||||
if not date_str or not date_str.strip():
|
||||
return None
|
||||
|
||||
date_str = date_str.strip()
|
||||
|
||||
# 尝试 YYYY-MM-DD 格式
|
||||
try:
|
||||
parsed_date = datetime.strptime(date_str, "%Y-%m-%d")
|
||||
# 设置为当天结束时间 (23:59:59.999999,应用时区)
|
||||
return parsed_date.replace(
|
||||
hour=23, minute=59, second=59, microsecond=999999, tzinfo=APP_TIMEZONE
|
||||
)
|
||||
except ValueError:
|
||||
pass
|
||||
|
||||
# 尝试完整 ISO 格式
|
||||
try:
|
||||
return datetime.fromisoformat(date_str.replace("Z", "+00:00"))
|
||||
except ValueError:
|
||||
pass
|
||||
|
||||
raise InvalidRequestException(f"无效的日期格式: {date_str},请使用 YYYY-MM-DD 格式")
|
||||
|
||||
|
||||
router = APIRouter(prefix="/api/admin/api-keys", tags=["Admin - API Keys (Standalone)"])
|
||||
pipeline = ApiRequestPipeline()
|
||||
|
||||
@@ -215,6 +257,9 @@ class AdminCreateStandaloneKeyAdapter(AdminApiAdapter):
|
||||
# 独立Key需要关联到管理员用户(从context获取)
|
||||
admin_user_id = context.user.id
|
||||
|
||||
# 解析过期时间(优先使用 expires_at,其次使用 expire_days)
|
||||
expires_at_dt = parse_expiry_date(self.key_data.expires_at)
|
||||
|
||||
# 创建独立Key
|
||||
api_key, plain_key = ApiKeyService.create_api_key(
|
||||
db=db,
|
||||
@@ -224,7 +269,8 @@ class AdminCreateStandaloneKeyAdapter(AdminApiAdapter):
|
||||
allowed_api_formats=self.key_data.allowed_api_formats,
|
||||
allowed_models=self.key_data.allowed_models,
|
||||
rate_limit=self.key_data.rate_limit, # None 表示不限制
|
||||
expire_days=self.key_data.expire_days,
|
||||
expire_days=self.key_data.expire_days, # 兼容旧版
|
||||
expires_at=expires_at_dt, # 优先使用
|
||||
initial_balance_usd=self.key_data.initial_balance_usd,
|
||||
is_standalone=True, # 标记为独立Key
|
||||
auto_delete_on_expiry=self.key_data.auto_delete_on_expiry,
|
||||
@@ -270,7 +316,8 @@ class AdminUpdateApiKeyAdapter(AdminApiAdapter):
|
||||
update_data = {}
|
||||
if self.key_data.name is not None:
|
||||
update_data["name"] = self.key_data.name
|
||||
if self.key_data.rate_limit is not None:
|
||||
# rate_limit: 显式传递时更新(包括 null 表示无限制)
|
||||
if "rate_limit" in self.key_data.model_fields_set:
|
||||
update_data["rate_limit"] = self.key_data.rate_limit
|
||||
if (
|
||||
hasattr(self.key_data, "auto_delete_on_expiry")
|
||||
@@ -287,18 +334,20 @@ class AdminUpdateApiKeyAdapter(AdminApiAdapter):
|
||||
update_data["allowed_models"] = self.key_data.allowed_models
|
||||
|
||||
# 处理过期时间
|
||||
if self.key_data.expire_days is not None:
|
||||
if self.key_data.expire_days > 0:
|
||||
from datetime import timedelta
|
||||
|
||||
# 优先使用 expires_at(如果显式传递且有值)
|
||||
if self.key_data.expires_at and self.key_data.expires_at.strip():
|
||||
update_data["expires_at"] = parse_expiry_date(self.key_data.expires_at)
|
||||
elif "expires_at" in self.key_data.model_fields_set:
|
||||
# expires_at 明确传递为 null 或空字符串,设为永不过期
|
||||
update_data["expires_at"] = None
|
||||
# 兼容旧版 expire_days
|
||||
elif "expire_days" in self.key_data.model_fields_set:
|
||||
if self.key_data.expire_days is not None and self.key_data.expire_days > 0:
|
||||
update_data["expires_at"] = datetime.now(timezone.utc) + timedelta(
|
||||
days=self.key_data.expire_days
|
||||
)
|
||||
else:
|
||||
# expire_days = 0 或负数表示永不过期
|
||||
update_data["expires_at"] = None
|
||||
elif hasattr(self.key_data, "expire_days") and self.key_data.expire_days is None:
|
||||
# 明确传递 None,设为永不过期
|
||||
# expire_days = None/0/负数 表示永不过期
|
||||
update_data["expires_at"] = None
|
||||
|
||||
# 使用 ApiKeyService 更新
|
||||
|
||||
@@ -80,6 +80,17 @@ async def get_keys_grouped_by_format(
|
||||
return await pipeline.run(adapter=adapter, http_request=request, db=db, mode=adapter.mode)
|
||||
|
||||
|
||||
@router.get("/keys/{key_id}/reveal")
|
||||
async def reveal_endpoint_key(
|
||||
key_id: str,
|
||||
request: Request,
|
||||
db: Session = Depends(get_db),
|
||||
) -> dict:
|
||||
"""获取完整的 API Key(用于查看和复制)"""
|
||||
adapter = AdminRevealEndpointKeyAdapter(key_id=key_id)
|
||||
return await pipeline.run(adapter=adapter, http_request=request, db=db, mode=adapter.mode)
|
||||
|
||||
|
||||
@router.delete("/keys/{key_id}")
|
||||
async def delete_endpoint_key(
|
||||
key_id: str,
|
||||
@@ -293,6 +304,30 @@ class AdminUpdateEndpointKeyAdapter(AdminApiAdapter):
|
||||
return EndpointAPIKeyResponse(**response_dict)
|
||||
|
||||
|
||||
@dataclass
|
||||
class AdminRevealEndpointKeyAdapter(AdminApiAdapter):
|
||||
"""获取完整的 API Key(用于查看和复制)"""
|
||||
|
||||
key_id: str
|
||||
|
||||
async def handle(self, context): # type: ignore[override]
|
||||
db = context.db
|
||||
key = db.query(ProviderAPIKey).filter(ProviderAPIKey.id == self.key_id).first()
|
||||
if not key:
|
||||
raise NotFoundException(f"Key {self.key_id} 不存在")
|
||||
|
||||
try:
|
||||
decrypted_key = crypto_service.decrypt(key.api_key)
|
||||
except Exception as e:
|
||||
logger.error(f"解密 Key 失败: ID={self.key_id}, Error={e}")
|
||||
raise InvalidRequestException(
|
||||
"无法解密 API Key,可能是加密密钥已更改。请重新添加该密钥。"
|
||||
)
|
||||
|
||||
logger.info(f"[REVEAL] 查看完整 Key: ID={self.key_id}, Name={key.name}")
|
||||
return {"api_key": decrypted_key}
|
||||
|
||||
|
||||
@dataclass
|
||||
class AdminDeleteEndpointKeyAdapter(AdminApiAdapter):
|
||||
key_id: str
|
||||
|
||||
@@ -206,6 +206,7 @@ class AdminCreateProviderEndpointAdapter(AdminApiAdapter):
|
||||
provider_id=self.provider_id,
|
||||
api_format=self.endpoint_data.api_format,
|
||||
base_url=self.endpoint_data.base_url,
|
||||
custom_path=self.endpoint_data.custom_path,
|
||||
headers=self.endpoint_data.headers,
|
||||
timeout=self.endpoint_data.timeout,
|
||||
max_retries=self.endpoint_data.max_retries,
|
||||
|
||||
@@ -5,7 +5,7 @@ GlobalModel Admin API
|
||||
"""
|
||||
|
||||
from dataclasses import dataclass
|
||||
from typing import List, Optional
|
||||
from typing import Optional
|
||||
|
||||
from fastapi import APIRouter, Depends, Query, Request
|
||||
from sqlalchemy.orm import Session
|
||||
@@ -19,9 +19,11 @@ from src.models.pydantic_models import (
|
||||
BatchAssignToProvidersResponse,
|
||||
GlobalModelCreate,
|
||||
GlobalModelListResponse,
|
||||
GlobalModelProvidersResponse,
|
||||
GlobalModelResponse,
|
||||
GlobalModelUpdate,
|
||||
GlobalModelWithStats,
|
||||
ModelCatalogProviderDetail,
|
||||
)
|
||||
from src.services.model.global_model import GlobalModelService
|
||||
|
||||
@@ -108,6 +110,17 @@ async def batch_assign_to_providers(
|
||||
return await pipeline.run(adapter=adapter, http_request=request, db=db, mode=adapter.mode)
|
||||
|
||||
|
||||
@router.get("/{global_model_id}/providers", response_model=GlobalModelProvidersResponse)
|
||||
async def get_global_model_providers(
|
||||
request: Request,
|
||||
global_model_id: str,
|
||||
db: Session = Depends(get_db),
|
||||
) -> GlobalModelProvidersResponse:
|
||||
"""获取 GlobalModel 的所有关联提供商(包括非活跃的)"""
|
||||
adapter = AdminGetGlobalModelProvidersAdapter(global_model_id=global_model_id)
|
||||
return await pipeline.run(adapter=adapter, http_request=request, db=db, mode=adapter.mode)
|
||||
|
||||
|
||||
# ========== Adapters ==========
|
||||
|
||||
|
||||
@@ -133,20 +146,25 @@ class AdminListGlobalModelsAdapter(AdminApiAdapter):
|
||||
search=self.search,
|
||||
)
|
||||
|
||||
# 为每个 GlobalModel 添加统计数据
|
||||
# 一次性查询所有 GlobalModel 的 provider_count(优化 N+1 问题)
|
||||
model_ids = [gm.id for gm in models]
|
||||
provider_counts = {}
|
||||
if model_ids:
|
||||
count_results = (
|
||||
context.db.query(
|
||||
Model.global_model_id, func.count(func.distinct(Model.provider_id))
|
||||
)
|
||||
.filter(Model.global_model_id.in_(model_ids))
|
||||
.group_by(Model.global_model_id)
|
||||
.all()
|
||||
)
|
||||
provider_counts = {gm_id: count for gm_id, count in count_results}
|
||||
|
||||
# 构建响应
|
||||
model_responses = []
|
||||
for gm in models:
|
||||
# 统计关联的 Model 数量(去重 Provider)
|
||||
provider_count = (
|
||||
context.db.query(func.count(func.distinct(Model.provider_id)))
|
||||
.filter(Model.global_model_id == gm.id)
|
||||
.scalar()
|
||||
or 0
|
||||
)
|
||||
|
||||
response = GlobalModelResponse.model_validate(gm)
|
||||
response.provider_count = provider_count
|
||||
# usage_count 直接从 GlobalModel 表读取,已在 model_validate 中自动映射
|
||||
response.provider_count = provider_counts.get(gm.id, 0)
|
||||
model_responses.append(response)
|
||||
|
||||
return GlobalModelListResponse(
|
||||
@@ -275,3 +293,61 @@ class AdminBatchAssignToProvidersAdapter(AdminApiAdapter):
|
||||
logger.info(f"批量为 Provider 添加 GlobalModel: global_model_id={self.global_model_id} success={len(result['success'])} errors={len(result['errors'])}")
|
||||
|
||||
return BatchAssignToProvidersResponse(**result)
|
||||
|
||||
|
||||
@dataclass
|
||||
class AdminGetGlobalModelProvidersAdapter(AdminApiAdapter):
|
||||
"""获取 GlobalModel 的所有关联提供商(包括非活跃的)"""
|
||||
|
||||
global_model_id: str
|
||||
|
||||
async def handle(self, context): # type: ignore[override]
|
||||
from sqlalchemy.orm import joinedload
|
||||
|
||||
from src.models.database import Model
|
||||
|
||||
global_model = GlobalModelService.get_global_model(context.db, self.global_model_id)
|
||||
|
||||
# 获取所有关联的 Model(包括非活跃的)
|
||||
models = (
|
||||
context.db.query(Model)
|
||||
.options(joinedload(Model.provider), joinedload(Model.global_model))
|
||||
.filter(Model.global_model_id == global_model.id)
|
||||
.all()
|
||||
)
|
||||
|
||||
provider_entries = []
|
||||
for model in models:
|
||||
provider = model.provider
|
||||
if not provider:
|
||||
continue
|
||||
|
||||
effective_tiered = model.get_effective_tiered_pricing()
|
||||
tier_count = len(effective_tiered.get("tiers", [])) if effective_tiered else 1
|
||||
|
||||
provider_entries.append(
|
||||
ModelCatalogProviderDetail(
|
||||
provider_id=provider.id,
|
||||
provider_name=provider.name,
|
||||
provider_display_name=provider.display_name,
|
||||
model_id=model.id,
|
||||
target_model=model.provider_model_name,
|
||||
input_price_per_1m=model.get_effective_input_price(),
|
||||
output_price_per_1m=model.get_effective_output_price(),
|
||||
cache_creation_price_per_1m=model.get_effective_cache_creation_price(),
|
||||
cache_read_price_per_1m=model.get_effective_cache_read_price(),
|
||||
cache_1h_creation_price_per_1m=model.get_effective_1h_cache_creation_price(),
|
||||
price_per_request=model.get_effective_price_per_request(),
|
||||
effective_tiered_pricing=effective_tiered,
|
||||
tier_count=tier_count,
|
||||
supports_vision=model.get_effective_supports_vision(),
|
||||
supports_function_calling=model.get_effective_supports_function_calling(),
|
||||
supports_streaming=model.get_effective_supports_streaming(),
|
||||
is_active=bool(model.is_active),
|
||||
)
|
||||
)
|
||||
|
||||
return GlobalModelProvidersResponse(
|
||||
providers=provider_entries,
|
||||
total=len(provider_entries),
|
||||
)
|
||||
|
||||
@@ -32,6 +32,17 @@ class ModelsQueryRequest(BaseModel):
|
||||
api_key_id: Optional[str] = None
|
||||
|
||||
|
||||
class TestModelRequest(BaseModel):
|
||||
"""模型测试请求"""
|
||||
|
||||
provider_id: str
|
||||
model_name: str
|
||||
api_key_id: Optional[str] = None
|
||||
stream: bool = False
|
||||
message: Optional[str] = "你好"
|
||||
api_format: Optional[str] = None # 指定使用的API格式,如果不指定则使用端点的默认格式
|
||||
|
||||
|
||||
# ============ API Endpoints ============
|
||||
|
||||
|
||||
@@ -206,3 +217,228 @@ async def query_available_models(
|
||||
"display_name": provider.display_name,
|
||||
},
|
||||
}
|
||||
|
||||
|
||||
@router.post("/test-model")
|
||||
async def test_model(
|
||||
request: TestModelRequest,
|
||||
db: Session = Depends(get_db),
|
||||
current_user: User = Depends(get_current_user),
|
||||
):
|
||||
"""
|
||||
测试模型连接性
|
||||
|
||||
向指定提供商的指定模型发送测试请求,验证模型是否可用
|
||||
|
||||
Args:
|
||||
request: 测试请求
|
||||
|
||||
Returns:
|
||||
测试结果
|
||||
"""
|
||||
# 获取提供商及其端点
|
||||
provider = (
|
||||
db.query(Provider)
|
||||
.options(joinedload(Provider.endpoints).joinedload(ProviderEndpoint.api_keys))
|
||||
.filter(Provider.id == request.provider_id)
|
||||
.first()
|
||||
)
|
||||
|
||||
if not provider:
|
||||
raise HTTPException(status_code=404, detail="Provider not found")
|
||||
|
||||
# 找到合适的端点和API Key
|
||||
endpoint_config = None
|
||||
endpoint = None
|
||||
api_key = None
|
||||
|
||||
if request.api_key_id:
|
||||
# 使用指定的API Key
|
||||
for ep in provider.endpoints:
|
||||
for key in ep.api_keys:
|
||||
if key.id == request.api_key_id and key.is_active and ep.is_active:
|
||||
endpoint = ep
|
||||
api_key = key
|
||||
break
|
||||
if endpoint:
|
||||
break
|
||||
else:
|
||||
# 使用第一个可用的端点和密钥
|
||||
for ep in provider.endpoints:
|
||||
if not ep.is_active or not ep.api_keys:
|
||||
continue
|
||||
for key in ep.api_keys:
|
||||
if key.is_active:
|
||||
endpoint = ep
|
||||
api_key = key
|
||||
break
|
||||
if endpoint:
|
||||
break
|
||||
|
||||
if not endpoint or not api_key:
|
||||
raise HTTPException(status_code=404, detail="No active endpoint or API key found")
|
||||
|
||||
try:
|
||||
api_key_value = crypto_service.decrypt(api_key.api_key)
|
||||
except Exception as e:
|
||||
logger.error(f"[test-model] Failed to decrypt API key: {e}")
|
||||
raise HTTPException(status_code=500, detail="Failed to decrypt API key")
|
||||
|
||||
# 构建请求配置
|
||||
endpoint_config = {
|
||||
"api_key": api_key_value,
|
||||
"api_key_id": api_key.id, # 添加API Key ID用于用量记录
|
||||
"base_url": endpoint.base_url,
|
||||
"api_format": endpoint.api_format,
|
||||
"extra_headers": endpoint.headers,
|
||||
"timeout": endpoint.timeout or 30.0,
|
||||
}
|
||||
|
||||
try:
|
||||
# 获取对应的 Adapter 类
|
||||
adapter_class = _get_adapter_for_format(endpoint.api_format)
|
||||
if not adapter_class:
|
||||
return {
|
||||
"success": False,
|
||||
"error": f"Unknown API format: {endpoint.api_format}",
|
||||
"provider": {
|
||||
"id": provider.id,
|
||||
"name": provider.name,
|
||||
"display_name": provider.display_name,
|
||||
},
|
||||
"model": request.model_name,
|
||||
}
|
||||
|
||||
logger.debug(f"[test-model] 使用 Adapter: {adapter_class.__name__}")
|
||||
logger.debug(f"[test-model] 端点 API Format: {endpoint.api_format}")
|
||||
|
||||
# 如果请求指定了 api_format,优先使用它
|
||||
target_api_format = request.api_format or endpoint.api_format
|
||||
if request.api_format and request.api_format != endpoint.api_format:
|
||||
logger.debug(f"[test-model] 请求指定 API Format: {request.api_format}")
|
||||
# 重新获取适配器
|
||||
adapter_class = _get_adapter_for_format(request.api_format)
|
||||
if not adapter_class:
|
||||
return {
|
||||
"success": False,
|
||||
"error": f"Unknown API format: {request.api_format}",
|
||||
"provider": {
|
||||
"id": provider.id,
|
||||
"name": provider.name,
|
||||
"display_name": provider.display_name,
|
||||
},
|
||||
"model": request.model_name,
|
||||
}
|
||||
logger.debug(f"[test-model] 重新选择 Adapter: {adapter_class.__name__}")
|
||||
|
||||
# 准备测试请求数据
|
||||
check_request = {
|
||||
"model": request.model_name,
|
||||
"messages": [
|
||||
{"role": "user", "content": request.message or "Hello! This is a test message."}
|
||||
],
|
||||
"max_tokens": 30,
|
||||
"temperature": 0.7,
|
||||
}
|
||||
|
||||
# 发送测试请求
|
||||
async with httpx.AsyncClient(timeout=endpoint_config["timeout"]) as client:
|
||||
# 非流式测试
|
||||
logger.debug(f"[test-model] 开始非流式测试...")
|
||||
|
||||
response = await adapter_class.check_endpoint(
|
||||
client,
|
||||
endpoint_config["base_url"],
|
||||
endpoint_config["api_key"],
|
||||
check_request,
|
||||
endpoint_config.get("extra_headers"),
|
||||
# 用量计算参数(现在强制记录)
|
||||
db=db,
|
||||
user=current_user,
|
||||
provider_name=provider.name,
|
||||
provider_id=provider.id,
|
||||
api_key_id=endpoint_config.get("api_key_id"),
|
||||
model_name=request.model_name,
|
||||
)
|
||||
|
||||
# 记录提供商返回信息
|
||||
logger.debug(f"[test-model] 非流式测试结果:")
|
||||
logger.debug(f"[test-model] Status Code: {response.get('status_code')}")
|
||||
logger.debug(f"[test-model] Response Headers: {response.get('headers', {})}")
|
||||
response_data = response.get('response', {})
|
||||
response_body = response_data.get('response_body', {})
|
||||
logger.debug(f"[test-model] Response Data: {response_data}")
|
||||
logger.debug(f"[test-model] Response Body: {response_body}")
|
||||
# 尝试解析 response_body (通常是 JSON 字符串)
|
||||
parsed_body = response_body
|
||||
import json
|
||||
if isinstance(response_body, str):
|
||||
try:
|
||||
parsed_body = json.loads(response_body)
|
||||
except json.JSONDecodeError:
|
||||
pass
|
||||
|
||||
if isinstance(parsed_body, dict) and 'error' in parsed_body:
|
||||
error_obj = parsed_body['error']
|
||||
# 兼容 error 可能是字典或字符串的情况
|
||||
if isinstance(error_obj, dict):
|
||||
logger.debug(f"[test-model] Error Message: {error_obj.get('message')}")
|
||||
raise HTTPException(status_code=500, detail=error_obj.get('message'))
|
||||
else:
|
||||
logger.debug(f"[test-model] Error: {error_obj}")
|
||||
raise HTTPException(status_code=500, detail=error_obj)
|
||||
elif 'error' in response:
|
||||
logger.debug(f"[test-model] Error: {response['error']}")
|
||||
raise HTTPException(status_code=500, detail=response['error'])
|
||||
else:
|
||||
# 如果有选择或消息,记录内容预览
|
||||
if isinstance(response_data, dict):
|
||||
if 'choices' in response_data and response_data['choices']:
|
||||
choice = response_data['choices'][0]
|
||||
if 'message' in choice:
|
||||
content = choice['message'].get('content', '')
|
||||
logger.debug(f"[test-model] Content Preview: {content[:200]}...")
|
||||
elif 'content' in response_data and response_data['content']:
|
||||
content = str(response_data['content'])
|
||||
logger.debug(f"[test-model] Content Preview: {content[:200]}...")
|
||||
|
||||
# 检查测试是否成功(基于HTTP状态码)
|
||||
status_code = response.get('status_code', 0)
|
||||
is_success = status_code == 200 and 'error' not in response
|
||||
|
||||
return {
|
||||
"success": is_success,
|
||||
"data": {
|
||||
"stream": False,
|
||||
"response": response,
|
||||
},
|
||||
"provider": {
|
||||
"id": provider.id,
|
||||
"name": provider.name,
|
||||
"display_name": provider.display_name,
|
||||
},
|
||||
"model": request.model_name,
|
||||
"endpoint": {
|
||||
"id": endpoint.id,
|
||||
"api_format": endpoint.api_format,
|
||||
"base_url": endpoint.base_url,
|
||||
},
|
||||
}
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"[test-model] Error testing model {request.model_name}: {e}")
|
||||
return {
|
||||
"success": False,
|
||||
"error": str(e),
|
||||
"provider": {
|
||||
"id": provider.id,
|
||||
"name": provider.name,
|
||||
"display_name": provider.display_name,
|
||||
},
|
||||
"model": request.model_name,
|
||||
"endpoint": {
|
||||
"id": endpoint.id,
|
||||
"api_format": endpoint.api_format,
|
||||
"base_url": endpoint.base_url,
|
||||
} if endpoint else None,
|
||||
}
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
提供商策略管理 API 端点
|
||||
"""
|
||||
|
||||
from datetime import datetime, timedelta
|
||||
from datetime import datetime, timedelta, timezone
|
||||
from typing import Optional
|
||||
|
||||
from fastapi import APIRouter, Depends, HTTPException, Request
|
||||
@@ -103,6 +103,9 @@ class AdminProviderBillingAdapter(AdminApiAdapter):
|
||||
|
||||
if config.quota_last_reset_at:
|
||||
new_reset_at = parser.parse(config.quota_last_reset_at)
|
||||
# 确保有时区信息,如果没有则假设为 UTC
|
||||
if new_reset_at.tzinfo is None:
|
||||
new_reset_at = new_reset_at.replace(tzinfo=timezone.utc)
|
||||
provider.quota_last_reset_at = new_reset_at
|
||||
|
||||
# 自动同步该周期内的历史使用量
|
||||
@@ -118,7 +121,11 @@ class AdminProviderBillingAdapter(AdminApiAdapter):
|
||||
logger.info(f"Synced usage for provider {provider.name}: ${period_usage:.4f} since {new_reset_at}")
|
||||
|
||||
if config.quota_expires_at:
|
||||
provider.quota_expires_at = parser.parse(config.quota_expires_at)
|
||||
expires_at = parser.parse(config.quota_expires_at)
|
||||
# 确保有时区信息,如果没有则假设为 UTC
|
||||
if expires_at.tzinfo is None:
|
||||
expires_at = expires_at.replace(tzinfo=timezone.utc)
|
||||
provider.quota_expires_at = expires_at
|
||||
|
||||
db.commit()
|
||||
db.refresh(provider)
|
||||
@@ -149,7 +156,7 @@ class AdminProviderStatsAdapter(AdminApiAdapter):
|
||||
if not provider:
|
||||
raise HTTPException(status_code=404, detail="Provider not found")
|
||||
|
||||
since = datetime.now() - timedelta(hours=self.hours)
|
||||
since = datetime.now(timezone.utc) - timedelta(hours=self.hours)
|
||||
stats = (
|
||||
db.query(ProviderUsageTracking)
|
||||
.filter(
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
"""系统设置API端点。"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from dataclasses import dataclass
|
||||
from typing import Optional
|
||||
|
||||
@@ -13,9 +15,50 @@ from src.core.exceptions import InvalidRequestException, NotFoundException, tran
|
||||
from src.database import get_db
|
||||
from src.models.api import SystemSettingsRequest, SystemSettingsResponse
|
||||
from src.models.database import ApiKey, Provider, Usage, User
|
||||
from src.services.email.email_template import EmailTemplate
|
||||
from src.services.system.config import SystemConfigService
|
||||
|
||||
router = APIRouter(prefix="/api/admin/system", tags=["Admin - System"])
|
||||
|
||||
|
||||
def _get_version_from_git() -> str | None:
|
||||
"""从 git describe 获取版本号"""
|
||||
import subprocess
|
||||
|
||||
try:
|
||||
result = subprocess.run(
|
||||
["git", "describe", "--tags", "--always"],
|
||||
capture_output=True,
|
||||
text=True,
|
||||
timeout=5,
|
||||
)
|
||||
if result.returncode == 0:
|
||||
version = result.stdout.strip()
|
||||
if version.startswith("v"):
|
||||
version = version[1:]
|
||||
return version
|
||||
except Exception:
|
||||
pass
|
||||
return None
|
||||
|
||||
|
||||
@router.get("/version")
|
||||
async def get_system_version():
|
||||
"""获取系统版本信息"""
|
||||
# 优先从 git 获取
|
||||
version = _get_version_from_git()
|
||||
if version:
|
||||
return {"version": version}
|
||||
|
||||
# 回退到静态版本文件
|
||||
try:
|
||||
from src._version import __version__
|
||||
|
||||
return {"version": __version__}
|
||||
except ImportError:
|
||||
return {"version": "unknown"}
|
||||
|
||||
|
||||
pipeline = ApiRequestPipeline()
|
||||
|
||||
|
||||
@@ -119,6 +162,59 @@ async def import_users(request: Request, db: Session = Depends(get_db)):
|
||||
return await pipeline.run(adapter=adapter, http_request=request, db=db, mode=adapter.mode)
|
||||
|
||||
|
||||
@router.post("/smtp/test")
|
||||
async def test_smtp(request: Request, db: Session = Depends(get_db)):
|
||||
"""测试 SMTP 连接(管理员)"""
|
||||
adapter = AdminTestSmtpAdapter()
|
||||
return await pipeline.run(adapter=adapter, http_request=request, db=db, mode=adapter.mode)
|
||||
|
||||
|
||||
# -------- 邮件模板 API --------
|
||||
|
||||
|
||||
@router.get("/email/templates")
|
||||
async def get_email_templates(request: Request, db: Session = Depends(get_db)):
|
||||
"""获取所有邮件模板(管理员)"""
|
||||
adapter = AdminGetEmailTemplatesAdapter()
|
||||
return await pipeline.run(adapter=adapter, http_request=request, db=db, mode=adapter.mode)
|
||||
|
||||
|
||||
@router.get("/email/templates/{template_type}")
|
||||
async def get_email_template(
|
||||
template_type: str, request: Request, db: Session = Depends(get_db)
|
||||
):
|
||||
"""获取指定类型的邮件模板(管理员)"""
|
||||
adapter = AdminGetEmailTemplateAdapter(template_type=template_type)
|
||||
return await pipeline.run(adapter=adapter, http_request=request, db=db, mode=adapter.mode)
|
||||
|
||||
|
||||
@router.put("/email/templates/{template_type}")
|
||||
async def update_email_template(
|
||||
template_type: str, request: Request, db: Session = Depends(get_db)
|
||||
):
|
||||
"""更新邮件模板(管理员)"""
|
||||
adapter = AdminUpdateEmailTemplateAdapter(template_type=template_type)
|
||||
return await pipeline.run(adapter=adapter, http_request=request, db=db, mode=adapter.mode)
|
||||
|
||||
|
||||
@router.post("/email/templates/{template_type}/preview")
|
||||
async def preview_email_template(
|
||||
template_type: str, request: Request, db: Session = Depends(get_db)
|
||||
):
|
||||
"""预览邮件模板(管理员)"""
|
||||
adapter = AdminPreviewEmailTemplateAdapter(template_type=template_type)
|
||||
return await pipeline.run(adapter=adapter, http_request=request, db=db, mode=adapter.mode)
|
||||
|
||||
|
||||
@router.post("/email/templates/{template_type}/reset")
|
||||
async def reset_email_template(
|
||||
template_type: str, request: Request, db: Session = Depends(get_db)
|
||||
):
|
||||
"""重置邮件模板为默认值(管理员)"""
|
||||
adapter = AdminResetEmailTemplateAdapter(template_type=template_type)
|
||||
return await pipeline.run(adapter=adapter, http_request=request, db=db, mode=adapter.mode)
|
||||
|
||||
|
||||
# -------- 系统设置适配器 --------
|
||||
|
||||
|
||||
@@ -196,10 +292,16 @@ class AdminGetAllConfigsAdapter(AdminApiAdapter):
|
||||
class AdminGetSystemConfigAdapter(AdminApiAdapter):
|
||||
key: str
|
||||
|
||||
# 敏感配置项,不返回实际值
|
||||
SENSITIVE_KEYS = {"smtp_password"}
|
||||
|
||||
async def handle(self, context): # type: ignore[override]
|
||||
value = SystemConfigService.get_config(context.db, self.key)
|
||||
if value is None:
|
||||
raise NotFoundException(f"配置项 '{self.key}' 不存在")
|
||||
# 对敏感配置,只返回是否已设置的标志,不返回实际值
|
||||
if self.key in self.SENSITIVE_KEYS:
|
||||
return {"key": self.key, "value": None, "is_set": bool(value)}
|
||||
return {"key": self.key, "value": value}
|
||||
|
||||
|
||||
@@ -207,18 +309,31 @@ class AdminGetSystemConfigAdapter(AdminApiAdapter):
|
||||
class AdminSetSystemConfigAdapter(AdminApiAdapter):
|
||||
key: str
|
||||
|
||||
# 需要加密存储的配置项
|
||||
ENCRYPTED_KEYS = {"smtp_password"}
|
||||
|
||||
async def handle(self, context): # type: ignore[override]
|
||||
payload = context.ensure_json_body()
|
||||
value = payload.get("value")
|
||||
|
||||
# 对敏感配置进行加密
|
||||
if self.key in self.ENCRYPTED_KEYS and value:
|
||||
from src.core.crypto import crypto_service
|
||||
value = crypto_service.encrypt(value)
|
||||
|
||||
config = SystemConfigService.set_config(
|
||||
context.db,
|
||||
self.key,
|
||||
payload.get("value"),
|
||||
value,
|
||||
payload.get("description"),
|
||||
)
|
||||
|
||||
# 返回时不暴露加密后的值
|
||||
display_value = "********" if self.key in self.ENCRYPTED_KEYS else config.value
|
||||
|
||||
return {
|
||||
"key": config.key,
|
||||
"value": config.value,
|
||||
"value": display_value,
|
||||
"description": config.description,
|
||||
"updated_at": config.updated_at.isoformat(),
|
||||
}
|
||||
@@ -877,23 +992,12 @@ class AdminExportUsersAdapter(AdminApiAdapter):
|
||||
|
||||
db = context.db
|
||||
|
||||
# 导出 Users(排除管理员)
|
||||
users = db.query(User).filter(
|
||||
User.is_deleted.is_(False),
|
||||
User.role != UserRole.ADMIN
|
||||
).all()
|
||||
users_data = []
|
||||
for user in users:
|
||||
# 导出用户的 API Keys(保留加密数据)
|
||||
api_keys = db.query(ApiKey).filter(ApiKey.user_id == user.id).all()
|
||||
api_keys_data = []
|
||||
for key in api_keys:
|
||||
api_keys_data.append(
|
||||
{
|
||||
def _serialize_api_key(key: ApiKey, include_is_standalone: bool = False) -> dict:
|
||||
"""序列化 API Key 为导出格式"""
|
||||
data = {
|
||||
"key_hash": key.key_hash,
|
||||
"key_encrypted": key.key_encrypted,
|
||||
"name": key.name,
|
||||
"is_standalone": key.is_standalone,
|
||||
"balance_used_usd": key.balance_used_usd,
|
||||
"current_balance_usd": key.current_balance_usd,
|
||||
"allowed_providers": key.allowed_providers,
|
||||
@@ -904,11 +1008,28 @@ class AdminExportUsersAdapter(AdminApiAdapter):
|
||||
"concurrent_limit": key.concurrent_limit,
|
||||
"force_capabilities": key.force_capabilities,
|
||||
"is_active": key.is_active,
|
||||
"expires_at": key.expires_at.isoformat() if key.expires_at else None,
|
||||
"auto_delete_on_expiry": key.auto_delete_on_expiry,
|
||||
"total_requests": key.total_requests,
|
||||
"total_cost_usd": key.total_cost_usd,
|
||||
}
|
||||
)
|
||||
if include_is_standalone:
|
||||
data["is_standalone"] = key.is_standalone
|
||||
return data
|
||||
|
||||
# 导出 Users(排除管理员)
|
||||
users = db.query(User).filter(
|
||||
User.is_deleted.is_(False),
|
||||
User.role != UserRole.ADMIN
|
||||
).all()
|
||||
users_data = []
|
||||
for user in users:
|
||||
# 导出用户的 API Keys(排除独立余额Key,独立Key单独导出)
|
||||
api_keys = db.query(ApiKey).filter(
|
||||
ApiKey.user_id == user.id,
|
||||
ApiKey.is_standalone.is_(False)
|
||||
).all()
|
||||
api_keys_data = [_serialize_api_key(key, include_is_standalone=True) for key in api_keys]
|
||||
|
||||
users_data.append(
|
||||
{
|
||||
@@ -928,10 +1049,15 @@ class AdminExportUsersAdapter(AdminApiAdapter):
|
||||
}
|
||||
)
|
||||
|
||||
# 导出独立余额 Keys(管理员创建的,不属于普通用户)
|
||||
standalone_keys = db.query(ApiKey).filter(ApiKey.is_standalone.is_(True)).all()
|
||||
standalone_keys_data = [_serialize_api_key(key) for key in standalone_keys]
|
||||
|
||||
return {
|
||||
"version": "1.0",
|
||||
"version": "1.1",
|
||||
"exported_at": datetime.now(timezone.utc).isoformat(),
|
||||
"users": users_data,
|
||||
"standalone_keys": standalone_keys_data,
|
||||
}
|
||||
|
||||
|
||||
@@ -951,21 +1077,72 @@ class AdminImportUsersAdapter(AdminApiAdapter):
|
||||
db = context.db
|
||||
payload = context.ensure_json_body()
|
||||
|
||||
# 验证配置版本
|
||||
version = payload.get("version")
|
||||
if version != "1.0":
|
||||
raise InvalidRequestException(f"不支持的配置版本: {version}")
|
||||
|
||||
# 获取导入选项
|
||||
merge_mode = payload.get("merge_mode", "skip") # skip, overwrite, error
|
||||
users_data = payload.get("users", [])
|
||||
standalone_keys_data = payload.get("standalone_keys", [])
|
||||
|
||||
stats = {
|
||||
"users": {"created": 0, "updated": 0, "skipped": 0},
|
||||
"api_keys": {"created": 0, "skipped": 0},
|
||||
"standalone_keys": {"created": 0, "skipped": 0},
|
||||
"errors": [],
|
||||
}
|
||||
|
||||
def _create_api_key_from_data(
|
||||
key_data: dict,
|
||||
owner_id: str,
|
||||
is_standalone: bool = False,
|
||||
) -> tuple[ApiKey | None, str]:
|
||||
"""从导入数据创建 ApiKey 对象
|
||||
|
||||
Returns:
|
||||
(ApiKey, "created"): 成功创建
|
||||
(None, "skipped"): key 已存在,跳过
|
||||
(None, "invalid"): 数据无效,跳过
|
||||
"""
|
||||
key_hash = key_data.get("key_hash", "").strip()
|
||||
if not key_hash:
|
||||
return None, "invalid"
|
||||
|
||||
# 检查是否已存在
|
||||
existing = db.query(ApiKey).filter(ApiKey.key_hash == key_hash).first()
|
||||
if existing:
|
||||
return None, "skipped"
|
||||
|
||||
# 解析 expires_at
|
||||
expires_at = None
|
||||
if key_data.get("expires_at"):
|
||||
try:
|
||||
expires_at = datetime.fromisoformat(key_data["expires_at"])
|
||||
except ValueError:
|
||||
stats["errors"].append(
|
||||
f"API Key '{key_data.get('name', key_hash[:8])}' 的 expires_at 格式无效"
|
||||
)
|
||||
|
||||
return ApiKey(
|
||||
id=str(uuid.uuid4()),
|
||||
user_id=owner_id,
|
||||
key_hash=key_hash,
|
||||
key_encrypted=key_data.get("key_encrypted"),
|
||||
name=key_data.get("name"),
|
||||
is_standalone=is_standalone or key_data.get("is_standalone", False),
|
||||
balance_used_usd=key_data.get("balance_used_usd", 0.0),
|
||||
current_balance_usd=key_data.get("current_balance_usd"),
|
||||
allowed_providers=key_data.get("allowed_providers"),
|
||||
allowed_endpoints=key_data.get("allowed_endpoints"),
|
||||
allowed_api_formats=key_data.get("allowed_api_formats"),
|
||||
allowed_models=key_data.get("allowed_models"),
|
||||
rate_limit=key_data.get("rate_limit"),
|
||||
concurrent_limit=key_data.get("concurrent_limit", 5),
|
||||
force_capabilities=key_data.get("force_capabilities"),
|
||||
is_active=key_data.get("is_active", True),
|
||||
expires_at=expires_at,
|
||||
auto_delete_on_expiry=key_data.get("auto_delete_on_expiry", False),
|
||||
total_requests=key_data.get("total_requests", 0),
|
||||
total_cost_usd=key_data.get("total_cost_usd", 0.0),
|
||||
), "created"
|
||||
|
||||
try:
|
||||
for user_data in users_data:
|
||||
# 跳过管理员角色的导入(不区分大小写)
|
||||
@@ -1036,40 +1213,31 @@ class AdminImportUsersAdapter(AdminApiAdapter):
|
||||
|
||||
# 导入 API Keys
|
||||
for key_data in user_data.get("api_keys", []):
|
||||
# 检查是否已存在相同的 key_hash
|
||||
if key_data.get("key_hash"):
|
||||
existing_key = (
|
||||
db.query(ApiKey)
|
||||
.filter(ApiKey.key_hash == key_data["key_hash"])
|
||||
.first()
|
||||
)
|
||||
if existing_key:
|
||||
stats["api_keys"]["skipped"] += 1
|
||||
continue
|
||||
|
||||
new_key = ApiKey(
|
||||
id=str(uuid.uuid4()),
|
||||
user_id=user_id,
|
||||
key_hash=key_data.get("key_hash", ""),
|
||||
key_encrypted=key_data.get("key_encrypted"),
|
||||
name=key_data.get("name"),
|
||||
is_standalone=key_data.get("is_standalone", False),
|
||||
balance_used_usd=key_data.get("balance_used_usd", 0.0),
|
||||
current_balance_usd=key_data.get("current_balance_usd"),
|
||||
allowed_providers=key_data.get("allowed_providers"),
|
||||
allowed_endpoints=key_data.get("allowed_endpoints"),
|
||||
allowed_api_formats=key_data.get("allowed_api_formats"),
|
||||
allowed_models=key_data.get("allowed_models"),
|
||||
rate_limit=key_data.get("rate_limit", 100),
|
||||
concurrent_limit=key_data.get("concurrent_limit", 5),
|
||||
force_capabilities=key_data.get("force_capabilities"),
|
||||
is_active=key_data.get("is_active", True),
|
||||
auto_delete_on_expiry=key_data.get("auto_delete_on_expiry", False),
|
||||
total_requests=key_data.get("total_requests", 0),
|
||||
total_cost_usd=key_data.get("total_cost_usd", 0.0),
|
||||
)
|
||||
new_key, status = _create_api_key_from_data(key_data, user_id)
|
||||
if new_key:
|
||||
db.add(new_key)
|
||||
stats["api_keys"]["created"] += 1
|
||||
elif status == "skipped":
|
||||
stats["api_keys"]["skipped"] += 1
|
||||
# invalid 数据不计入统计
|
||||
|
||||
# 导入独立余额 Keys(需要找一个管理员用户作为 owner)
|
||||
if standalone_keys_data:
|
||||
# 查找一个管理员用户作为独立Key的owner
|
||||
admin_user = db.query(User).filter(User.role == UserRole.ADMIN).first()
|
||||
if not admin_user:
|
||||
stats["errors"].append("无法导入独立余额Key: 系统中没有管理员用户")
|
||||
else:
|
||||
for key_data in standalone_keys_data:
|
||||
new_key, status = _create_api_key_from_data(
|
||||
key_data, admin_user.id, is_standalone=True
|
||||
)
|
||||
if new_key:
|
||||
db.add(new_key)
|
||||
stats["standalone_keys"]["created"] += 1
|
||||
elif status == "skipped":
|
||||
stats["standalone_keys"]["skipped"] += 1
|
||||
# invalid 数据不计入统计
|
||||
|
||||
db.commit()
|
||||
|
||||
@@ -1084,3 +1252,265 @@ class AdminImportUsersAdapter(AdminApiAdapter):
|
||||
except Exception as e:
|
||||
db.rollback()
|
||||
raise InvalidRequestException(f"导入失败: {str(e)}")
|
||||
|
||||
|
||||
class AdminTestSmtpAdapter(AdminApiAdapter):
|
||||
async def handle(self, context): # type: ignore[override]
|
||||
"""测试 SMTP 连接"""
|
||||
from src.core.crypto import crypto_service
|
||||
from src.services.system.config import SystemConfigService
|
||||
from src.services.email.email_sender import EmailSenderService
|
||||
|
||||
db = context.db
|
||||
payload = context.ensure_json_body() or {}
|
||||
|
||||
# 获取密码:优先使用前端传入的明文密码,否则从数据库获取并解密
|
||||
smtp_password = payload.get("smtp_password")
|
||||
if not smtp_password:
|
||||
encrypted_password = SystemConfigService.get_config(db, "smtp_password")
|
||||
if encrypted_password:
|
||||
try:
|
||||
smtp_password = crypto_service.decrypt(encrypted_password, silent=True)
|
||||
except Exception:
|
||||
# 解密失败,可能是旧的未加密密码
|
||||
smtp_password = encrypted_password
|
||||
|
||||
# 前端可传入未保存的配置,优先使用前端值,否则回退数据库
|
||||
config = {
|
||||
"smtp_host": payload.get("smtp_host") or SystemConfigService.get_config(db, "smtp_host"),
|
||||
"smtp_port": payload.get("smtp_port") or SystemConfigService.get_config(db, "smtp_port", default=587),
|
||||
"smtp_user": payload.get("smtp_user") or SystemConfigService.get_config(db, "smtp_user"),
|
||||
"smtp_password": smtp_password,
|
||||
"smtp_use_tls": payload.get("smtp_use_tls")
|
||||
if payload.get("smtp_use_tls") is not None
|
||||
else SystemConfigService.get_config(db, "smtp_use_tls", default=True),
|
||||
"smtp_use_ssl": payload.get("smtp_use_ssl")
|
||||
if payload.get("smtp_use_ssl") is not None
|
||||
else SystemConfigService.get_config(db, "smtp_use_ssl", default=False),
|
||||
"smtp_from_email": payload.get("smtp_from_email")
|
||||
or SystemConfigService.get_config(db, "smtp_from_email"),
|
||||
"smtp_from_name": payload.get("smtp_from_name")
|
||||
or SystemConfigService.get_config(db, "smtp_from_name", default="Aether"),
|
||||
}
|
||||
|
||||
# 验证必要配置
|
||||
missing_fields = [
|
||||
field for field in ["smtp_host", "smtp_user", "smtp_password", "smtp_from_email"] if not config.get(field)
|
||||
]
|
||||
if missing_fields:
|
||||
return {
|
||||
"success": False,
|
||||
"message": f"SMTP 配置不完整,请检查 {', '.join(missing_fields)}"
|
||||
}
|
||||
|
||||
# 测试连接
|
||||
try:
|
||||
success, error_msg = await EmailSenderService.test_smtp_connection(
|
||||
db=db, override_config=config
|
||||
)
|
||||
|
||||
if success:
|
||||
return {
|
||||
"success": True,
|
||||
"message": "SMTP 连接测试成功"
|
||||
}
|
||||
else:
|
||||
return {
|
||||
"success": False,
|
||||
"message": error_msg
|
||||
}
|
||||
except Exception as e:
|
||||
return {
|
||||
"success": False,
|
||||
"message": str(e)
|
||||
}
|
||||
|
||||
|
||||
# -------- 邮件模板适配器 --------
|
||||
|
||||
|
||||
class AdminGetEmailTemplatesAdapter(AdminApiAdapter):
|
||||
"""获取所有邮件模板"""
|
||||
|
||||
async def handle(self, context): # type: ignore[override]
|
||||
db = context.db
|
||||
templates = []
|
||||
|
||||
for template_type, type_info in EmailTemplate.TEMPLATE_TYPES.items():
|
||||
# 获取自定义模板或默认模板
|
||||
template = EmailTemplate.get_template(db, template_type)
|
||||
default_template = EmailTemplate.get_default_template(template_type)
|
||||
|
||||
# 检查是否使用了自定义模板
|
||||
is_custom = (
|
||||
template["subject"] != default_template["subject"]
|
||||
or template["html"] != default_template["html"]
|
||||
)
|
||||
|
||||
templates.append(
|
||||
{
|
||||
"type": template_type,
|
||||
"name": type_info["name"],
|
||||
"variables": type_info["variables"],
|
||||
"subject": template["subject"],
|
||||
"html": template["html"],
|
||||
"is_custom": is_custom,
|
||||
}
|
||||
)
|
||||
|
||||
return {"templates": templates}
|
||||
|
||||
|
||||
@dataclass
|
||||
class AdminGetEmailTemplateAdapter(AdminApiAdapter):
|
||||
"""获取指定类型的邮件模板"""
|
||||
|
||||
template_type: str
|
||||
|
||||
async def handle(self, context): # type: ignore[override]
|
||||
# 验证模板类型
|
||||
if self.template_type not in EmailTemplate.TEMPLATE_TYPES:
|
||||
raise NotFoundException(f"模板类型 '{self.template_type}' 不存在")
|
||||
|
||||
db = context.db
|
||||
type_info = EmailTemplate.TEMPLATE_TYPES[self.template_type]
|
||||
template = EmailTemplate.get_template(db, self.template_type)
|
||||
default_template = EmailTemplate.get_default_template(self.template_type)
|
||||
|
||||
is_custom = (
|
||||
template["subject"] != default_template["subject"]
|
||||
or template["html"] != default_template["html"]
|
||||
)
|
||||
|
||||
return {
|
||||
"type": self.template_type,
|
||||
"name": type_info["name"],
|
||||
"variables": type_info["variables"],
|
||||
"subject": template["subject"],
|
||||
"html": template["html"],
|
||||
"is_custom": is_custom,
|
||||
"default_subject": default_template["subject"],
|
||||
"default_html": default_template["html"],
|
||||
}
|
||||
|
||||
|
||||
@dataclass
|
||||
class AdminUpdateEmailTemplateAdapter(AdminApiAdapter):
|
||||
"""更新邮件模板"""
|
||||
|
||||
template_type: str
|
||||
|
||||
async def handle(self, context): # type: ignore[override]
|
||||
# 验证模板类型
|
||||
if self.template_type not in EmailTemplate.TEMPLATE_TYPES:
|
||||
raise NotFoundException(f"模板类型 '{self.template_type}' 不存在")
|
||||
|
||||
db = context.db
|
||||
payload = context.ensure_json_body()
|
||||
|
||||
subject = payload.get("subject")
|
||||
html = payload.get("html")
|
||||
|
||||
# 至少需要提供一个字段
|
||||
if subject is None and html is None:
|
||||
raise InvalidRequestException("请提供 subject 或 html")
|
||||
|
||||
# 保存模板
|
||||
subject_key = f"email_template_{self.template_type}_subject"
|
||||
html_key = f"email_template_{self.template_type}_html"
|
||||
|
||||
if subject is not None:
|
||||
if subject:
|
||||
SystemConfigService.set_config(db, subject_key, subject)
|
||||
else:
|
||||
# 空字符串表示删除自定义值,恢复默认
|
||||
SystemConfigService.delete_config(db, subject_key)
|
||||
|
||||
if html is not None:
|
||||
if html:
|
||||
SystemConfigService.set_config(db, html_key, html)
|
||||
else:
|
||||
SystemConfigService.delete_config(db, html_key)
|
||||
|
||||
return {"message": "模板保存成功"}
|
||||
|
||||
|
||||
@dataclass
|
||||
class AdminPreviewEmailTemplateAdapter(AdminApiAdapter):
|
||||
"""预览邮件模板"""
|
||||
|
||||
template_type: str
|
||||
|
||||
async def handle(self, context): # type: ignore[override]
|
||||
# 验证模板类型
|
||||
if self.template_type not in EmailTemplate.TEMPLATE_TYPES:
|
||||
raise NotFoundException(f"模板类型 '{self.template_type}' 不存在")
|
||||
|
||||
db = context.db
|
||||
payload = context.ensure_json_body() or {}
|
||||
|
||||
# 获取模板 HTML(优先使用请求体中的,否则使用数据库中的)
|
||||
html = payload.get("html")
|
||||
if not html:
|
||||
template = EmailTemplate.get_template(db, self.template_type)
|
||||
html = template["html"]
|
||||
|
||||
# 获取预览变量
|
||||
type_info = EmailTemplate.TEMPLATE_TYPES[self.template_type]
|
||||
|
||||
# 构建预览变量,使用请求中的值或默认示例值
|
||||
preview_variables = {}
|
||||
default_values = {
|
||||
"app_name": SystemConfigService.get_config(db, "email_app_name")
|
||||
or SystemConfigService.get_config(db, "smtp_from_name", default="Aether"),
|
||||
"code": "123456",
|
||||
"expire_minutes": "30",
|
||||
"email": "example@example.com",
|
||||
"reset_link": "https://example.com/reset?token=abc123",
|
||||
}
|
||||
|
||||
for var in type_info["variables"]:
|
||||
preview_variables[var] = payload.get(var, default_values.get(var, f"{{{{{var}}}}}"))
|
||||
|
||||
# 渲染模板
|
||||
rendered_html = EmailTemplate.render_template(html, preview_variables)
|
||||
|
||||
return {
|
||||
"html": rendered_html,
|
||||
"variables": preview_variables,
|
||||
}
|
||||
|
||||
|
||||
@dataclass
|
||||
class AdminResetEmailTemplateAdapter(AdminApiAdapter):
|
||||
"""重置邮件模板为默认值"""
|
||||
|
||||
template_type: str
|
||||
|
||||
async def handle(self, context): # type: ignore[override]
|
||||
# 验证模板类型
|
||||
if self.template_type not in EmailTemplate.TEMPLATE_TYPES:
|
||||
raise NotFoundException(f"模板类型 '{self.template_type}' 不存在")
|
||||
|
||||
db = context.db
|
||||
|
||||
# 删除自定义模板
|
||||
subject_key = f"email_template_{self.template_type}_subject"
|
||||
html_key = f"email_template_{self.template_type}_html"
|
||||
|
||||
SystemConfigService.delete_config(db, subject_key)
|
||||
SystemConfigService.delete_config(db, html_key)
|
||||
|
||||
# 返回默认模板
|
||||
default_template = EmailTemplate.get_default_template(self.template_type)
|
||||
type_info = EmailTemplate.TEMPLATE_TYPES[self.template_type]
|
||||
|
||||
return {
|
||||
"message": "模板已重置为默认值",
|
||||
"template": {
|
||||
"type": self.template_type,
|
||||
"name": type_info["name"],
|
||||
"subject": default_template["subject"],
|
||||
"html": default_template["html"],
|
||||
},
|
||||
}
|
||||
|
||||
@@ -73,11 +73,26 @@ async def get_usage_stats(
|
||||
return await pipeline.run(adapter=adapter, http_request=request, db=db, mode=adapter.mode)
|
||||
|
||||
|
||||
@router.get("/heatmap")
|
||||
async def get_activity_heatmap(
|
||||
request: Request,
|
||||
db: Session = Depends(get_db),
|
||||
):
|
||||
"""
|
||||
Get activity heatmap data for the past 365 days.
|
||||
|
||||
This endpoint is cached for 5 minutes to reduce database load.
|
||||
"""
|
||||
adapter = AdminActivityHeatmapAdapter()
|
||||
return await pipeline.run(adapter=adapter, http_request=request, db=db, mode=adapter.mode)
|
||||
|
||||
|
||||
@router.get("/records")
|
||||
async def get_usage_records(
|
||||
request: Request,
|
||||
start_date: Optional[datetime] = None,
|
||||
end_date: Optional[datetime] = None,
|
||||
search: Optional[str] = None, # 通用搜索:用户名、密钥名、模型名、提供商名
|
||||
user_id: Optional[str] = None,
|
||||
username: Optional[str] = None,
|
||||
model: Optional[str] = None,
|
||||
@@ -90,6 +105,7 @@ async def get_usage_records(
|
||||
adapter = AdminUsageRecordsAdapter(
|
||||
start_date=start_date,
|
||||
end_date=end_date,
|
||||
search=search,
|
||||
user_id=user_id,
|
||||
username=username,
|
||||
model=model,
|
||||
@@ -168,12 +184,6 @@ class AdminUsageStatsAdapter(AdminApiAdapter):
|
||||
(Usage.status_code >= 400) | (Usage.error_message.isnot(None))
|
||||
).count()
|
||||
|
||||
activity_heatmap = UsageService.get_daily_activity(
|
||||
db=db,
|
||||
window_days=365,
|
||||
include_actual_cost=True,
|
||||
)
|
||||
|
||||
context.add_audit_metadata(
|
||||
action="usage_stats",
|
||||
start_date=self.start_date.isoformat() if self.start_date else None,
|
||||
@@ -204,10 +214,22 @@ class AdminUsageStatsAdapter(AdminApiAdapter):
|
||||
),
|
||||
"cache_read_cost": float(cache_stats.cache_read_cost or 0) if cache_stats else 0,
|
||||
},
|
||||
"activity_heatmap": activity_heatmap,
|
||||
}
|
||||
|
||||
|
||||
class AdminActivityHeatmapAdapter(AdminApiAdapter):
|
||||
"""Activity heatmap adapter with Redis caching."""
|
||||
|
||||
async def handle(self, context): # type: ignore[override]
|
||||
result = await UsageService.get_cached_heatmap(
|
||||
db=context.db,
|
||||
user_id=None,
|
||||
include_actual_cost=True,
|
||||
)
|
||||
context.add_audit_metadata(action="activity_heatmap")
|
||||
return result
|
||||
|
||||
|
||||
class AdminUsageByModelAdapter(AdminApiAdapter):
|
||||
def __init__(self, start_date: Optional[datetime], end_date: Optional[datetime], limit: int):
|
||||
self.start_date = start_date
|
||||
@@ -480,6 +502,7 @@ class AdminUsageRecordsAdapter(AdminApiAdapter):
|
||||
self,
|
||||
start_date: Optional[datetime],
|
||||
end_date: Optional[datetime],
|
||||
search: Optional[str],
|
||||
user_id: Optional[str],
|
||||
username: Optional[str],
|
||||
model: Optional[str],
|
||||
@@ -490,6 +513,7 @@ class AdminUsageRecordsAdapter(AdminApiAdapter):
|
||||
):
|
||||
self.start_date = start_date
|
||||
self.end_date = end_date
|
||||
self.search = search
|
||||
self.user_id = user_id
|
||||
self.username = username
|
||||
self.model = model
|
||||
@@ -499,25 +523,54 @@ class AdminUsageRecordsAdapter(AdminApiAdapter):
|
||||
self.offset = offset
|
||||
|
||||
async def handle(self, context): # type: ignore[override]
|
||||
from sqlalchemy import or_
|
||||
|
||||
from src.utils.database_helpers import escape_like_pattern, safe_truncate_escaped
|
||||
|
||||
db = context.db
|
||||
query = (
|
||||
db.query(Usage, User, ProviderEndpoint, ProviderAPIKey)
|
||||
db.query(Usage, User, ProviderEndpoint, ProviderAPIKey, ApiKey)
|
||||
.outerjoin(User, Usage.user_id == User.id)
|
||||
.outerjoin(ProviderEndpoint, Usage.provider_endpoint_id == ProviderEndpoint.id)
|
||||
.outerjoin(ProviderAPIKey, Usage.provider_api_key_id == ProviderAPIKey.id)
|
||||
.outerjoin(ApiKey, Usage.api_key_id == ApiKey.id)
|
||||
)
|
||||
|
||||
# 如果需要按 Provider 名称搜索/筛选,统一在这里 JOIN
|
||||
if self.search or self.provider:
|
||||
query = query.join(Provider, Usage.provider_id == Provider.id, isouter=True)
|
||||
|
||||
# 通用搜索:用户名、密钥名、模型名、提供商名
|
||||
# 支持空格分隔的组合搜索,多个关键词之间是 AND 关系
|
||||
# 限制:最多 10 个关键词,转义后每个关键词最长 100 字符
|
||||
if self.search:
|
||||
keywords = [kw for kw in self.search.strip().split() if kw][:10]
|
||||
for keyword in keywords:
|
||||
escaped = safe_truncate_escaped(escape_like_pattern(keyword), 100)
|
||||
search_pattern = f"%{escaped}%"
|
||||
query = query.filter(
|
||||
or_(
|
||||
User.username.ilike(search_pattern, escape="\\"),
|
||||
ApiKey.name.ilike(search_pattern, escape="\\"),
|
||||
Usage.model.ilike(search_pattern, escape="\\"),
|
||||
Provider.name.ilike(search_pattern, escape="\\"),
|
||||
)
|
||||
)
|
||||
|
||||
if self.user_id:
|
||||
query = query.filter(Usage.user_id == self.user_id)
|
||||
if self.username:
|
||||
# 支持用户名模糊搜索
|
||||
query = query.filter(User.username.ilike(f"%{self.username}%"))
|
||||
escaped = escape_like_pattern(self.username)
|
||||
query = query.filter(User.username.ilike(f"%{escaped}%", escape="\\"))
|
||||
if self.model:
|
||||
# 支持模型名模糊搜索
|
||||
query = query.filter(Usage.model.ilike(f"%{self.model}%"))
|
||||
escaped = escape_like_pattern(self.model)
|
||||
query = query.filter(Usage.model.ilike(f"%{escaped}%", escape="\\"))
|
||||
if self.provider:
|
||||
# 支持提供商名称搜索(通过 Provider 表)
|
||||
query = query.join(Provider, Usage.provider_id == Provider.id, isouter=True)
|
||||
query = query.filter(Provider.name.ilike(f"%{self.provider}%"))
|
||||
# 支持提供商名称搜索
|
||||
escaped = escape_like_pattern(self.provider)
|
||||
query = query.filter(Provider.name.ilike(f"%{escaped}%", escape="\\"))
|
||||
if self.status:
|
||||
# 状态筛选
|
||||
# 旧的筛选值(基于 is_stream 和 status_code):stream, standard, error
|
||||
@@ -555,7 +608,7 @@ class AdminUsageRecordsAdapter(AdminApiAdapter):
|
||||
query.order_by(Usage.created_at.desc()).offset(self.offset).limit(self.limit).all()
|
||||
)
|
||||
|
||||
request_ids = [usage.request_id for usage, _, _, _ in records if usage.request_id]
|
||||
request_ids = [usage.request_id for usage, _, _, _, _ in records if usage.request_id]
|
||||
fallback_map = {}
|
||||
if request_ids:
|
||||
# 只统计实际执行的候选(success 或 failed),不包括 skipped/pending/available
|
||||
@@ -575,6 +628,7 @@ class AdminUsageRecordsAdapter(AdminApiAdapter):
|
||||
action="usage_records",
|
||||
start_date=self.start_date.isoformat() if self.start_date else None,
|
||||
end_date=self.end_date.isoformat() if self.end_date else None,
|
||||
search=self.search,
|
||||
user_id=self.user_id,
|
||||
username=self.username,
|
||||
model=self.model,
|
||||
@@ -586,7 +640,7 @@ class AdminUsageRecordsAdapter(AdminApiAdapter):
|
||||
)
|
||||
|
||||
# 构建 provider_id -> Provider 名称的映射,避免 N+1 查询
|
||||
provider_ids = [usage.provider_id for usage, _, _, _ in records if usage.provider_id]
|
||||
provider_ids = [usage.provider_id for usage, _, _, _, _ in records if usage.provider_id]
|
||||
provider_map = {}
|
||||
if provider_ids:
|
||||
providers_data = (
|
||||
@@ -595,7 +649,7 @@ class AdminUsageRecordsAdapter(AdminApiAdapter):
|
||||
provider_map = {str(p.id): p.name for p in providers_data}
|
||||
|
||||
data = []
|
||||
for usage, user, endpoint, api_key in records:
|
||||
for usage, user, endpoint, provider_api_key, user_api_key in records:
|
||||
actual_cost = (
|
||||
float(usage.actual_total_cost_usd)
|
||||
if usage.actual_total_cost_usd is not None
|
||||
@@ -616,6 +670,15 @@ class AdminUsageRecordsAdapter(AdminApiAdapter):
|
||||
"user_id": user.id if user else None,
|
||||
"user_email": user.email if user else "已删除用户",
|
||||
"username": user.username if user else "已删除用户",
|
||||
"api_key": (
|
||||
{
|
||||
"id": user_api_key.id,
|
||||
"name": user_api_key.name,
|
||||
"display": user_api_key.get_display_key(),
|
||||
}
|
||||
if user_api_key
|
||||
else None
|
||||
),
|
||||
"provider": provider_name,
|
||||
"model": usage.model,
|
||||
"target_model": usage.target_model, # 映射后的目标模型名
|
||||
@@ -641,7 +704,7 @@ class AdminUsageRecordsAdapter(AdminApiAdapter):
|
||||
"has_fallback": fallback_map.get(usage.request_id, False),
|
||||
"api_format": usage.api_format
|
||||
or (endpoint.api_format if endpoint and endpoint.api_format else None),
|
||||
"api_key_name": api_key.name if api_key else None,
|
||||
"api_key_name": provider_api_key.name if provider_api_key else None,
|
||||
"request_metadata": usage.request_metadata, # Provider 响应元数据
|
||||
}
|
||||
)
|
||||
@@ -670,7 +733,9 @@ class AdminActiveRequestsAdapter(AdminApiAdapter):
|
||||
if not id_list:
|
||||
return {"requests": []}
|
||||
|
||||
requests = UsageService.get_active_requests_status(db=db, ids=id_list)
|
||||
requests = UsageService.get_active_requests_status(
|
||||
db=db, ids=id_list, include_admin_fields=True
|
||||
)
|
||||
return {"requests": requests}
|
||||
|
||||
|
||||
|
||||
@@ -248,6 +248,7 @@ class AdminUpdateUserAdapter(AdminApiAdapter):
|
||||
raise InvalidRequestException("请求数据验证失败")
|
||||
|
||||
update_data = request.model_dump(exclude_unset=True)
|
||||
old_role = existing_user.role
|
||||
if "role" in update_data and update_data["role"]:
|
||||
if hasattr(update_data["role"], "value"):
|
||||
update_data["role"] = update_data["role"]
|
||||
@@ -258,6 +259,12 @@ class AdminUpdateUserAdapter(AdminApiAdapter):
|
||||
if not user:
|
||||
raise NotFoundException("用户不存在", "user")
|
||||
|
||||
# 角色变更时清除热力图缓存(影响 include_actual_cost 权限)
|
||||
if "role" in update_data and update_data["role"] != old_role:
|
||||
from src.services.usage.service import UsageService
|
||||
|
||||
await UsageService.clear_user_heatmap_cache(self.user_id)
|
||||
|
||||
changed_fields = list(update_data.keys())
|
||||
context.add_audit_metadata(
|
||||
action="update_user",
|
||||
@@ -424,7 +431,7 @@ class AdminCreateUserKeyAdapter(AdminApiAdapter):
|
||||
name=key_data.name,
|
||||
allowed_providers=key_data.allowed_providers,
|
||||
allowed_models=key_data.allowed_models,
|
||||
rate_limit=key_data.rate_limit or 100,
|
||||
rate_limit=key_data.rate_limit, # None = 无限制
|
||||
expire_days=key_data.expire_days,
|
||||
initial_balance_usd=None, # 普通Key不设置余额限制
|
||||
is_standalone=False, # 不是独立Key
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
认证相关API端点
|
||||
"""
|
||||
|
||||
from typing import Optional
|
||||
from typing import Optional, Tuple
|
||||
|
||||
from fastapi import APIRouter, Depends, HTTPException, Request, status
|
||||
from fastapi.security import HTTPAuthorizationCredentials, HTTPBearer
|
||||
@@ -23,21 +23,82 @@ from src.models.api import (
|
||||
RefreshTokenResponse,
|
||||
RegisterRequest,
|
||||
RegisterResponse,
|
||||
RegistrationSettingsResponse,
|
||||
SendVerificationCodeRequest,
|
||||
SendVerificationCodeResponse,
|
||||
VerificationStatusRequest,
|
||||
VerificationStatusResponse,
|
||||
VerifyEmailRequest,
|
||||
VerifyEmailResponse,
|
||||
)
|
||||
from src.models.database import AuditEventType, User, UserRole
|
||||
from src.services.auth.service import AuthService
|
||||
from src.services.rate_limit.ip_limiter import IPRateLimiter
|
||||
from src.services.system.audit import AuditService
|
||||
from src.services.system.config import SystemConfigService
|
||||
from src.services.user.service import UserService
|
||||
from src.services.email import EmailSenderService, EmailVerificationService
|
||||
from src.utils.request_utils import get_client_ip, get_user_agent
|
||||
|
||||
|
||||
def validate_email_suffix(db: Session, email: str) -> Tuple[bool, Optional[str]]:
|
||||
"""
|
||||
验证邮箱后缀是否允许注册
|
||||
|
||||
Args:
|
||||
db: 数据库会话
|
||||
email: 邮箱地址
|
||||
|
||||
Returns:
|
||||
(是否允许, 错误信息)
|
||||
"""
|
||||
# 获取邮箱后缀限制配置
|
||||
mode = SystemConfigService.get_config(db, "email_suffix_mode", default="none")
|
||||
|
||||
if mode == "none":
|
||||
return True, None
|
||||
|
||||
# 获取邮箱后缀列表
|
||||
suffix_list = SystemConfigService.get_config(db, "email_suffix_list", default=[])
|
||||
if not suffix_list:
|
||||
# 没有配置后缀列表时,不限制
|
||||
return True, None
|
||||
|
||||
# 确保 suffix_list 是列表类型
|
||||
if isinstance(suffix_list, str):
|
||||
suffix_list = [s.strip().lower() for s in suffix_list.split(",") if s.strip()]
|
||||
|
||||
# 获取邮箱后缀
|
||||
if "@" not in email:
|
||||
return False, "邮箱格式无效"
|
||||
|
||||
email_suffix = email.split("@")[1].lower()
|
||||
|
||||
if mode == "whitelist":
|
||||
# 白名单模式:只允许列出的后缀
|
||||
if email_suffix not in suffix_list:
|
||||
return False, f"该邮箱后缀不在允许列表中,仅支持: {', '.join(suffix_list)}"
|
||||
elif mode == "blacklist":
|
||||
# 黑名单模式:拒绝列出的后缀
|
||||
if email_suffix in suffix_list:
|
||||
return False, f"该邮箱后缀 ({email_suffix}) 不允许注册"
|
||||
|
||||
return True, None
|
||||
|
||||
|
||||
router = APIRouter(prefix="/api/auth", tags=["Authentication"])
|
||||
security = HTTPBearer()
|
||||
pipeline = ApiRequestPipeline()
|
||||
|
||||
|
||||
# API端点
|
||||
@router.get("/registration-settings", response_model=RegistrationSettingsResponse)
|
||||
async def registration_settings(request: Request, db: Session = Depends(get_db)):
|
||||
"""公开获取注册相关配置"""
|
||||
adapter = AuthRegistrationSettingsAdapter()
|
||||
return await pipeline.run(adapter=adapter, http_request=request, db=db, mode=adapter.mode)
|
||||
|
||||
|
||||
@router.post("/login", response_model=LoginResponse)
|
||||
async def login(request: Request, db: Session = Depends(get_db)):
|
||||
adapter = AuthLoginAdapter()
|
||||
@@ -75,6 +136,27 @@ async def logout(request: Request, db: Session = Depends(get_db)):
|
||||
return await pipeline.run(adapter=adapter, http_request=request, db=db, mode=adapter.mode)
|
||||
|
||||
|
||||
@router.post("/send-verification-code", response_model=SendVerificationCodeResponse)
|
||||
async def send_verification_code(request: Request, db: Session = Depends(get_db)):
|
||||
"""发送邮箱验证码"""
|
||||
adapter = AuthSendVerificationCodeAdapter()
|
||||
return await pipeline.run(adapter=adapter, http_request=request, db=db, mode=adapter.mode)
|
||||
|
||||
|
||||
@router.post("/verify-email", response_model=VerifyEmailResponse)
|
||||
async def verify_email(request: Request, db: Session = Depends(get_db)):
|
||||
"""验证邮箱验证码"""
|
||||
adapter = AuthVerifyEmailAdapter()
|
||||
return await pipeline.run(adapter=adapter, http_request=request, db=db, mode=adapter.mode)
|
||||
|
||||
|
||||
@router.post("/verification-status", response_model=VerificationStatusResponse)
|
||||
async def verification_status(request: Request, db: Session = Depends(get_db)):
|
||||
"""查询邮箱验证状态"""
|
||||
adapter = AuthVerificationStatusAdapter()
|
||||
return await pipeline.run(adapter=adapter, http_request=request, db=db, mode=adapter.mode)
|
||||
|
||||
|
||||
# ============== 适配器实现 ==============
|
||||
|
||||
|
||||
@@ -209,6 +291,20 @@ class AuthRefreshAdapter(AuthPublicAdapter):
|
||||
raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="刷新令牌失败")
|
||||
|
||||
|
||||
class AuthRegistrationSettingsAdapter(AuthPublicAdapter):
|
||||
async def handle(self, context): # type: ignore[override]
|
||||
"""公开返回注册相关配置"""
|
||||
db = context.db
|
||||
|
||||
enable_registration = SystemConfigService.get_config(db, "enable_registration", default=False)
|
||||
require_verification = SystemConfigService.get_config(db, "require_email_verification", default=False)
|
||||
|
||||
return RegistrationSettingsResponse(
|
||||
enable_registration=bool(enable_registration),
|
||||
require_email_verification=bool(require_verification),
|
||||
).model_dump()
|
||||
|
||||
|
||||
class AuthRegisterAdapter(AuthPublicAdapter):
|
||||
async def handle(self, context): # type: ignore[override]
|
||||
from src.models.database import SystemConfig
|
||||
@@ -241,6 +337,37 @@ class AuthRegisterAdapter(AuthPublicAdapter):
|
||||
db.commit()
|
||||
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="系统暂不开放注册")
|
||||
|
||||
# 检查邮箱后缀是否允许
|
||||
suffix_allowed, suffix_error = validate_email_suffix(db, register_request.email)
|
||||
if not suffix_allowed:
|
||||
logger.warning(f"注册失败:邮箱后缀不允许: {register_request.email}")
|
||||
AuditService.log_event(
|
||||
db=db,
|
||||
event_type=AuditEventType.UNAUTHORIZED_ACCESS,
|
||||
description=f"Registration attempt rejected - email suffix not allowed: {register_request.email}",
|
||||
ip_address=client_ip,
|
||||
user_agent=user_agent,
|
||||
metadata={"email": register_request.email, "reason": "email_suffix_not_allowed"},
|
||||
)
|
||||
db.commit()
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
detail=suffix_error,
|
||||
)
|
||||
|
||||
# 检查是否需要邮箱验证
|
||||
require_verification = SystemConfigService.get_config(db, "require_email_verification", default=False)
|
||||
|
||||
if require_verification:
|
||||
# 检查邮箱是否已验证
|
||||
is_verified = await EmailVerificationService.is_email_verified(register_request.email)
|
||||
if not is_verified:
|
||||
logger.warning(f"注册失败:邮箱未验证: {register_request.email}")
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
detail="请先完成邮箱验证。请发送验证码并验证后再注册。",
|
||||
)
|
||||
|
||||
try:
|
||||
user = UserService.create_user(
|
||||
db=db,
|
||||
@@ -258,7 +385,16 @@ class AuthRegisterAdapter(AuthPublicAdapter):
|
||||
user_agent=user_agent,
|
||||
metadata={"email": user.email, "username": user.username, "role": user.role.value},
|
||||
)
|
||||
|
||||
db.commit()
|
||||
|
||||
# 注册成功后清除验证状态(在 commit 后清理,即使清理失败也不影响注册结果)
|
||||
if require_verification:
|
||||
try:
|
||||
await EmailVerificationService.clear_verification(register_request.email)
|
||||
except Exception as e:
|
||||
logger.warning(f"清理验证状态失败: {e}")
|
||||
|
||||
return RegisterResponse(
|
||||
user_id=user.id,
|
||||
email=user.email,
|
||||
@@ -308,8 +444,8 @@ class AuthChangePasswordAdapter(AuthenticatedApiAdapter):
|
||||
user = context.user
|
||||
if not user.verify_password(old_password):
|
||||
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="旧密码错误")
|
||||
if len(new_password) < 8:
|
||||
raise InvalidRequestException("密码长度至少8位")
|
||||
if len(new_password) < 6:
|
||||
raise InvalidRequestException("密码长度至少6位")
|
||||
user.set_password(new_password)
|
||||
context.db.commit()
|
||||
logger.info(f"用户修改密码: {user.email}")
|
||||
@@ -351,3 +487,177 @@ class AuthLogoutAdapter(AuthenticatedApiAdapter):
|
||||
else:
|
||||
logger.warning(f"用户登出失败(Redis不可用): {user.email}")
|
||||
return LogoutResponse(message="登出成功(降级模式)", success=False).model_dump()
|
||||
|
||||
|
||||
class AuthSendVerificationCodeAdapter(AuthPublicAdapter):
|
||||
async def handle(self, context): # type: ignore[override]
|
||||
"""发送邮箱验证码"""
|
||||
db = context.db
|
||||
payload = context.ensure_json_body()
|
||||
|
||||
try:
|
||||
send_request = SendVerificationCodeRequest.model_validate(payload)
|
||||
except ValidationError as exc:
|
||||
errors = []
|
||||
for error in exc.errors():
|
||||
field = " -> ".join(str(x) for x in error["loc"])
|
||||
errors.append(f"{field}: {error['msg']}")
|
||||
raise InvalidRequestException("输入验证失败: " + "; ".join(errors))
|
||||
|
||||
client_ip = get_client_ip(context.request)
|
||||
email = send_request.email
|
||||
|
||||
# IP 速率限制检查(验证码发送:3次/分钟)
|
||||
allowed, remaining, reset_after = await IPRateLimiter.check_limit(
|
||||
client_ip, "verification_send"
|
||||
)
|
||||
if not allowed:
|
||||
logger.warning(f"验证码发送请求超过速率限制: IP={client_ip}, 剩余={remaining}")
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_429_TOO_MANY_REQUESTS,
|
||||
detail=f"请求过于频繁,请在 {reset_after} 秒后重试",
|
||||
)
|
||||
|
||||
# 检查邮箱是否已注册
|
||||
existing_user = db.query(User).filter(User.email == email).first()
|
||||
if existing_user:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
detail="该邮箱已被注册,请直接登录或使用其他邮箱",
|
||||
)
|
||||
|
||||
# 检查邮箱后缀是否允许
|
||||
suffix_allowed, suffix_error = validate_email_suffix(db, email)
|
||||
if not suffix_allowed:
|
||||
logger.warning(f"邮箱后缀不允许: {email}")
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
detail=suffix_error,
|
||||
)
|
||||
|
||||
# 生成并发送验证码(使用服务中的默认配置)
|
||||
success, code_or_error, error_detail = await EmailVerificationService.send_verification_code(
|
||||
email
|
||||
)
|
||||
|
||||
if not success:
|
||||
logger.error(f"发送验证码失败: {email}, 错误: {code_or_error}")
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
detail=error_detail or code_or_error,
|
||||
)
|
||||
|
||||
# 发送邮件
|
||||
expire_minutes = EmailVerificationService.DEFAULT_CODE_EXPIRE_MINUTES
|
||||
email_success, email_error = await EmailSenderService.send_verification_code(
|
||||
db=db, to_email=email, code=code_or_error, expire_minutes=expire_minutes
|
||||
)
|
||||
|
||||
if not email_success:
|
||||
logger.error(f"发送验证码邮件失败: {email}, 错误: {email_error}")
|
||||
# 不向用户暴露 SMTP 详细错误信息,防止信息泄露
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
||||
detail="发送验证码失败,请稍后重试",
|
||||
)
|
||||
|
||||
logger.info(f"验证码已发送: {email}")
|
||||
|
||||
return SendVerificationCodeResponse(
|
||||
message="验证码已发送,请查收邮件",
|
||||
success=True,
|
||||
expire_minutes=expire_minutes,
|
||||
).model_dump()
|
||||
|
||||
|
||||
class AuthVerifyEmailAdapter(AuthPublicAdapter):
|
||||
async def handle(self, context): # type: ignore[override]
|
||||
"""验证邮箱验证码"""
|
||||
db = context.db
|
||||
payload = context.ensure_json_body()
|
||||
|
||||
try:
|
||||
verify_request = VerifyEmailRequest.model_validate(payload)
|
||||
except ValidationError as exc:
|
||||
errors = []
|
||||
for error in exc.errors():
|
||||
field = " -> ".join(str(x) for x in error["loc"])
|
||||
errors.append(f"{field}: {error['msg']}")
|
||||
raise InvalidRequestException("输入验证失败: " + "; ".join(errors))
|
||||
|
||||
client_ip = get_client_ip(context.request)
|
||||
email = verify_request.email
|
||||
code = verify_request.code
|
||||
|
||||
# IP 速率限制检查(验证码验证:10次/分钟)
|
||||
allowed, remaining, reset_after = await IPRateLimiter.check_limit(
|
||||
client_ip, "verification_verify"
|
||||
)
|
||||
if not allowed:
|
||||
logger.warning(f"验证码验证请求超过速率限制: IP={client_ip}, 剩余={remaining}")
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_429_TOO_MANY_REQUESTS,
|
||||
detail=f"请求过于频繁,请在 {reset_after} 秒后重试",
|
||||
)
|
||||
|
||||
# 验证验证码
|
||||
success, message = await EmailVerificationService.verify_code(email, code)
|
||||
|
||||
if not success:
|
||||
logger.warning(f"验证码验证失败: {email}, 原因: {message}")
|
||||
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail=message)
|
||||
|
||||
logger.info(f"邮箱验证成功: {email}")
|
||||
|
||||
return VerifyEmailResponse(message="邮箱验证成功", success=True).model_dump()
|
||||
|
||||
|
||||
class AuthVerificationStatusAdapter(AuthPublicAdapter):
|
||||
async def handle(self, context): # type: ignore[override]
|
||||
"""查询邮箱验证状态"""
|
||||
payload = context.ensure_json_body()
|
||||
|
||||
try:
|
||||
status_request = VerificationStatusRequest.model_validate(payload)
|
||||
except ValidationError as exc:
|
||||
errors = []
|
||||
for error in exc.errors():
|
||||
field = " -> ".join(str(x) for x in error["loc"])
|
||||
errors.append(f"{field}: {error['msg']}")
|
||||
raise InvalidRequestException("输入验证失败: " + "; ".join(errors))
|
||||
|
||||
client_ip = get_client_ip(context.request)
|
||||
email = status_request.email
|
||||
|
||||
# IP 速率限制检查(验证状态查询:20次/分钟)
|
||||
allowed, remaining, reset_after = await IPRateLimiter.check_limit(
|
||||
client_ip, "verification_status", limit=20
|
||||
)
|
||||
if not allowed:
|
||||
logger.warning(f"验证状态查询请求超过速率限制: IP={client_ip}, 剩余={remaining}")
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_429_TOO_MANY_REQUESTS,
|
||||
detail=f"请求过于频繁,请在 {reset_after} 秒后重试",
|
||||
)
|
||||
|
||||
# 获取验证状态
|
||||
status_data = await EmailVerificationService.get_verification_status(email)
|
||||
|
||||
# 计算冷却剩余时间
|
||||
cooldown_remaining = None
|
||||
if status_data.get("has_pending_code") and status_data.get("created_at"):
|
||||
from datetime import datetime, timezone
|
||||
|
||||
created_at = datetime.fromisoformat(status_data["created_at"])
|
||||
elapsed = (datetime.now(timezone.utc) - created_at).total_seconds()
|
||||
cooldown = EmailVerificationService.SEND_COOLDOWN_SECONDS - int(elapsed)
|
||||
if cooldown > 0:
|
||||
cooldown_remaining = cooldown
|
||||
|
||||
return VerificationStatusResponse(
|
||||
email=email,
|
||||
has_pending_code=status_data.get("has_pending_code", False),
|
||||
is_verified=status_data.get("is_verified", False),
|
||||
cooldown_remaining=cooldown_remaining,
|
||||
code_expires_in=status_data.get("code_expires_in"),
|
||||
).model_dump()
|
||||
|
||||
@@ -18,7 +18,15 @@ from sqlalchemy.orm import Session, joinedload
|
||||
from src.config.constants import CacheTTL
|
||||
from src.core.cache_service import CacheService
|
||||
from src.core.logger import logger
|
||||
from src.models.database import GlobalModel, Model, Provider, ProviderAPIKey, ProviderEndpoint
|
||||
from src.models.database import (
|
||||
ApiKey,
|
||||
GlobalModel,
|
||||
Model,
|
||||
Provider,
|
||||
ProviderAPIKey,
|
||||
ProviderEndpoint,
|
||||
User,
|
||||
)
|
||||
|
||||
# 缓存 key 前缀
|
||||
_CACHE_KEY_PREFIX = "models:list"
|
||||
@@ -82,6 +90,7 @@ class ModelInfo:
|
||||
created_at: Optional[str] # ISO 格式
|
||||
created_timestamp: int # Unix 时间戳
|
||||
provider_name: str
|
||||
provider_id: str = "" # Provider ID,用于权限过滤
|
||||
# 能力配置
|
||||
streaming: bool = True
|
||||
vision: bool = False
|
||||
@@ -99,6 +108,92 @@ class ModelInfo:
|
||||
output_modalities: Optional[list[str]] = None
|
||||
|
||||
|
||||
@dataclass
|
||||
class AccessRestrictions:
|
||||
"""API Key 或 User 的访问限制"""
|
||||
|
||||
allowed_providers: Optional[list[str]] = None # 允许的 Provider ID 列表
|
||||
allowed_models: Optional[list[str]] = None # 允许的模型名称列表
|
||||
allowed_api_formats: Optional[list[str]] = None # 允许的 API 格式列表
|
||||
|
||||
@classmethod
|
||||
def from_api_key_and_user(
|
||||
cls, api_key: Optional[ApiKey], user: Optional[User]
|
||||
) -> "AccessRestrictions":
|
||||
"""
|
||||
从 API Key 和 User 合并访问限制
|
||||
|
||||
限制逻辑:
|
||||
- API Key 的限制优先于 User 的限制
|
||||
- 如果 API Key 有限制,使用 API Key 的限制
|
||||
- 如果 API Key 无限制但 User 有限制,使用 User 的限制
|
||||
- 两者都无限制则返回空限制
|
||||
"""
|
||||
allowed_providers: Optional[list[str]] = None
|
||||
allowed_models: Optional[list[str]] = None
|
||||
allowed_api_formats: Optional[list[str]] = None
|
||||
|
||||
# 优先使用 API Key 的限制
|
||||
if api_key:
|
||||
if api_key.allowed_providers is not None:
|
||||
allowed_providers = api_key.allowed_providers
|
||||
if api_key.allowed_models is not None:
|
||||
allowed_models = api_key.allowed_models
|
||||
if api_key.allowed_api_formats is not None:
|
||||
allowed_api_formats = api_key.allowed_api_formats
|
||||
|
||||
# 如果 API Key 没有限制,检查 User 的限制
|
||||
# 注意: User 没有 allowed_api_formats 字段
|
||||
if user:
|
||||
if allowed_providers is None and user.allowed_providers is not None:
|
||||
allowed_providers = user.allowed_providers
|
||||
if allowed_models is None and user.allowed_models is not None:
|
||||
allowed_models = user.allowed_models
|
||||
|
||||
return cls(
|
||||
allowed_providers=allowed_providers,
|
||||
allowed_models=allowed_models,
|
||||
allowed_api_formats=allowed_api_formats,
|
||||
)
|
||||
|
||||
def is_api_format_allowed(self, api_format: str) -> bool:
|
||||
"""
|
||||
检查 API 格式是否被允许
|
||||
|
||||
Args:
|
||||
api_format: API 格式 (如 "OPENAI", "CLAUDE", "GEMINI")
|
||||
|
||||
Returns:
|
||||
True 如果格式被允许,False 否则
|
||||
"""
|
||||
if self.allowed_api_formats is None:
|
||||
return True
|
||||
return api_format in self.allowed_api_formats
|
||||
|
||||
def is_model_allowed(self, model_id: str, provider_id: str) -> bool:
|
||||
"""
|
||||
检查模型是否被允许访问
|
||||
|
||||
Args:
|
||||
model_id: 模型 ID
|
||||
provider_id: Provider ID
|
||||
|
||||
Returns:
|
||||
True 如果模型被允许,False 否则
|
||||
"""
|
||||
# 检查 Provider 限制
|
||||
if self.allowed_providers is not None:
|
||||
if provider_id not in self.allowed_providers:
|
||||
return False
|
||||
|
||||
# 检查模型限制
|
||||
if self.allowed_models is not None:
|
||||
if model_id not in self.allowed_models:
|
||||
return False
|
||||
|
||||
return True
|
||||
|
||||
|
||||
def get_available_provider_ids(db: Session, api_formats: list[str]) -> set[str]:
|
||||
"""
|
||||
返回有可用端点的 Provider IDs
|
||||
@@ -218,6 +313,7 @@ def _extract_model_info(model: Any) -> ModelInfo:
|
||||
)
|
||||
created_timestamp: int = int(model.created_at.timestamp()) if model.created_at else 0
|
||||
provider_name: str = model.provider.name if model.provider else "unknown"
|
||||
provider_id: str = model.provider_id or ""
|
||||
|
||||
# 从 GlobalModel.config 提取配置信息
|
||||
config: dict = {}
|
||||
@@ -233,6 +329,7 @@ def _extract_model_info(model: Any) -> ModelInfo:
|
||||
created_at=created_at,
|
||||
created_timestamp=created_timestamp,
|
||||
provider_name=provider_name,
|
||||
provider_id=provider_id,
|
||||
# 能力配置
|
||||
streaming=config.get("streaming", True),
|
||||
vision=config.get("vision", False),
|
||||
@@ -255,6 +352,7 @@ async def list_available_models(
|
||||
db: Session,
|
||||
available_provider_ids: set[str],
|
||||
api_formats: Optional[list[str]] = None,
|
||||
restrictions: Optional[AccessRestrictions] = None,
|
||||
) -> list[ModelInfo]:
|
||||
"""
|
||||
获取可用模型列表(已去重,带缓存)
|
||||
@@ -263,6 +361,7 @@ async def list_available_models(
|
||||
db: 数据库会话
|
||||
available_provider_ids: 有可用端点的 Provider ID 集合
|
||||
api_formats: API 格式列表,用于检查 Key 的 allowed_models
|
||||
restrictions: API Key/User 的访问限制
|
||||
|
||||
Returns:
|
||||
去重后的 ModelInfo 列表,按创建时间倒序
|
||||
@@ -270,8 +369,16 @@ async def list_available_models(
|
||||
if not available_provider_ids:
|
||||
return []
|
||||
|
||||
# 缓存策略:只有完全无访问限制时才使用缓存
|
||||
# - restrictions is None: 未传入限制对象
|
||||
# - restrictions 的两个字段都为 None: 传入了限制对象但无实际限制
|
||||
# 以上两种情况返回的结果相同,可以共享全局缓存
|
||||
use_cache = restrictions is None or (
|
||||
restrictions.allowed_providers is None and restrictions.allowed_models is None
|
||||
)
|
||||
|
||||
# 尝试从缓存获取
|
||||
if api_formats:
|
||||
if api_formats and use_cache:
|
||||
cached = await _get_cached_models(api_formats)
|
||||
if cached is not None:
|
||||
return cached
|
||||
@@ -306,14 +413,19 @@ async def list_available_models(
|
||||
if available_model_ids is not None and info.id not in available_model_ids:
|
||||
continue
|
||||
|
||||
# 检查 API Key/User 访问限制
|
||||
if restrictions is not None:
|
||||
if not restrictions.is_model_allowed(info.id, info.provider_id):
|
||||
continue
|
||||
|
||||
if info.id in seen_model_ids:
|
||||
continue
|
||||
seen_model_ids.add(info.id)
|
||||
|
||||
result.append(info)
|
||||
|
||||
# 写入缓存
|
||||
if api_formats:
|
||||
# 只有无限制的情况才写入缓存
|
||||
if api_formats and use_cache:
|
||||
await _set_cached_models(api_formats, result)
|
||||
|
||||
return result
|
||||
@@ -324,6 +436,7 @@ def find_model_by_id(
|
||||
model_id: str,
|
||||
available_provider_ids: set[str],
|
||||
api_formats: Optional[list[str]] = None,
|
||||
restrictions: Optional[AccessRestrictions] = None,
|
||||
) -> Optional[ModelInfo]:
|
||||
"""
|
||||
按 ID 查找模型
|
||||
@@ -338,6 +451,7 @@ def find_model_by_id(
|
||||
model_id: 模型 ID
|
||||
available_provider_ids: 有可用端点的 Provider ID 集合
|
||||
api_formats: API 格式列表,用于检查 Key 的 allowed_models
|
||||
restrictions: API Key/User 的访问限制
|
||||
|
||||
Returns:
|
||||
ModelInfo 或 None
|
||||
@@ -353,6 +467,11 @@ def find_model_by_id(
|
||||
if available_model_ids is not None and model_id not in available_model_ids:
|
||||
return None
|
||||
|
||||
# 快速检查:如果 restrictions 明确限制了模型列表且目标模型不在其中,直接返回 None
|
||||
if restrictions is not None and restrictions.allowed_models is not None:
|
||||
if model_id not in restrictions.allowed_models:
|
||||
return None
|
||||
|
||||
# 先按 GlobalModel.name 查找
|
||||
models_by_global = (
|
||||
db.query(Model)
|
||||
@@ -368,8 +487,19 @@ def find_model_by_id(
|
||||
.all()
|
||||
)
|
||||
|
||||
def is_model_accessible(m: Model) -> bool:
|
||||
"""检查模型是否可访问"""
|
||||
if m.provider_id not in available_provider_ids:
|
||||
return False
|
||||
# 检查 API Key/User 访问限制
|
||||
if restrictions is not None:
|
||||
provider_id = m.provider_id or ""
|
||||
if not restrictions.is_model_allowed(model_id, provider_id):
|
||||
return False
|
||||
return True
|
||||
|
||||
model = next(
|
||||
(m for m in models_by_global if m.provider_id in available_provider_ids),
|
||||
(m for m in models_by_global if is_model_accessible(m)),
|
||||
None,
|
||||
)
|
||||
|
||||
@@ -393,7 +523,7 @@ def find_model_by_id(
|
||||
)
|
||||
|
||||
model = next(
|
||||
(m for m in models_by_provider_name if m.provider_id in available_provider_ids),
|
||||
(m for m in models_by_provider_name if is_model_accessible(m)),
|
||||
None,
|
||||
)
|
||||
|
||||
|
||||
@@ -118,7 +118,9 @@ class AdminDashboardStatsAdapter(AdminApiAdapter):
|
||||
# 转换为 UTC 用于与 stats_daily.date 比较(存储的是业务日期对应的 UTC 开始时间)
|
||||
today = today_local.astimezone(timezone.utc)
|
||||
yesterday = (today_local - timedelta(days=1)).astimezone(timezone.utc)
|
||||
last_month = (today_local - timedelta(days=30)).astimezone(timezone.utc)
|
||||
# 本月第一天(自然月)
|
||||
month_start_local = today_local.replace(day=1)
|
||||
month_start = month_start_local.astimezone(timezone.utc)
|
||||
|
||||
# ==================== 使用预聚合数据 ====================
|
||||
# 从 stats_summary + 今日实时数据获取全局统计
|
||||
@@ -208,7 +210,7 @@ class AdminDashboardStatsAdapter(AdminApiAdapter):
|
||||
func.sum(StatsDaily.cache_read_cost).label("cache_read_cost"),
|
||||
func.sum(StatsDaily.fallback_count).label("fallback_count"),
|
||||
)
|
||||
.filter(StatsDaily.date >= last_month, StatsDaily.date < today)
|
||||
.filter(StatsDaily.date >= month_start, StatsDaily.date < today)
|
||||
.first()
|
||||
)
|
||||
|
||||
@@ -227,24 +229,24 @@ class AdminDashboardStatsAdapter(AdminApiAdapter):
|
||||
else:
|
||||
# 回退到实时查询(没有预聚合数据时)
|
||||
total_requests = (
|
||||
db.query(func.count(Usage.id)).filter(Usage.created_at >= last_month).scalar() or 0
|
||||
db.query(func.count(Usage.id)).filter(Usage.created_at >= month_start).scalar() or 0
|
||||
)
|
||||
total_cost = (
|
||||
db.query(func.sum(Usage.total_cost_usd)).filter(Usage.created_at >= last_month).scalar() or 0
|
||||
db.query(func.sum(Usage.total_cost_usd)).filter(Usage.created_at >= month_start).scalar() or 0
|
||||
)
|
||||
total_actual_cost = (
|
||||
db.query(func.sum(Usage.actual_total_cost_usd))
|
||||
.filter(Usage.created_at >= last_month).scalar() or 0
|
||||
.filter(Usage.created_at >= month_start).scalar() or 0
|
||||
)
|
||||
error_requests = (
|
||||
db.query(func.count(Usage.id))
|
||||
.filter(
|
||||
Usage.created_at >= last_month,
|
||||
Usage.created_at >= month_start,
|
||||
(Usage.status_code >= 400) | (Usage.error_message.isnot(None)),
|
||||
).scalar() or 0
|
||||
)
|
||||
total_tokens = (
|
||||
db.query(func.sum(Usage.total_tokens)).filter(Usage.created_at >= last_month).scalar() or 0
|
||||
db.query(func.sum(Usage.total_tokens)).filter(Usage.created_at >= month_start).scalar() or 0
|
||||
)
|
||||
cache_stats = (
|
||||
db.query(
|
||||
@@ -253,7 +255,7 @@ class AdminDashboardStatsAdapter(AdminApiAdapter):
|
||||
func.sum(Usage.cache_creation_cost_usd).label("cache_creation_cost"),
|
||||
func.sum(Usage.cache_read_cost_usd).label("cache_read_cost"),
|
||||
)
|
||||
.filter(Usage.created_at >= last_month)
|
||||
.filter(Usage.created_at >= month_start)
|
||||
.first()
|
||||
)
|
||||
cache_creation_tokens = int(cache_stats.cache_creation_tokens or 0) if cache_stats else 0
|
||||
@@ -267,7 +269,7 @@ class AdminDashboardStatsAdapter(AdminApiAdapter):
|
||||
RequestCandidate.request_id, func.count(RequestCandidate.id).label("executed_count")
|
||||
)
|
||||
.filter(
|
||||
RequestCandidate.created_at >= last_month,
|
||||
RequestCandidate.created_at >= month_start,
|
||||
RequestCandidate.status.in_(["success", "failed"]),
|
||||
)
|
||||
.group_by(RequestCandidate.request_id)
|
||||
@@ -447,7 +449,9 @@ class UserDashboardStatsAdapter(DashboardAdapter):
|
||||
# 转换为 UTC 用于数据库查询
|
||||
today = today_local.astimezone(timezone.utc)
|
||||
yesterday = (today_local - timedelta(days=1)).astimezone(timezone.utc)
|
||||
last_month = (today_local - timedelta(days=30)).astimezone(timezone.utc)
|
||||
# 本月第一天(自然月)
|
||||
month_start_local = today_local.replace(day=1)
|
||||
month_start = month_start_local.astimezone(timezone.utc)
|
||||
|
||||
user_api_keys = db.query(func.count(ApiKey.id)).filter(ApiKey.user_id == user.id).scalar()
|
||||
active_keys = (
|
||||
@@ -483,12 +487,12 @@ class UserDashboardStatsAdapter(DashboardAdapter):
|
||||
# 本月请求统计
|
||||
user_requests = (
|
||||
db.query(func.count(Usage.id))
|
||||
.filter(and_(Usage.user_id == user.id, Usage.created_at >= last_month))
|
||||
.filter(and_(Usage.user_id == user.id, Usage.created_at >= month_start))
|
||||
.scalar()
|
||||
)
|
||||
user_cost = (
|
||||
db.query(func.sum(Usage.total_cost_usd))
|
||||
.filter(and_(Usage.user_id == user.id, Usage.created_at >= last_month))
|
||||
.filter(and_(Usage.user_id == user.id, Usage.created_at >= month_start))
|
||||
.scalar()
|
||||
or 0
|
||||
)
|
||||
@@ -532,18 +536,19 @@ class UserDashboardStatsAdapter(DashboardAdapter):
|
||||
func.sum(Usage.cache_read_input_tokens).label("cache_read_tokens"),
|
||||
func.sum(Usage.input_tokens).label("total_input_tokens"),
|
||||
)
|
||||
.filter(and_(Usage.user_id == user.id, Usage.created_at >= last_month))
|
||||
.filter(and_(Usage.user_id == user.id, Usage.created_at >= month_start))
|
||||
.first()
|
||||
)
|
||||
cache_creation_tokens = int(cache_stats.cache_creation_tokens or 0) if cache_stats else 0
|
||||
cache_read_tokens = int(cache_stats.cache_read_tokens or 0) if cache_stats else 0
|
||||
monthly_input_tokens = int(cache_stats.total_input_tokens or 0) if cache_stats else 0
|
||||
|
||||
# 计算缓存命中率:cache_read / (input_tokens + cache_read)
|
||||
# 计算本月缓存命中率:cache_read / (input_tokens + cache_read)
|
||||
# input_tokens 是实际发送给模型的输入(不含缓存读取),cache_read 是从缓存读取的
|
||||
# 总输入 = input_tokens + cache_read,缓存命中率 = cache_read / 总输入
|
||||
total_input_with_cache = all_time_input_tokens + all_time_cache_read
|
||||
total_input_with_cache = monthly_input_tokens + cache_read_tokens
|
||||
cache_hit_rate = (
|
||||
round((all_time_cache_read / total_input_with_cache) * 100, 1)
|
||||
round((cache_read_tokens / total_input_with_cache) * 100, 1)
|
||||
if total_input_with_cache > 0
|
||||
else 0
|
||||
)
|
||||
@@ -569,15 +574,15 @@ class UserDashboardStatsAdapter(DashboardAdapter):
|
||||
quota_value = "无限制"
|
||||
quota_change = f"已用 ${user.used_usd:.2f}"
|
||||
quota_high = False
|
||||
elif user.quota_usd and user.quota_usd > 0:
|
||||
elif user.quota_usd > 0:
|
||||
percent = min(100, int((user.used_usd / user.quota_usd) * 100))
|
||||
quota_value = "无限制"
|
||||
quota_value = f"${user.quota_usd:.0f}"
|
||||
quota_change = f"已用 ${user.used_usd:.2f}"
|
||||
quota_high = percent > 80
|
||||
else:
|
||||
quota_value = "0%"
|
||||
quota_value = "$0"
|
||||
quota_change = f"已用 ${user.used_usd:.2f}"
|
||||
quota_high = False
|
||||
quota_high = True
|
||||
|
||||
return {
|
||||
"stats": [
|
||||
@@ -605,9 +610,15 @@ class UserDashboardStatsAdapter(DashboardAdapter):
|
||||
"icon": "TrendingUp",
|
||||
},
|
||||
{
|
||||
"name": "本月费用",
|
||||
"value": f"${user_cost:.2f}",
|
||||
"icon": "DollarSign",
|
||||
"name": "总Token",
|
||||
"value": format_tokens(
|
||||
all_time_input_tokens
|
||||
+ all_time_output_tokens
|
||||
+ all_time_cache_creation
|
||||
+ all_time_cache_read
|
||||
),
|
||||
"subValue": f"输入 {format_tokens(all_time_input_tokens)} / 输出 {format_tokens(all_time_output_tokens)}",
|
||||
"icon": "Hash",
|
||||
},
|
||||
],
|
||||
"today": {
|
||||
@@ -631,6 +642,8 @@ class UserDashboardStatsAdapter(DashboardAdapter):
|
||||
"cache_hit_rate": cache_hit_rate,
|
||||
"total_cache_tokens": cache_creation_tokens + cache_read_tokens,
|
||||
},
|
||||
# 本月费用(用于下方缓存区域显示)
|
||||
"monthly_cost": float(user_cost),
|
||||
}
|
||||
|
||||
|
||||
|
||||
@@ -47,7 +47,6 @@ if TYPE_CHECKING:
|
||||
from src.api.handlers.base.stream_context import StreamContext
|
||||
|
||||
|
||||
|
||||
class MessageTelemetry:
|
||||
"""
|
||||
负责记录 Usage/Audit,避免处理器里重复代码。
|
||||
@@ -406,7 +405,7 @@ class BaseMessageHandler:
|
||||
asyncio.create_task(_do_update())
|
||||
|
||||
def _update_usage_to_streaming_with_ctx(self, ctx: "StreamContext") -> None:
|
||||
"""更新 Usage 状态为 streaming,同时更新 provider 和 target_model
|
||||
"""更新 Usage 状态为 streaming,同时更新 provider 相关信息
|
||||
|
||||
使用 asyncio 后台任务执行数据库更新,避免阻塞流式传输
|
||||
|
||||
@@ -414,7 +413,7 @@ class BaseMessageHandler:
|
||||
并在最终 record_success 时传递到数据库,避免重复记录导致数据不一致。
|
||||
|
||||
Args:
|
||||
ctx: 流式上下文,包含 provider_name 和 mapped_model
|
||||
ctx: 流式上下文,包含 provider 相关信息
|
||||
"""
|
||||
import asyncio
|
||||
from src.database.database import get_db
|
||||
@@ -422,6 +421,17 @@ class BaseMessageHandler:
|
||||
target_request_id = self.request_id
|
||||
provider = ctx.provider_name
|
||||
target_model = ctx.mapped_model
|
||||
provider_id = ctx.provider_id
|
||||
endpoint_id = ctx.endpoint_id
|
||||
key_id = ctx.key_id
|
||||
first_byte_time_ms = ctx.first_byte_time_ms
|
||||
|
||||
# 如果 provider 为空,记录警告(不应该发生,但用于调试)
|
||||
if not provider:
|
||||
logger.warning(
|
||||
f"[{target_request_id}] 更新 streaming 状态时 provider 为空: "
|
||||
f"ctx.provider_name={ctx.provider_name}, ctx.provider_id={ctx.provider_id}"
|
||||
)
|
||||
|
||||
async def _do_update() -> None:
|
||||
try:
|
||||
@@ -434,6 +444,10 @@ class BaseMessageHandler:
|
||||
status="streaming",
|
||||
provider=provider,
|
||||
target_model=target_model,
|
||||
provider_id=provider_id,
|
||||
provider_endpoint_id=endpoint_id,
|
||||
provider_api_key_id=key_id,
|
||||
first_byte_time_ms=first_byte_time_ms,
|
||||
)
|
||||
finally:
|
||||
db.close()
|
||||
|
||||
@@ -40,6 +40,7 @@ from src.core.exceptions import (
|
||||
UpstreamClientException,
|
||||
)
|
||||
from src.core.logger import logger
|
||||
from src.services.billing import calculate_request_cost as _calculate_request_cost
|
||||
from src.services.request.result import RequestResult
|
||||
from src.services.usage.recorder import UsageRecorder
|
||||
|
||||
@@ -63,6 +64,37 @@ class ChatAdapterBase(ApiAdapter):
|
||||
name: str = "chat.base"
|
||||
mode = ApiMode.STANDARD
|
||||
|
||||
# 计费模板配置(子类可覆盖,如 "claude", "openai", "gemini")
|
||||
BILLING_TEMPLATE: str = "claude"
|
||||
|
||||
# 子类可以配置的特殊方法(用于check_endpoint)
|
||||
@classmethod
|
||||
def build_endpoint_url(cls, base_url: str) -> str:
|
||||
"""构建端点URL,子类可以覆盖以自定义URL构建逻辑"""
|
||||
# 默认实现:在base_url后添加特定路径
|
||||
return base_url
|
||||
|
||||
@classmethod
|
||||
def build_base_headers(cls, api_key: str) -> Dict[str, str]:
|
||||
"""构建基础请求头,子类可以覆盖以自定义认证头"""
|
||||
# 默认实现:Bearer token认证
|
||||
return {
|
||||
"Authorization": f"Bearer {api_key}",
|
||||
"Content-Type": "application/json",
|
||||
}
|
||||
|
||||
@classmethod
|
||||
def get_protected_header_keys(cls) -> tuple:
|
||||
"""返回不应被extra_headers覆盖的头部key,子类可以覆盖"""
|
||||
# 默认保护认证相关头部
|
||||
return ("authorization", "content-type")
|
||||
|
||||
@classmethod
|
||||
def build_request_body(cls, request_data: Dict[str, Any]) -> Dict[str, Any]:
|
||||
"""构建请求体,子类可以覆盖以自定义请求格式转换"""
|
||||
# 默认实现:直接使用请求数据
|
||||
return request_data.copy()
|
||||
|
||||
def __init__(self, allowed_api_formats: Optional[list[str]] = None):
|
||||
self.allowed_api_formats = allowed_api_formats or [self.FORMAT_ID]
|
||||
|
||||
@@ -458,40 +490,6 @@ class ChatAdapterBase(ApiAdapter):
|
||||
"""
|
||||
return input_tokens + cache_read_input_tokens
|
||||
|
||||
def get_cache_read_price_for_ttl(
|
||||
self,
|
||||
tier: dict,
|
||||
cache_ttl_minutes: Optional[int] = None,
|
||||
) -> Optional[float]:
|
||||
"""
|
||||
根据缓存 TTL 获取缓存读取价格
|
||||
|
||||
默认实现:检查 cache_ttl_pricing 配置,按 TTL 选择价格
|
||||
子类可覆盖此方法实现不同的 TTL 定价逻辑
|
||||
|
||||
Args:
|
||||
tier: 当前阶梯配置
|
||||
cache_ttl_minutes: 缓存时长(分钟)
|
||||
|
||||
Returns:
|
||||
缓存读取价格(每 1M tokens)
|
||||
"""
|
||||
ttl_pricing = tier.get("cache_ttl_pricing")
|
||||
if ttl_pricing and cache_ttl_minutes is not None:
|
||||
matched_price = None
|
||||
for ttl_config in ttl_pricing:
|
||||
ttl_limit = ttl_config.get("ttl_minutes", 0)
|
||||
if cache_ttl_minutes <= ttl_limit:
|
||||
matched_price = ttl_config.get("cache_read_price_per_1m")
|
||||
break
|
||||
if matched_price is not None:
|
||||
return matched_price
|
||||
# 超过所有配置的 TTL,使用最后一个
|
||||
if ttl_pricing:
|
||||
return ttl_pricing[-1].get("cache_read_price_per_1m")
|
||||
|
||||
return tier.get("cache_read_price_per_1m")
|
||||
|
||||
def compute_cost(
|
||||
self,
|
||||
input_tokens: int,
|
||||
@@ -509,8 +507,9 @@ class ChatAdapterBase(ApiAdapter):
|
||||
"""
|
||||
计算请求成本
|
||||
|
||||
默认实现:支持固定价格和阶梯计费
|
||||
子类可覆盖此方法实现完全不同的计费逻辑
|
||||
使用 billing 模块的配置驱动计费。
|
||||
子类可通过设置 BILLING_TEMPLATE 类属性来指定计费模板,
|
||||
或覆盖此方法实现完全自定义的计费逻辑。
|
||||
|
||||
Args:
|
||||
input_tokens: 输入 token 数
|
||||
@@ -538,88 +537,26 @@ class ChatAdapterBase(ApiAdapter):
|
||||
"tier_index": Optional[int], # 命中的阶梯索引
|
||||
}
|
||||
"""
|
||||
tier_index = None
|
||||
effective_input_price = input_price_per_1m
|
||||
effective_output_price = output_price_per_1m
|
||||
effective_cache_creation_price = cache_creation_price_per_1m
|
||||
effective_cache_read_price = cache_read_price_per_1m
|
||||
|
||||
# 检查阶梯计费
|
||||
if tiered_pricing and tiered_pricing.get("tiers"):
|
||||
# 计算总输入上下文(使用子类可覆盖的方法)
|
||||
total_input_context = self.compute_total_input_context(
|
||||
input_tokens, cache_read_input_tokens, cache_creation_input_tokens
|
||||
)
|
||||
tier = self._get_tier_for_tokens(tiered_pricing, total_input_context)
|
||||
|
||||
if tier:
|
||||
tier_index = tiered_pricing["tiers"].index(tier)
|
||||
effective_input_price = tier.get("input_price_per_1m", input_price_per_1m)
|
||||
effective_output_price = tier.get("output_price_per_1m", output_price_per_1m)
|
||||
effective_cache_creation_price = tier.get(
|
||||
"cache_creation_price_per_1m", cache_creation_price_per_1m
|
||||
return _calculate_request_cost(
|
||||
input_tokens=input_tokens,
|
||||
output_tokens=output_tokens,
|
||||
cache_creation_input_tokens=cache_creation_input_tokens,
|
||||
cache_read_input_tokens=cache_read_input_tokens,
|
||||
input_price_per_1m=input_price_per_1m,
|
||||
output_price_per_1m=output_price_per_1m,
|
||||
cache_creation_price_per_1m=cache_creation_price_per_1m,
|
||||
cache_read_price_per_1m=cache_read_price_per_1m,
|
||||
price_per_request=price_per_request,
|
||||
tiered_pricing=tiered_pricing,
|
||||
cache_ttl_minutes=cache_ttl_minutes,
|
||||
total_input_context=total_input_context,
|
||||
billing_template=self.BILLING_TEMPLATE,
|
||||
)
|
||||
effective_cache_read_price = self.get_cache_read_price_for_ttl(
|
||||
tier, cache_ttl_minutes
|
||||
)
|
||||
if effective_cache_read_price is None:
|
||||
effective_cache_read_price = cache_read_price_per_1m
|
||||
|
||||
# 计算各项成本
|
||||
input_cost = (input_tokens / 1_000_000) * effective_input_price
|
||||
output_cost = (output_tokens / 1_000_000) * effective_output_price
|
||||
|
||||
cache_creation_cost = 0.0
|
||||
cache_read_cost = 0.0
|
||||
if cache_creation_input_tokens > 0 and effective_cache_creation_price is not None:
|
||||
cache_creation_cost = (
|
||||
cache_creation_input_tokens / 1_000_000
|
||||
) * effective_cache_creation_price
|
||||
if cache_read_input_tokens > 0 and effective_cache_read_price is not None:
|
||||
cache_read_cost = (
|
||||
cache_read_input_tokens / 1_000_000
|
||||
) * effective_cache_read_price
|
||||
|
||||
cache_cost = cache_creation_cost + cache_read_cost
|
||||
request_cost = price_per_request if price_per_request else 0.0
|
||||
total_cost = input_cost + output_cost + cache_cost + request_cost
|
||||
|
||||
return {
|
||||
"input_cost": input_cost,
|
||||
"output_cost": output_cost,
|
||||
"cache_creation_cost": cache_creation_cost,
|
||||
"cache_read_cost": cache_read_cost,
|
||||
"cache_cost": cache_cost,
|
||||
"request_cost": request_cost,
|
||||
"total_cost": total_cost,
|
||||
"tier_index": tier_index,
|
||||
}
|
||||
|
||||
@staticmethod
|
||||
def _get_tier_for_tokens(tiered_pricing: dict, total_input_tokens: int) -> Optional[dict]:
|
||||
"""
|
||||
根据总输入 token 数确定价格阶梯
|
||||
|
||||
Args:
|
||||
tiered_pricing: 阶梯计费配置 {"tiers": [...]}
|
||||
total_input_tokens: 总输入 token 数
|
||||
|
||||
Returns:
|
||||
匹配的阶梯配置
|
||||
"""
|
||||
if not tiered_pricing or "tiers" not in tiered_pricing:
|
||||
return None
|
||||
|
||||
tiers = tiered_pricing.get("tiers", [])
|
||||
if not tiers:
|
||||
return None
|
||||
|
||||
for tier in tiers:
|
||||
up_to = tier.get("up_to")
|
||||
if up_to is None or total_input_tokens <= up_to:
|
||||
return tier
|
||||
|
||||
# 如果所有阶梯都有上限且都超过了,返回最后一个阶梯
|
||||
return tiers[-1] if tiers else None
|
||||
|
||||
# =========================================================================
|
||||
# 模型列表查询 - 子类应覆盖此方法
|
||||
@@ -654,6 +591,65 @@ class ChatAdapterBase(ApiAdapter):
|
||||
# 默认实现返回空列表,子类应覆盖
|
||||
return [], f"{cls.FORMAT_ID} adapter does not implement fetch_models"
|
||||
|
||||
@classmethod
|
||||
async def check_endpoint(
|
||||
cls,
|
||||
client: httpx.AsyncClient,
|
||||
base_url: str,
|
||||
api_key: str,
|
||||
request_data: Dict[str, Any],
|
||||
extra_headers: Optional[Dict[str, str]] = None,
|
||||
# 用量计算参数(现在强制记录)
|
||||
db: Optional[Any] = None,
|
||||
user: Optional[Any] = None,
|
||||
provider_name: Optional[str] = None,
|
||||
provider_id: Optional[str] = None,
|
||||
api_key_id: Optional[str] = None,
|
||||
model_name: Optional[str] = None,
|
||||
) -> Dict[str, Any]:
|
||||
"""
|
||||
测试模型连接性(非流式)
|
||||
|
||||
Args:
|
||||
client: httpx 异步客户端
|
||||
base_url: API 基础 URL
|
||||
api_key: API 密钥(已解密)
|
||||
request_data: 请求数据
|
||||
extra_headers: 端点配置的额外请求头
|
||||
db: 数据库会话
|
||||
user: 用户对象
|
||||
provider_name: 提供商名称
|
||||
provider_id: 提供商ID
|
||||
api_key_id: API Key ID
|
||||
model_name: 模型名称
|
||||
|
||||
Returns:
|
||||
测试响应数据
|
||||
"""
|
||||
from src.api.handlers.base.endpoint_checker import build_safe_headers, run_endpoint_check
|
||||
|
||||
# 使用子类配置方法构建请求组件
|
||||
url = cls.build_endpoint_url(base_url)
|
||||
base_headers = cls.build_base_headers(api_key)
|
||||
protected_keys = cls.get_protected_header_keys()
|
||||
headers = build_safe_headers(base_headers, extra_headers, protected_keys)
|
||||
body = cls.build_request_body(request_data)
|
||||
|
||||
# 使用通用的endpoint checker执行请求
|
||||
return await run_endpoint_check(
|
||||
client=client,
|
||||
url=url,
|
||||
headers=headers,
|
||||
json_body=body,
|
||||
api_format=cls.name,
|
||||
# 用量计算参数(现在强制记录)
|
||||
db=db,
|
||||
user=user,
|
||||
provider_name=provider_name,
|
||||
provider_id=provider_id,
|
||||
api_key_id=api_key_id,
|
||||
model_name=model_name or request_data.get("model"),
|
||||
)
|
||||
|
||||
# =========================================================================
|
||||
# Adapter 注册表 - 用于根据 API format 获取 Adapter 实例
|
||||
|
||||
@@ -36,6 +36,7 @@ from src.api.handlers.base.stream_processor import StreamProcessor
|
||||
from src.api.handlers.base.stream_telemetry import StreamTelemetryRecorder
|
||||
from src.api.handlers.base.utils import build_sse_headers
|
||||
from src.config.settings import config
|
||||
from src.core.error_utils import extract_error_message
|
||||
from src.core.exceptions import (
|
||||
EmbeddedErrorException,
|
||||
ProviderAuthException,
|
||||
@@ -500,6 +501,8 @@ class ChatHandlerBase(BaseMessageHandler, ABC):
|
||||
error_text = await self._extract_error_text(e)
|
||||
logger.error(f"Provider 返回错误: {e.response.status_code}\n Response: {error_text}")
|
||||
await http_client.aclose()
|
||||
# 将上游错误信息附加到异常,以便故障转移时能够返回给客户端
|
||||
e.upstream_response = error_text # type: ignore[attr-defined]
|
||||
raise
|
||||
|
||||
except EmbeddedErrorException:
|
||||
@@ -549,7 +552,7 @@ class ChatHandlerBase(BaseMessageHandler, ABC):
|
||||
model=ctx.model,
|
||||
response_time_ms=response_time_ms,
|
||||
status_code=status_code,
|
||||
error_message=str(error),
|
||||
error_message=extract_error_message(error),
|
||||
request_headers=original_headers,
|
||||
request_body=actual_request_body,
|
||||
is_stream=True,
|
||||
@@ -785,7 +788,7 @@ class ChatHandlerBase(BaseMessageHandler, ABC):
|
||||
model=model,
|
||||
response_time_ms=response_time_ms,
|
||||
status_code=status_code,
|
||||
error_message=str(e),
|
||||
error_message=extract_error_message(e),
|
||||
request_headers=original_headers,
|
||||
request_body=actual_request_body,
|
||||
is_stream=False,
|
||||
@@ -802,10 +805,10 @@ class ChatHandlerBase(BaseMessageHandler, ABC):
|
||||
try:
|
||||
if hasattr(e.response, "is_stream_consumed") and not e.response.is_stream_consumed:
|
||||
error_bytes = await e.response.aread()
|
||||
return error_bytes.decode("utf-8", errors="replace")[:500]
|
||||
return error_bytes.decode("utf-8", errors="replace")
|
||||
else:
|
||||
return (
|
||||
e.response.text[:500] if hasattr(e.response, "_content") else "Unable to read"
|
||||
e.response.text if hasattr(e.response, "_content") else "Unable to read"
|
||||
)
|
||||
except Exception as decode_error:
|
||||
return f"Unable to read error: {decode_error}"
|
||||
|
||||
@@ -38,6 +38,7 @@ from src.core.exceptions import (
|
||||
UpstreamClientException,
|
||||
)
|
||||
from src.core.logger import logger
|
||||
from src.services.billing import calculate_request_cost as _calculate_request_cost
|
||||
from src.services.request.result import RequestResult
|
||||
from src.services.usage.recorder import UsageRecorder
|
||||
|
||||
@@ -61,6 +62,9 @@ class CliAdapterBase(ApiAdapter):
|
||||
name: str = "cli.base"
|
||||
mode = ApiMode.PROXY
|
||||
|
||||
# 计费模板配置(子类可覆盖,如 "claude", "openai", "gemini")
|
||||
BILLING_TEMPLATE: str = "claude"
|
||||
|
||||
def __init__(self, allowed_api_formats: Optional[list[str]] = None):
|
||||
self.allowed_api_formats = allowed_api_formats or [self.FORMAT_ID]
|
||||
|
||||
@@ -438,40 +442,6 @@ class CliAdapterBase(ApiAdapter):
|
||||
"""
|
||||
return input_tokens + cache_read_input_tokens
|
||||
|
||||
def get_cache_read_price_for_ttl(
|
||||
self,
|
||||
tier: dict,
|
||||
cache_ttl_minutes: Optional[int] = None,
|
||||
) -> Optional[float]:
|
||||
"""
|
||||
根据缓存 TTL 获取缓存读取价格
|
||||
|
||||
默认实现:检查 cache_ttl_pricing 配置,按 TTL 选择价格
|
||||
子类可覆盖此方法实现不同的 TTL 定价逻辑
|
||||
|
||||
Args:
|
||||
tier: 当前阶梯配置
|
||||
cache_ttl_minutes: 缓存时长(分钟)
|
||||
|
||||
Returns:
|
||||
缓存读取价格(每 1M tokens)
|
||||
"""
|
||||
ttl_pricing = tier.get("cache_ttl_pricing")
|
||||
if ttl_pricing and cache_ttl_minutes is not None:
|
||||
matched_price = None
|
||||
for ttl_config in ttl_pricing:
|
||||
ttl_limit = ttl_config.get("ttl_minutes", 0)
|
||||
if cache_ttl_minutes <= ttl_limit:
|
||||
matched_price = ttl_config.get("cache_read_price_per_1m")
|
||||
break
|
||||
if matched_price is not None:
|
||||
return matched_price
|
||||
# 超过所有配置的 TTL,使用最后一个
|
||||
if ttl_pricing:
|
||||
return ttl_pricing[-1].get("cache_read_price_per_1m")
|
||||
|
||||
return tier.get("cache_read_price_per_1m")
|
||||
|
||||
def compute_cost(
|
||||
self,
|
||||
input_tokens: int,
|
||||
@@ -489,8 +459,9 @@ class CliAdapterBase(ApiAdapter):
|
||||
"""
|
||||
计算请求成本
|
||||
|
||||
默认实现:支持固定价格和阶梯计费
|
||||
子类可覆盖此方法实现完全不同的计费逻辑
|
||||
使用 billing 模块的配置驱动计费。
|
||||
子类可通过设置 BILLING_TEMPLATE 类属性来指定计费模板,
|
||||
或覆盖此方法实现完全自定义的计费逻辑。
|
||||
|
||||
Args:
|
||||
input_tokens: 输入 token 数
|
||||
@@ -508,78 +479,26 @@ class CliAdapterBase(ApiAdapter):
|
||||
Returns:
|
||||
包含各项成本的字典
|
||||
"""
|
||||
tier_index = None
|
||||
effective_input_price = input_price_per_1m
|
||||
effective_output_price = output_price_per_1m
|
||||
effective_cache_creation_price = cache_creation_price_per_1m
|
||||
effective_cache_read_price = cache_read_price_per_1m
|
||||
|
||||
# 检查阶梯计费
|
||||
if tiered_pricing and tiered_pricing.get("tiers"):
|
||||
# 计算总输入上下文(使用子类可覆盖的方法)
|
||||
total_input_context = self.compute_total_input_context(
|
||||
input_tokens, cache_read_input_tokens, cache_creation_input_tokens
|
||||
)
|
||||
tier = self._get_tier_for_tokens(tiered_pricing, total_input_context)
|
||||
|
||||
if tier:
|
||||
tier_index = tiered_pricing["tiers"].index(tier)
|
||||
effective_input_price = tier.get("input_price_per_1m", input_price_per_1m)
|
||||
effective_output_price = tier.get("output_price_per_1m", output_price_per_1m)
|
||||
effective_cache_creation_price = tier.get(
|
||||
"cache_creation_price_per_1m", cache_creation_price_per_1m
|
||||
return _calculate_request_cost(
|
||||
input_tokens=input_tokens,
|
||||
output_tokens=output_tokens,
|
||||
cache_creation_input_tokens=cache_creation_input_tokens,
|
||||
cache_read_input_tokens=cache_read_input_tokens,
|
||||
input_price_per_1m=input_price_per_1m,
|
||||
output_price_per_1m=output_price_per_1m,
|
||||
cache_creation_price_per_1m=cache_creation_price_per_1m,
|
||||
cache_read_price_per_1m=cache_read_price_per_1m,
|
||||
price_per_request=price_per_request,
|
||||
tiered_pricing=tiered_pricing,
|
||||
cache_ttl_minutes=cache_ttl_minutes,
|
||||
total_input_context=total_input_context,
|
||||
billing_template=self.BILLING_TEMPLATE,
|
||||
)
|
||||
effective_cache_read_price = self.get_cache_read_price_for_ttl(
|
||||
tier, cache_ttl_minutes
|
||||
)
|
||||
if effective_cache_read_price is None:
|
||||
effective_cache_read_price = cache_read_price_per_1m
|
||||
|
||||
# 计算各项成本
|
||||
input_cost = (input_tokens / 1_000_000) * effective_input_price
|
||||
output_cost = (output_tokens / 1_000_000) * effective_output_price
|
||||
|
||||
cache_creation_cost = 0.0
|
||||
cache_read_cost = 0.0
|
||||
if cache_creation_input_tokens > 0 and effective_cache_creation_price is not None:
|
||||
cache_creation_cost = (
|
||||
cache_creation_input_tokens / 1_000_000
|
||||
) * effective_cache_creation_price
|
||||
if cache_read_input_tokens > 0 and effective_cache_read_price is not None:
|
||||
cache_read_cost = (
|
||||
cache_read_input_tokens / 1_000_000
|
||||
) * effective_cache_read_price
|
||||
|
||||
cache_cost = cache_creation_cost + cache_read_cost
|
||||
request_cost = price_per_request if price_per_request else 0.0
|
||||
total_cost = input_cost + output_cost + cache_cost + request_cost
|
||||
|
||||
return {
|
||||
"input_cost": input_cost,
|
||||
"output_cost": output_cost,
|
||||
"cache_creation_cost": cache_creation_cost,
|
||||
"cache_read_cost": cache_read_cost,
|
||||
"cache_cost": cache_cost,
|
||||
"request_cost": request_cost,
|
||||
"total_cost": total_cost,
|
||||
"tier_index": tier_index,
|
||||
}
|
||||
|
||||
@staticmethod
|
||||
def _get_tier_for_tokens(tiered_pricing: dict, total_input_tokens: int) -> Optional[dict]:
|
||||
"""根据总输入 token 数确定价格阶梯"""
|
||||
if not tiered_pricing or "tiers" not in tiered_pricing:
|
||||
return None
|
||||
|
||||
tiers = tiered_pricing.get("tiers", [])
|
||||
if not tiers:
|
||||
return None
|
||||
|
||||
for tier in tiers:
|
||||
up_to = tier.get("up_to")
|
||||
if up_to is None or total_input_tokens <= up_to:
|
||||
return tier
|
||||
|
||||
return tiers[-1] if tiers else None
|
||||
|
||||
# =========================================================================
|
||||
# 模型列表查询 - 子类应覆盖此方法
|
||||
@@ -614,6 +533,146 @@ class CliAdapterBase(ApiAdapter):
|
||||
# 默认实现返回空列表,子类应覆盖
|
||||
return [], f"{cls.FORMAT_ID} adapter does not implement fetch_models"
|
||||
|
||||
@classmethod
|
||||
async def check_endpoint(
|
||||
cls,
|
||||
client: httpx.AsyncClient,
|
||||
base_url: str,
|
||||
api_key: str,
|
||||
request_data: Dict[str, Any],
|
||||
extra_headers: Optional[Dict[str, str]] = None,
|
||||
# 用量计算参数
|
||||
db: Optional[Any] = None,
|
||||
user: Optional[Any] = None,
|
||||
provider_name: Optional[str] = None,
|
||||
provider_id: Optional[str] = None,
|
||||
api_key_id: Optional[str] = None,
|
||||
model_name: Optional[str] = None,
|
||||
) -> Dict[str, Any]:
|
||||
"""
|
||||
测试模型连接性(非流式)
|
||||
|
||||
通用的CLI endpoint测试方法,使用配置方法模式:
|
||||
- build_endpoint_url(): 构建请求URL
|
||||
- build_base_headers(): 构建基础认证头
|
||||
- get_protected_header_keys(): 获取受保护的头部key
|
||||
- build_request_body(): 构建请求体
|
||||
- get_cli_user_agent(): 获取CLI User-Agent(子类可覆盖)
|
||||
|
||||
Args:
|
||||
client: httpx 异步客户端
|
||||
base_url: API 基础 URL
|
||||
api_key: API 密钥(已解密)
|
||||
request_data: 请求数据
|
||||
extra_headers: 端点配置的额外请求头
|
||||
db: 数据库会话
|
||||
user: 用户对象
|
||||
provider_name: 提供商名称
|
||||
provider_id: 提供商ID
|
||||
api_key_id: API密钥ID
|
||||
model_name: 模型名称
|
||||
|
||||
Returns:
|
||||
测试响应数据
|
||||
"""
|
||||
from src.api.handlers.base.endpoint_checker import build_safe_headers, run_endpoint_check
|
||||
|
||||
# 构建请求组件
|
||||
url = cls.build_endpoint_url(base_url, request_data, model_name)
|
||||
base_headers = cls.build_base_headers(api_key)
|
||||
protected_keys = cls.get_protected_header_keys()
|
||||
|
||||
# 添加CLI User-Agent
|
||||
cli_user_agent = cls.get_cli_user_agent()
|
||||
if cli_user_agent:
|
||||
base_headers["User-Agent"] = cli_user_agent
|
||||
protected_keys = tuple(list(protected_keys) + ["user-agent"])
|
||||
|
||||
headers = build_safe_headers(base_headers, extra_headers, protected_keys)
|
||||
body = cls.build_request_body(request_data)
|
||||
|
||||
# 获取有效的模型名称
|
||||
effective_model_name = model_name or request_data.get("model")
|
||||
|
||||
return await run_endpoint_check(
|
||||
client=client,
|
||||
url=url,
|
||||
headers=headers,
|
||||
json_body=body,
|
||||
api_format=cls.name,
|
||||
# 用量计算参数(现在强制记录)
|
||||
db=db,
|
||||
user=user,
|
||||
provider_name=provider_name,
|
||||
provider_id=provider_id,
|
||||
api_key_id=api_key_id,
|
||||
model_name=effective_model_name,
|
||||
)
|
||||
|
||||
# =========================================================================
|
||||
# CLI Adapter 配置方法 - 子类应覆盖这些方法而不是整个 check_endpoint
|
||||
# =========================================================================
|
||||
|
||||
@classmethod
|
||||
def build_endpoint_url(cls, base_url: str, request_data: Dict[str, Any], model_name: Optional[str] = None) -> str:
|
||||
"""
|
||||
构建CLI API端点URL - 子类应覆盖
|
||||
|
||||
Args:
|
||||
base_url: API基础URL
|
||||
request_data: 请求数据
|
||||
model_name: 模型名称(某些API需要,如Gemini)
|
||||
|
||||
Returns:
|
||||
完整的端点URL
|
||||
"""
|
||||
raise NotImplementedError(f"{cls.FORMAT_ID} adapter must implement build_endpoint_url")
|
||||
|
||||
@classmethod
|
||||
def build_base_headers(cls, api_key: str) -> Dict[str, str]:
|
||||
"""
|
||||
构建CLI API认证头 - 子类应覆盖
|
||||
|
||||
Args:
|
||||
api_key: API密钥
|
||||
|
||||
Returns:
|
||||
基础认证头部字典
|
||||
"""
|
||||
raise NotImplementedError(f"{cls.FORMAT_ID} adapter must implement build_base_headers")
|
||||
|
||||
@classmethod
|
||||
def get_protected_header_keys(cls) -> tuple:
|
||||
"""
|
||||
返回CLI API的保护头部key - 子类应覆盖
|
||||
|
||||
Returns:
|
||||
保护头部key的元组
|
||||
"""
|
||||
raise NotImplementedError(f"{cls.FORMAT_ID} adapter must implement get_protected_header_keys")
|
||||
|
||||
@classmethod
|
||||
def build_request_body(cls, request_data: Dict[str, Any]) -> Dict[str, Any]:
|
||||
"""
|
||||
构建CLI API请求体 - 子类应覆盖
|
||||
|
||||
Args:
|
||||
request_data: 请求数据
|
||||
|
||||
Returns:
|
||||
请求体字典
|
||||
"""
|
||||
raise NotImplementedError(f"{cls.FORMAT_ID} adapter must implement build_request_body")
|
||||
|
||||
@classmethod
|
||||
def get_cli_user_agent(cls) -> Optional[str]:
|
||||
"""
|
||||
获取CLI User-Agent - 子类可覆盖
|
||||
|
||||
Returns:
|
||||
CLI User-Agent字符串,如果不需要则为None
|
||||
"""
|
||||
return None
|
||||
|
||||
# =========================================================================
|
||||
# CLI Adapter 注册表 - 用于根据 API format 获取 CLI Adapter 实例
|
||||
|
||||
@@ -34,7 +34,12 @@ from src.api.handlers.base.base_handler import (
|
||||
from src.api.handlers.base.parsers import get_parser_for_format
|
||||
from src.api.handlers.base.request_builder import PassthroughRequestBuilder
|
||||
from src.api.handlers.base.stream_context import StreamContext
|
||||
from src.api.handlers.base.utils import build_sse_headers
|
||||
from src.api.handlers.base.utils import (
|
||||
build_sse_headers,
|
||||
check_html_response,
|
||||
check_prefetched_response_error,
|
||||
)
|
||||
from src.core.error_utils import extract_error_message
|
||||
|
||||
# 直接从具体模块导入,避免循环依赖
|
||||
from src.api.handlers.base.response_parser import (
|
||||
@@ -57,6 +62,7 @@ from src.models.database import (
|
||||
ProviderEndpoint,
|
||||
User,
|
||||
)
|
||||
from src.config.constants import StreamDefaults
|
||||
from src.config.settings import config
|
||||
from src.services.provider.transport import build_provider_url
|
||||
from src.utils.sse_parser import SSEEventParser
|
||||
@@ -328,9 +334,9 @@ class CliMessageHandlerBase(BaseMessageHandler):
|
||||
stream_generator,
|
||||
provider_name,
|
||||
attempt_id,
|
||||
_provider_id,
|
||||
_endpoint_id,
|
||||
_key_id,
|
||||
provider_id,
|
||||
endpoint_id,
|
||||
key_id,
|
||||
) = await self.orchestrator.execute_with_fallback(
|
||||
api_format=ctx.api_format,
|
||||
model_name=ctx.model,
|
||||
@@ -340,7 +346,17 @@ class CliMessageHandlerBase(BaseMessageHandler):
|
||||
is_stream=True,
|
||||
capability_requirements=capability_requirements or None,
|
||||
)
|
||||
|
||||
# 更新上下文(确保 provider 信息已设置,用于 streaming 状态更新)
|
||||
ctx.attempt_id = attempt_id
|
||||
if not ctx.provider_name:
|
||||
ctx.provider_name = provider_name
|
||||
if not ctx.provider_id:
|
||||
ctx.provider_id = provider_id
|
||||
if not ctx.endpoint_id:
|
||||
ctx.endpoint_id = endpoint_id
|
||||
if not ctx.key_id:
|
||||
ctx.key_id = key_id
|
||||
|
||||
# 创建后台任务记录统计
|
||||
background_tasks = BackgroundTasks()
|
||||
@@ -488,6 +504,8 @@ class CliMessageHandlerBase(BaseMessageHandler):
|
||||
error_text = await self._extract_error_text(e)
|
||||
logger.error(f"Provider 返回错误状态: {e.response.status_code}\n Response: {error_text}")
|
||||
await http_client.aclose()
|
||||
# 将上游错误信息附加到异常,以便故障转移时能够返回给客户端
|
||||
e.upstream_response = error_text # type: ignore[attr-defined]
|
||||
raise
|
||||
|
||||
except EmbeddedErrorException:
|
||||
@@ -523,8 +541,8 @@ class CliMessageHandlerBase(BaseMessageHandler):
|
||||
try:
|
||||
sse_parser = SSEEventParser()
|
||||
last_data_time = time.time()
|
||||
streaming_status_updated = False
|
||||
buffer = b""
|
||||
output_state = {"first_yield": True, "streaming_updated": False}
|
||||
# 使用增量解码器处理跨 chunk 的 UTF-8 字符
|
||||
decoder = codecs.getincrementaldecoder("utf-8")(errors="replace")
|
||||
|
||||
@@ -532,11 +550,6 @@ class CliMessageHandlerBase(BaseMessageHandler):
|
||||
needs_conversion = self._needs_format_conversion(ctx)
|
||||
|
||||
async for chunk in stream_response.aiter_bytes():
|
||||
# 在第一次输出数据前更新状态为 streaming
|
||||
if not streaming_status_updated:
|
||||
self._update_usage_to_streaming_with_ctx(ctx)
|
||||
streaming_status_updated = True
|
||||
|
||||
buffer += chunk
|
||||
# 处理缓冲区中的完整行
|
||||
while b"\n" in buffer:
|
||||
@@ -561,6 +574,7 @@ class CliMessageHandlerBase(BaseMessageHandler):
|
||||
event.get("event"),
|
||||
event.get("data") or "",
|
||||
)
|
||||
self._mark_first_output(ctx, output_state)
|
||||
yield b"\n"
|
||||
continue
|
||||
|
||||
@@ -578,6 +592,7 @@ class CliMessageHandlerBase(BaseMessageHandler):
|
||||
"message": f"提供商 '{ctx.provider_name}' 流超时且未返回有效数据",
|
||||
},
|
||||
}
|
||||
self._mark_first_output(ctx, output_state)
|
||||
yield f"event: error\ndata: {json.dumps(error_event)}\n\n".encode("utf-8")
|
||||
return # 结束生成器
|
||||
|
||||
@@ -585,8 +600,10 @@ class CliMessageHandlerBase(BaseMessageHandler):
|
||||
if needs_conversion:
|
||||
converted_line = self._convert_sse_line(ctx, line, events)
|
||||
if converted_line:
|
||||
self._mark_first_output(ctx, output_state)
|
||||
yield (converted_line + "\n").encode("utf-8")
|
||||
else:
|
||||
self._mark_first_output(ctx, output_state)
|
||||
yield (line + "\n").encode("utf-8")
|
||||
|
||||
for event in events:
|
||||
@@ -637,7 +654,7 @@ class CliMessageHandlerBase(BaseMessageHandler):
|
||||
},
|
||||
}
|
||||
yield f"event: error\ndata: {json.dumps(error_event)}\n\n".encode("utf-8")
|
||||
except httpx.RemoteProtocolError as e:
|
||||
except httpx.RemoteProtocolError:
|
||||
if ctx.data_count > 0:
|
||||
error_event = {
|
||||
"type": "error",
|
||||
@@ -691,7 +708,9 @@ class CliMessageHandlerBase(BaseMessageHandler):
|
||||
ProviderTimeoutException: 如果首字节超时(TTFB timeout)
|
||||
"""
|
||||
prefetched_chunks: list = []
|
||||
max_prefetch_lines = 5 # 最多预读5行来检测错误
|
||||
max_prefetch_lines = config.stream_prefetch_lines # 最多预读行数来检测错误
|
||||
max_prefetch_bytes = StreamDefaults.MAX_PREFETCH_BYTES # 避免无换行响应导致 buffer 增长
|
||||
total_prefetched_bytes = 0
|
||||
buffer = b""
|
||||
line_count = 0
|
||||
should_stop = False
|
||||
@@ -718,14 +737,16 @@ class CliMessageHandlerBase(BaseMessageHandler):
|
||||
provider_name=str(provider.name),
|
||||
)
|
||||
prefetched_chunks.append(first_chunk)
|
||||
total_prefetched_bytes += len(first_chunk)
|
||||
buffer += first_chunk
|
||||
|
||||
# 继续读取剩余的预读数据
|
||||
async for chunk in aiter:
|
||||
prefetched_chunks.append(chunk)
|
||||
total_prefetched_bytes += len(chunk)
|
||||
buffer += chunk
|
||||
|
||||
# 尝试按行解析缓冲区
|
||||
# 尝试按行解析缓冲区(SSE 格式)
|
||||
while b"\n" in buffer:
|
||||
line_bytes, buffer = buffer.split(b"\n", 1)
|
||||
try:
|
||||
@@ -742,15 +763,15 @@ class CliMessageHandlerBase(BaseMessageHandler):
|
||||
normalized_line = line.rstrip("\r")
|
||||
|
||||
# 检测 HTML 响应(base_url 配置错误的常见症状)
|
||||
lower_line = normalized_line.lower()
|
||||
if lower_line.startswith("<!doctype") or lower_line.startswith("<html"):
|
||||
if check_html_response(normalized_line):
|
||||
logger.error(
|
||||
f" [{self.request_id}] 检测到 HTML 响应,可能是 base_url 配置错误: "
|
||||
f"Provider={provider.name}, Endpoint={endpoint.id[:8]}..., "
|
||||
f"base_url={endpoint.base_url}"
|
||||
)
|
||||
raise ProviderNotAvailableException(
|
||||
f"提供商 '{provider.name}' 返回了 HTML 页面而非 API 响应,请检查 endpoint 的 base_url 配置是否正确"
|
||||
f"提供商 '{provider.name}' 返回了 HTML 页面而非 API 响应,"
|
||||
f"请检查 endpoint 的 base_url 配置是否正确"
|
||||
)
|
||||
|
||||
if not normalized_line or normalized_line.startswith(":"):
|
||||
@@ -799,9 +820,30 @@ class CliMessageHandlerBase(BaseMessageHandler):
|
||||
should_stop = True
|
||||
break
|
||||
|
||||
# 达到预读字节上限,停止继续预读(避免无换行响应导致内存增长)
|
||||
if not should_stop and total_prefetched_bytes >= max_prefetch_bytes:
|
||||
logger.debug(
|
||||
f" [{self.request_id}] 预读达到字节上限,停止继续预读: "
|
||||
f"Provider={provider.name}, bytes={total_prefetched_bytes}, "
|
||||
f"max_bytes={max_prefetch_bytes}"
|
||||
)
|
||||
break
|
||||
|
||||
if should_stop or line_count >= max_prefetch_lines:
|
||||
break
|
||||
|
||||
# 预读结束后,检查是否为非 SSE 格式的 HTML/JSON 响应
|
||||
# 处理某些代理返回的纯 JSON 错误(可能无换行/多行 JSON)以及 HTML 页面(base_url 配置错误)
|
||||
if not should_stop and prefetched_chunks:
|
||||
check_prefetched_response_error(
|
||||
prefetched_chunks=prefetched_chunks,
|
||||
parser=provider_parser,
|
||||
request_id=self.request_id,
|
||||
provider_name=str(provider.name),
|
||||
endpoint_id=endpoint.id,
|
||||
base_url=endpoint.base_url,
|
||||
)
|
||||
|
||||
except (EmbeddedErrorException, ProviderTimeoutException, ProviderNotAvailableException):
|
||||
# 重新抛出可重试的 Provider 异常,触发故障转移
|
||||
raise
|
||||
@@ -833,17 +875,13 @@ class CliMessageHandlerBase(BaseMessageHandler):
|
||||
sse_parser = SSEEventParser()
|
||||
last_data_time = time.time()
|
||||
buffer = b""
|
||||
first_yield = True # 标记是否是第一次 yield
|
||||
output_state = {"first_yield": True, "streaming_updated": False}
|
||||
# 使用增量解码器处理跨 chunk 的 UTF-8 字符
|
||||
decoder = codecs.getincrementaldecoder("utf-8")(errors="replace")
|
||||
|
||||
# 检查是否需要格式转换
|
||||
needs_conversion = self._needs_format_conversion(ctx)
|
||||
|
||||
# 在第一次输出数据前更新状态为 streaming
|
||||
if prefetched_chunks:
|
||||
self._update_usage_to_streaming_with_ctx(ctx)
|
||||
|
||||
# 先处理预读的字节块
|
||||
for chunk in prefetched_chunks:
|
||||
buffer += chunk
|
||||
@@ -870,10 +908,7 @@ class CliMessageHandlerBase(BaseMessageHandler):
|
||||
event.get("event"),
|
||||
event.get("data") or "",
|
||||
)
|
||||
# 记录首字时间 (第一次 yield)
|
||||
if first_yield:
|
||||
ctx.record_first_byte_time(self.start_time)
|
||||
first_yield = False
|
||||
self._mark_first_output(ctx, output_state)
|
||||
yield b"\n"
|
||||
continue
|
||||
|
||||
@@ -883,16 +918,10 @@ class CliMessageHandlerBase(BaseMessageHandler):
|
||||
if needs_conversion:
|
||||
converted_line = self._convert_sse_line(ctx, line, events)
|
||||
if converted_line:
|
||||
# 记录首字时间 (第一次 yield)
|
||||
if first_yield:
|
||||
ctx.record_first_byte_time(self.start_time)
|
||||
first_yield = False
|
||||
self._mark_first_output(ctx, output_state)
|
||||
yield (converted_line + "\n").encode("utf-8")
|
||||
else:
|
||||
# 记录首字时间 (第一次 yield)
|
||||
if first_yield:
|
||||
ctx.record_first_byte_time(self.start_time)
|
||||
first_yield = False
|
||||
self._mark_first_output(ctx, output_state)
|
||||
yield (line + "\n").encode("utf-8")
|
||||
|
||||
for event in events:
|
||||
@@ -931,10 +960,7 @@ class CliMessageHandlerBase(BaseMessageHandler):
|
||||
event.get("event"),
|
||||
event.get("data") or "",
|
||||
)
|
||||
# 记录首字时间 (第一次 yield) - 如果预读数据为空
|
||||
if first_yield:
|
||||
ctx.record_first_byte_time(self.start_time)
|
||||
first_yield = False
|
||||
self._mark_first_output(ctx, output_state)
|
||||
yield b"\n"
|
||||
continue
|
||||
|
||||
@@ -952,6 +978,7 @@ class CliMessageHandlerBase(BaseMessageHandler):
|
||||
"message": f"提供商 '{ctx.provider_name}' 流超时且未返回有效数据",
|
||||
},
|
||||
}
|
||||
self._mark_first_output(ctx, output_state)
|
||||
yield f"event: error\ndata: {json.dumps(error_event)}\n\n".encode("utf-8")
|
||||
return
|
||||
|
||||
@@ -959,16 +986,10 @@ class CliMessageHandlerBase(BaseMessageHandler):
|
||||
if needs_conversion:
|
||||
converted_line = self._convert_sse_line(ctx, line, events)
|
||||
if converted_line:
|
||||
# 记录首字时间 (第一次 yield) - 如果预读数据为空
|
||||
if first_yield:
|
||||
ctx.record_first_byte_time(self.start_time)
|
||||
first_yield = False
|
||||
self._mark_first_output(ctx, output_state)
|
||||
yield (converted_line + "\n").encode("utf-8")
|
||||
else:
|
||||
# 记录首字时间 (第一次 yield) - 如果预读数据为空
|
||||
if first_yield:
|
||||
ctx.record_first_byte_time(self.start_time)
|
||||
first_yield = False
|
||||
self._mark_first_output(ctx, output_state)
|
||||
yield (line + "\n").encode("utf-8")
|
||||
|
||||
for event in events:
|
||||
@@ -1352,7 +1373,7 @@ class CliMessageHandlerBase(BaseMessageHandler):
|
||||
model=ctx.model,
|
||||
response_time_ms=response_time_ms,
|
||||
status_code=status_code,
|
||||
error_message=str(error),
|
||||
error_message=extract_error_message(error),
|
||||
request_headers=original_headers,
|
||||
request_body=actual_request_body,
|
||||
is_stream=True,
|
||||
@@ -1476,8 +1497,12 @@ class CliMessageHandlerBase(BaseMessageHandler):
|
||||
retry_after=int(resp.headers.get("retry-after", 0)) or None,
|
||||
)
|
||||
elif resp.status_code >= 500:
|
||||
error_text = resp.text
|
||||
raise ProviderNotAvailableException(
|
||||
f"提供商服务不可用: {provider.name}, 状态: {resp.status_code}"
|
||||
f"提供商服务不可用: {provider.name}, 状态: {resp.status_code}",
|
||||
provider_name=str(provider.name),
|
||||
upstream_status=resp.status_code,
|
||||
upstream_response=error_text,
|
||||
)
|
||||
elif 300 <= resp.status_code < 400:
|
||||
redirect_url = resp.headers.get("location", "unknown")
|
||||
@@ -1487,7 +1512,10 @@ class CliMessageHandlerBase(BaseMessageHandler):
|
||||
elif resp.status_code != 200:
|
||||
error_text = resp.text
|
||||
raise ProviderNotAvailableException(
|
||||
f"提供商返回错误: {provider.name}, 状态: {resp.status_code}, 错误: {error_text[:200]}"
|
||||
f"提供商返回错误: {provider.name}, 状态: {resp.status_code}",
|
||||
provider_name=str(provider.name),
|
||||
upstream_status=resp.status_code,
|
||||
upstream_response=error_text,
|
||||
)
|
||||
|
||||
# 安全解析 JSON 响应,处理可能的编码错误
|
||||
@@ -1620,7 +1648,7 @@ class CliMessageHandlerBase(BaseMessageHandler):
|
||||
model=model,
|
||||
response_time_ms=response_time_ms,
|
||||
status_code=status_code,
|
||||
error_message=str(e),
|
||||
error_message=extract_error_message(e),
|
||||
request_headers=original_headers,
|
||||
request_body=actual_request_body,
|
||||
is_stream=False,
|
||||
@@ -1640,14 +1668,14 @@ class CliMessageHandlerBase(BaseMessageHandler):
|
||||
|
||||
for encoding in ["utf-8", "gbk", "latin1"]:
|
||||
try:
|
||||
return error_bytes.decode(encoding)[:500]
|
||||
return error_bytes.decode(encoding)
|
||||
except (UnicodeDecodeError, LookupError):
|
||||
continue
|
||||
|
||||
return error_bytes.decode("utf-8", errors="replace")[:500]
|
||||
return error_bytes.decode("utf-8", errors="replace")
|
||||
else:
|
||||
return (
|
||||
e.response.text[:500]
|
||||
e.response.text
|
||||
if hasattr(e.response, "_content")
|
||||
else "Unable to read response"
|
||||
)
|
||||
@@ -1665,6 +1693,25 @@ class CliMessageHandlerBase(BaseMessageHandler):
|
||||
return False
|
||||
return ctx.provider_api_format.upper() != ctx.client_api_format.upper()
|
||||
|
||||
def _mark_first_output(self, ctx: StreamContext, state: Dict[str, bool]) -> None:
|
||||
"""
|
||||
标记首次输出:记录 TTFB 并更新 streaming 状态
|
||||
|
||||
在第一次 yield 数据前调用,确保:
|
||||
1. 首字时间 (TTFB) 已记录到 ctx
|
||||
2. Usage 状态已更新为 streaming(包含 provider/key/TTFB 信息)
|
||||
|
||||
Args:
|
||||
ctx: 流上下文
|
||||
state: 包含 first_yield 和 streaming_updated 的状态字典
|
||||
"""
|
||||
if state["first_yield"]:
|
||||
ctx.record_first_byte_time(self.start_time)
|
||||
state["first_yield"] = False
|
||||
if not state["streaming_updated"]:
|
||||
self._update_usage_to_streaming_with_ctx(ctx)
|
||||
state["streaming_updated"] = True
|
||||
|
||||
def _convert_sse_line(
|
||||
self,
|
||||
ctx: StreamContext,
|
||||
|
||||
1252
src/api/handlers/base/endpoint_checker.py
Normal file
1252
src/api/handlers/base/endpoint_checker.py
Normal file
File diff suppressed because it is too large
Load Diff
@@ -98,6 +98,17 @@ class OpenAIResponseParser(ResponseParser):
|
||||
chunk.is_done = True
|
||||
stats.has_completion = True
|
||||
|
||||
# 提取 usage 信息(某些 OpenAI 兼容 API 如豆包会在最后一个 chunk 中发送 usage)
|
||||
# 这个 chunk 通常 choices 为空数组,但包含完整的 usage 信息
|
||||
usage = parsed.get("usage")
|
||||
if usage and isinstance(usage, dict):
|
||||
chunk.input_tokens = usage.get("prompt_tokens", 0)
|
||||
chunk.output_tokens = usage.get("completion_tokens", 0)
|
||||
|
||||
# 更新 stats
|
||||
stats.input_tokens = chunk.input_tokens
|
||||
stats.output_tokens = chunk.output_tokens
|
||||
|
||||
stats.chunk_count += 1
|
||||
stats.data_count += 1
|
||||
|
||||
|
||||
@@ -25,8 +25,17 @@ from src.api.handlers.base.content_extractors import (
|
||||
from src.api.handlers.base.parsers import get_parser_for_format
|
||||
from src.api.handlers.base.response_parser import ResponseParser
|
||||
from src.api.handlers.base.stream_context import StreamContext
|
||||
from src.api.handlers.base.utils import (
|
||||
check_html_response,
|
||||
check_prefetched_response_error,
|
||||
)
|
||||
from src.config.constants import StreamDefaults
|
||||
from src.config.settings import config
|
||||
from src.core.exceptions import EmbeddedErrorException, ProviderTimeoutException
|
||||
from src.core.exceptions import (
|
||||
EmbeddedErrorException,
|
||||
ProviderNotAvailableException,
|
||||
ProviderTimeoutException,
|
||||
)
|
||||
from src.core.logger import logger
|
||||
from src.models.database import Provider, ProviderEndpoint
|
||||
from src.utils.sse_parser import SSEEventParser
|
||||
@@ -165,6 +174,7 @@ class StreamProcessor:
|
||||
endpoint: ProviderEndpoint,
|
||||
ctx: StreamContext,
|
||||
max_prefetch_lines: int = 5,
|
||||
max_prefetch_bytes: int = StreamDefaults.MAX_PREFETCH_BYTES,
|
||||
) -> list:
|
||||
"""
|
||||
预读流的前几行,检测嵌套错误
|
||||
@@ -180,12 +190,14 @@ class StreamProcessor:
|
||||
endpoint: Endpoint 对象
|
||||
ctx: 流式上下文
|
||||
max_prefetch_lines: 最多预读行数
|
||||
max_prefetch_bytes: 最多预读字节数(避免无换行响应导致 buffer 增长)
|
||||
|
||||
Returns:
|
||||
预读的字节块列表
|
||||
|
||||
Raises:
|
||||
EmbeddedErrorException: 如果检测到嵌套错误
|
||||
ProviderNotAvailableException: 如果检测到 HTML 响应(配置错误)
|
||||
ProviderTimeoutException: 如果首字节超时(TTFB timeout)
|
||||
"""
|
||||
prefetched_chunks: list = []
|
||||
@@ -193,6 +205,7 @@ class StreamProcessor:
|
||||
buffer = b""
|
||||
line_count = 0
|
||||
should_stop = False
|
||||
total_prefetched_bytes = 0
|
||||
# 使用增量解码器处理跨 chunk 的 UTF-8 字符
|
||||
decoder = codecs.getincrementaldecoder("utf-8")(errors="replace")
|
||||
|
||||
@@ -206,11 +219,13 @@ class StreamProcessor:
|
||||
provider_name=str(provider.name),
|
||||
)
|
||||
prefetched_chunks.append(first_chunk)
|
||||
total_prefetched_bytes += len(first_chunk)
|
||||
buffer += first_chunk
|
||||
|
||||
# 继续读取剩余的预读数据
|
||||
async for chunk in aiter:
|
||||
prefetched_chunks.append(chunk)
|
||||
total_prefetched_bytes += len(chunk)
|
||||
buffer += chunk
|
||||
|
||||
# 尝试按行解析缓冲区
|
||||
@@ -228,10 +243,21 @@ class StreamProcessor:
|
||||
|
||||
line_count += 1
|
||||
|
||||
# 检测 HTML 响应(base_url 配置错误的常见症状)
|
||||
if check_html_response(line):
|
||||
logger.error(
|
||||
f" [{self.request_id}] 检测到 HTML 响应,可能是 base_url 配置错误: "
|
||||
f"Provider={provider.name}, Endpoint={endpoint.id[:8]}..., "
|
||||
f"base_url={endpoint.base_url}"
|
||||
)
|
||||
raise ProviderNotAvailableException(
|
||||
f"提供商 '{provider.name}' 返回了 HTML 页面而非 API 响应,"
|
||||
f"请检查 endpoint 的 base_url 配置是否正确"
|
||||
)
|
||||
|
||||
# 跳过空行和注释行
|
||||
if not line or line.startswith(":"):
|
||||
if line_count >= max_prefetch_lines:
|
||||
should_stop = True
|
||||
break
|
||||
continue
|
||||
|
||||
@@ -248,7 +274,6 @@ class StreamProcessor:
|
||||
data = json.loads(data_str)
|
||||
except json.JSONDecodeError:
|
||||
if line_count >= max_prefetch_lines:
|
||||
should_stop = True
|
||||
break
|
||||
continue
|
||||
|
||||
@@ -276,14 +301,34 @@ class StreamProcessor:
|
||||
should_stop = True
|
||||
break
|
||||
|
||||
# 达到预读字节上限,停止继续预读(避免无换行响应导致内存增长)
|
||||
if not should_stop and total_prefetched_bytes >= max_prefetch_bytes:
|
||||
logger.debug(
|
||||
f" [{self.request_id}] 预读达到字节上限,停止继续预读: "
|
||||
f"Provider={provider.name}, bytes={total_prefetched_bytes}, "
|
||||
f"max_bytes={max_prefetch_bytes}"
|
||||
)
|
||||
break
|
||||
|
||||
if should_stop or line_count >= max_prefetch_lines:
|
||||
break
|
||||
|
||||
except (EmbeddedErrorException, ProviderTimeoutException):
|
||||
# 预读结束后,检查是否为非 SSE 格式的 HTML/JSON 响应
|
||||
if not should_stop and prefetched_chunks:
|
||||
check_prefetched_response_error(
|
||||
prefetched_chunks=prefetched_chunks,
|
||||
parser=parser,
|
||||
request_id=self.request_id,
|
||||
provider_name=str(provider.name),
|
||||
endpoint_id=endpoint.id,
|
||||
base_url=endpoint.base_url,
|
||||
)
|
||||
|
||||
except (EmbeddedErrorException, ProviderNotAvailableException, ProviderTimeoutException):
|
||||
# 重新抛出可重试的 Provider 异常,触发故障转移
|
||||
raise
|
||||
except (OSError, IOError) as e:
|
||||
# 网络 I/O <EFBFBD><EFBFBD><EFBFBD>常:记录警告,可能需要重试
|
||||
# 网络 I/O 异常:记录警告,可能需要重试
|
||||
logger.warning(
|
||||
f" [{self.request_id}] 预读流时发生网络异常: {type(e).__name__}: {e}"
|
||||
)
|
||||
@@ -332,15 +377,15 @@ class StreamProcessor:
|
||||
|
||||
# 处理预读数据
|
||||
if prefetched_chunks:
|
||||
if not streaming_started and self.on_streaming_start:
|
||||
self.on_streaming_start()
|
||||
streaming_started = True
|
||||
|
||||
for chunk in prefetched_chunks:
|
||||
# 记录首字时间 (TTFB) - 在 yield 之前记录
|
||||
if start_time is not None:
|
||||
ctx.record_first_byte_time(start_time)
|
||||
start_time = None # 只记录一次
|
||||
# 首次输出前触发 streaming 回调(确保 TTFB 已写入 ctx)
|
||||
if not streaming_started and self.on_streaming_start:
|
||||
self.on_streaming_start()
|
||||
streaming_started = True
|
||||
|
||||
# 把原始数据转发给客户端
|
||||
yield chunk
|
||||
@@ -363,14 +408,14 @@ class StreamProcessor:
|
||||
|
||||
# 处理剩余的流数据
|
||||
async for chunk in byte_iterator:
|
||||
if not streaming_started and self.on_streaming_start:
|
||||
self.on_streaming_start()
|
||||
streaming_started = True
|
||||
|
||||
# 记录首字时间 (TTFB) - 在 yield 之前记录(如果预读数据为空)
|
||||
if start_time is not None:
|
||||
ctx.record_first_byte_time(start_time)
|
||||
start_time = None # 只记录一次
|
||||
# 首次输出前触发 streaming 回调(确保 TTFB 已写入 ctx)
|
||||
if not streaming_started and self.on_streaming_start:
|
||||
self.on_streaming_start()
|
||||
streaming_started = True
|
||||
|
||||
# 原始数据透传
|
||||
yield chunk
|
||||
|
||||
@@ -2,8 +2,10 @@
|
||||
Handler 基础工具函数
|
||||
"""
|
||||
|
||||
import json
|
||||
from typing import Any, Dict, Optional
|
||||
|
||||
from src.core.exceptions import EmbeddedErrorException, ProviderNotAvailableException
|
||||
from src.core.logger import logger
|
||||
|
||||
|
||||
@@ -107,3 +109,95 @@ def build_sse_headers(extra_headers: Optional[Dict[str, str]] = None) -> Dict[st
|
||||
if extra_headers:
|
||||
headers.update(extra_headers)
|
||||
return headers
|
||||
|
||||
|
||||
def check_html_response(line: str) -> bool:
|
||||
"""
|
||||
检查行是否为 HTML 响应(base_url 配置错误的常见症状)
|
||||
|
||||
Args:
|
||||
line: 要检查的行内容
|
||||
|
||||
Returns:
|
||||
True 如果检测到 HTML 响应
|
||||
"""
|
||||
lower_line = line.lstrip().lower()
|
||||
return lower_line.startswith("<!doctype") or lower_line.startswith("<html")
|
||||
|
||||
|
||||
def check_prefetched_response_error(
|
||||
prefetched_chunks: list,
|
||||
parser: Any,
|
||||
request_id: str,
|
||||
provider_name: str,
|
||||
endpoint_id: Optional[str],
|
||||
base_url: Optional[str],
|
||||
) -> None:
|
||||
"""
|
||||
检查预读的响应是否为非 SSE 格式的错误响应(HTML 或纯 JSON 错误)
|
||||
|
||||
某些代理可能返回:
|
||||
1. HTML 页面(base_url 配置错误)
|
||||
2. 纯 JSON 错误(无换行或多行 JSON)
|
||||
|
||||
Args:
|
||||
prefetched_chunks: 预读的字节块列表
|
||||
parser: 响应解析器(需要有 is_error_response 和 parse_response 方法)
|
||||
request_id: 请求 ID(用于日志)
|
||||
provider_name: Provider 名称
|
||||
endpoint_id: Endpoint ID
|
||||
base_url: Endpoint 的 base_url
|
||||
|
||||
Raises:
|
||||
ProviderNotAvailableException: 如果检测到 HTML 响应
|
||||
EmbeddedErrorException: 如果检测到 JSON 错误响应
|
||||
"""
|
||||
if not prefetched_chunks:
|
||||
return
|
||||
|
||||
try:
|
||||
prefetched_bytes = b"".join(prefetched_chunks)
|
||||
stripped = prefetched_bytes.lstrip()
|
||||
|
||||
# 去除 BOM
|
||||
if stripped.startswith(b"\xef\xbb\xbf"):
|
||||
stripped = stripped[3:]
|
||||
|
||||
# HTML 响应(通常是 base_url 配置错误导致返回网页)
|
||||
lower_prefix = stripped[:32].lower()
|
||||
if lower_prefix.startswith(b"<!doctype") or lower_prefix.startswith(b"<html"):
|
||||
endpoint_short = endpoint_id[:8] + "..." if endpoint_id else "N/A"
|
||||
logger.error(
|
||||
f" [{request_id}] 检测到 HTML 响应,可能是 base_url 配置错误: "
|
||||
f"Provider={provider_name}, Endpoint={endpoint_short}, "
|
||||
f"base_url={base_url}"
|
||||
)
|
||||
raise ProviderNotAvailableException(
|
||||
f"提供商 '{provider_name}' 返回了 HTML 页面而非 API 响应,"
|
||||
f"请检查 endpoint 的 base_url 配置是否正确"
|
||||
)
|
||||
|
||||
# 纯 JSON(可能无换行/多行 JSON)
|
||||
if stripped.startswith(b"{") or stripped.startswith(b"["):
|
||||
payload_str = stripped.decode("utf-8", errors="replace").strip()
|
||||
data = json.loads(payload_str)
|
||||
if isinstance(data, dict) and parser.is_error_response(data):
|
||||
parsed = parser.parse_response(data, 200)
|
||||
logger.warning(
|
||||
f" [{request_id}] 检测到 JSON 错误响应: "
|
||||
f"Provider={provider_name}, "
|
||||
f"error_type={parsed.error_type}, "
|
||||
f"message={parsed.error_message}"
|
||||
)
|
||||
raise EmbeddedErrorException(
|
||||
provider_name=provider_name,
|
||||
error_code=(
|
||||
int(parsed.error_type)
|
||||
if parsed.error_type and parsed.error_type.isdigit()
|
||||
else None
|
||||
),
|
||||
error_message=parsed.error_message,
|
||||
error_status=parsed.error_type,
|
||||
)
|
||||
except json.JSONDecodeError:
|
||||
pass
|
||||
|
||||
@@ -63,6 +63,7 @@ class ClaudeChatAdapter(ChatAdapterBase):
|
||||
"""
|
||||
|
||||
FORMAT_ID = "CLAUDE"
|
||||
BILLING_TEMPLATE = "claude" # 使用 Claude 计费模板
|
||||
name = "claude.chat"
|
||||
|
||||
@property
|
||||
@@ -209,6 +210,38 @@ class ClaudeChatAdapter(ChatAdapterBase):
|
||||
logger.warning(f"Failed to fetch Claude models from {models_url}: {e}")
|
||||
return [], error_msg
|
||||
|
||||
@classmethod
|
||||
def build_endpoint_url(cls, base_url: str) -> str:
|
||||
"""构建Claude API端点URL"""
|
||||
base_url = base_url.rstrip("/")
|
||||
if base_url.endswith("/v1"):
|
||||
return f"{base_url}/messages"
|
||||
else:
|
||||
return f"{base_url}/v1/messages"
|
||||
|
||||
@classmethod
|
||||
def build_base_headers(cls, api_key: str) -> Dict[str, str]:
|
||||
"""构建Claude API认证头"""
|
||||
return {
|
||||
"x-api-key": api_key,
|
||||
"Content-Type": "application/json",
|
||||
"anthropic-version": "2023-06-01",
|
||||
}
|
||||
|
||||
@classmethod
|
||||
def get_protected_header_keys(cls) -> tuple:
|
||||
"""返回Claude API的保护头部key"""
|
||||
return ("x-api-key", "content-type", "anthropic-version")
|
||||
|
||||
@classmethod
|
||||
def build_request_body(cls, request_data: Dict[str, Any]) -> Dict[str, Any]:
|
||||
"""构建Claude API请求体"""
|
||||
return {
|
||||
"model": request_data.get("model"),
|
||||
"max_tokens": request_data.get("max_tokens", 100),
|
||||
"messages": request_data.get("messages", []),
|
||||
}
|
||||
|
||||
|
||||
def build_claude_adapter(x_app_header: Optional[str]):
|
||||
"""根据 x-app 头部构造 Chat 或 Claude Code 适配器。"""
|
||||
|
||||
@@ -4,7 +4,7 @@ Claude CLI Adapter - 基于通用 CLI Adapter 基类的简化实现
|
||||
继承 CliAdapterBase,只需配置 FORMAT_ID 和 HANDLER_CLASS。
|
||||
"""
|
||||
|
||||
from typing import Any, Dict, Optional, Tuple, Type
|
||||
from typing import Any, AsyncIterator, Dict, Optional, Tuple, Type, Union
|
||||
|
||||
import httpx
|
||||
from fastapi import Request
|
||||
@@ -24,6 +24,7 @@ class ClaudeCliAdapter(CliAdapterBase):
|
||||
"""
|
||||
|
||||
FORMAT_ID = "CLAUDE_CLI"
|
||||
BILLING_TEMPLATE = "claude" # 使用 Claude 计费模板
|
||||
name = "claude.cli"
|
||||
|
||||
@property
|
||||
@@ -126,5 +127,41 @@ class ClaudeCliAdapter(CliAdapterBase):
|
||||
m["api_format"] = cls.FORMAT_ID
|
||||
return models, error
|
||||
|
||||
@classmethod
|
||||
def build_endpoint_url(cls, base_url: str, request_data: Dict[str, Any], model_name: Optional[str] = None) -> str:
|
||||
"""构建Claude CLI API端点URL"""
|
||||
base_url = base_url.rstrip("/")
|
||||
if base_url.endswith("/v1"):
|
||||
return f"{base_url}/messages"
|
||||
else:
|
||||
return f"{base_url}/v1/messages"
|
||||
|
||||
@classmethod
|
||||
def build_base_headers(cls, api_key: str) -> Dict[str, str]:
|
||||
"""构建Claude CLI API认证头"""
|
||||
return {
|
||||
"Authorization": f"Bearer {api_key}",
|
||||
"Content-Type": "application/json",
|
||||
}
|
||||
|
||||
@classmethod
|
||||
def get_protected_header_keys(cls) -> tuple:
|
||||
"""返回Claude CLI API的保护头部key"""
|
||||
return ("authorization", "content-type")
|
||||
|
||||
@classmethod
|
||||
def build_request_body(cls, request_data: Dict[str, Any]) -> Dict[str, Any]:
|
||||
"""构建Claude CLI API请求体"""
|
||||
return {
|
||||
"model": request_data.get("model"),
|
||||
"max_tokens": request_data.get("max_tokens", 100),
|
||||
"messages": request_data.get("messages", []),
|
||||
}
|
||||
|
||||
@classmethod
|
||||
def get_cli_user_agent(cls) -> Optional[str]:
|
||||
"""获取Claude CLI User-Agent"""
|
||||
return config.internal_user_agent_claude_cli
|
||||
|
||||
|
||||
__all__ = ["ClaudeCliAdapter"]
|
||||
|
||||
@@ -4,7 +4,7 @@ Gemini Chat Adapter
|
||||
处理 Gemini API 格式的请求适配
|
||||
"""
|
||||
|
||||
from typing import Any, Dict, Optional, Tuple, Type
|
||||
from typing import Any, AsyncIterator, Dict, Optional, Tuple, Type, Union
|
||||
|
||||
import httpx
|
||||
from fastapi import HTTPException, Request
|
||||
@@ -12,6 +12,7 @@ from fastapi.responses import JSONResponse
|
||||
|
||||
from src.api.handlers.base.chat_adapter_base import ChatAdapterBase, register_adapter
|
||||
from src.api.handlers.base.chat_handler_base import ChatHandlerBase
|
||||
from src.api.handlers.base.endpoint_checker import build_safe_headers, run_endpoint_check
|
||||
from src.core.logger import logger
|
||||
from src.models.gemini import GeminiRequest
|
||||
|
||||
@@ -26,6 +27,7 @@ class GeminiChatAdapter(ChatAdapterBase):
|
||||
"""
|
||||
|
||||
FORMAT_ID = "GEMINI"
|
||||
BILLING_TEMPLATE = "gemini" # 使用 Gemini 计费模板
|
||||
name = "gemini.chat"
|
||||
|
||||
@property
|
||||
@@ -199,6 +201,94 @@ class GeminiChatAdapter(ChatAdapterBase):
|
||||
logger.warning(f"Failed to fetch Gemini models from {models_url}: {e}")
|
||||
return [], error_msg
|
||||
|
||||
@classmethod
|
||||
def build_endpoint_url(cls, base_url: str) -> str:
|
||||
"""构建Gemini API端点URL"""
|
||||
base_url = base_url.rstrip("/")
|
||||
if base_url.endswith("/v1beta"):
|
||||
return base_url # 子类需要处理model参数
|
||||
else:
|
||||
return f"{base_url}/v1beta"
|
||||
|
||||
@classmethod
|
||||
def build_base_headers(cls, api_key: str) -> Dict[str, str]:
|
||||
"""构建Gemini API认证头"""
|
||||
return {
|
||||
"x-goog-api-key": api_key,
|
||||
"Content-Type": "application/json",
|
||||
}
|
||||
|
||||
@classmethod
|
||||
def get_protected_header_keys(cls) -> tuple:
|
||||
"""返回Gemini API的保护头部key"""
|
||||
return ("x-goog-api-key", "content-type")
|
||||
|
||||
@classmethod
|
||||
def build_request_body(cls, request_data: Dict[str, Any]) -> Dict[str, Any]:
|
||||
"""构建Gemini API请求体"""
|
||||
return {
|
||||
"contents": request_data.get("messages", []),
|
||||
"generationConfig": {
|
||||
"maxOutputTokens": request_data.get("max_tokens", 100),
|
||||
"temperature": request_data.get("temperature", 0.7),
|
||||
},
|
||||
"safetySettings": [
|
||||
{"category": "HARM_CATEGORY_HARASSMENT", "threshold": "BLOCK_NONE"}
|
||||
],
|
||||
}
|
||||
|
||||
@classmethod
|
||||
async def check_endpoint(
|
||||
cls,
|
||||
client: httpx.AsyncClient,
|
||||
base_url: str,
|
||||
api_key: str,
|
||||
request_data: Dict[str, Any],
|
||||
extra_headers: Optional[Dict[str, str]] = None,
|
||||
# 用量计算参数
|
||||
db: Optional[Any] = None,
|
||||
user: Optional[Any] = None,
|
||||
provider_name: Optional[str] = None,
|
||||
provider_id: Optional[str] = None,
|
||||
api_key_id: Optional[str] = None,
|
||||
model_name: Optional[str] = None,
|
||||
) -> Dict[str, Any]:
|
||||
"""测试 Gemini API 模型连接性(非流式)"""
|
||||
# Gemini需要从request_data或model_name参数获取model名称
|
||||
effective_model_name = model_name or request_data.get("model", "")
|
||||
if not effective_model_name:
|
||||
return {
|
||||
"error": "Model name is required for Gemini API",
|
||||
"status_code": 400,
|
||||
}
|
||||
|
||||
# 使用基类配置方法,但重写URL构建逻辑
|
||||
base_url = cls.build_endpoint_url(base_url)
|
||||
url = f"{base_url}/models/{effective_model_name}:generateContent"
|
||||
|
||||
# 构建请求组件
|
||||
base_headers = cls.build_base_headers(api_key)
|
||||
protected_keys = cls.get_protected_header_keys()
|
||||
headers = build_safe_headers(base_headers, extra_headers, protected_keys)
|
||||
body = cls.build_request_body(request_data)
|
||||
|
||||
# 使用基类的通用endpoint checker
|
||||
from src.api.handlers.base.endpoint_checker import run_endpoint_check
|
||||
return await run_endpoint_check(
|
||||
client=client,
|
||||
url=url,
|
||||
headers=headers,
|
||||
json_body=body,
|
||||
api_format=cls.name,
|
||||
# 用量计算参数(现在强制记录)
|
||||
db=db,
|
||||
user=user,
|
||||
provider_name=provider_name,
|
||||
provider_id=provider_id,
|
||||
api_key_id=api_key_id,
|
||||
model_name=effective_model_name,
|
||||
)
|
||||
|
||||
|
||||
def build_gemini_adapter(x_app_header: str = "") -> GeminiChatAdapter:
|
||||
"""
|
||||
|
||||
@@ -4,7 +4,7 @@ Gemini CLI Adapter - 基于通用 CLI Adapter 基类的实现
|
||||
继承 CliAdapterBase,处理 Gemini CLI 格式的请求。
|
||||
"""
|
||||
|
||||
from typing import Any, Dict, Optional, Tuple, Type
|
||||
from typing import Any, AsyncIterator, Dict, Optional, Tuple, Type, Union
|
||||
|
||||
import httpx
|
||||
from fastapi import Request
|
||||
@@ -24,6 +24,7 @@ class GeminiCliAdapter(CliAdapterBase):
|
||||
"""
|
||||
|
||||
FORMAT_ID = "GEMINI_CLI"
|
||||
BILLING_TEMPLATE = "gemini" # 使用 Gemini 计费模板
|
||||
name = "gemini.cli"
|
||||
|
||||
@property
|
||||
@@ -123,6 +124,52 @@ class GeminiCliAdapter(CliAdapterBase):
|
||||
m["api_format"] = cls.FORMAT_ID
|
||||
return models, error
|
||||
|
||||
@classmethod
|
||||
def build_endpoint_url(cls, base_url: str, request_data: Dict[str, Any], model_name: Optional[str] = None) -> str:
|
||||
"""构建Gemini CLI API端点URL"""
|
||||
effective_model_name = model_name or request_data.get("model", "")
|
||||
if not effective_model_name:
|
||||
raise ValueError("Model name is required for Gemini API")
|
||||
|
||||
base_url = base_url.rstrip("/")
|
||||
if base_url.endswith("/v1beta"):
|
||||
prefix = base_url
|
||||
else:
|
||||
prefix = f"{base_url}/v1beta"
|
||||
return f"{prefix}/models/{effective_model_name}:generateContent"
|
||||
|
||||
@classmethod
|
||||
def build_base_headers(cls, api_key: str) -> Dict[str, str]:
|
||||
"""构建Gemini CLI API认证头"""
|
||||
return {
|
||||
"x-goog-api-key": api_key,
|
||||
"Content-Type": "application/json",
|
||||
}
|
||||
|
||||
@classmethod
|
||||
def get_protected_header_keys(cls) -> tuple:
|
||||
"""返回Gemini CLI API的保护头部key"""
|
||||
return ("x-goog-api-key", "content-type")
|
||||
|
||||
@classmethod
|
||||
def build_request_body(cls, request_data: Dict[str, Any]) -> Dict[str, Any]:
|
||||
"""构建Gemini CLI API请求体"""
|
||||
return {
|
||||
"contents": request_data.get("messages", []),
|
||||
"generationConfig": {
|
||||
"maxOutputTokens": request_data.get("max_tokens", 100),
|
||||
"temperature": request_data.get("temperature", 0.7),
|
||||
},
|
||||
"safetySettings": [
|
||||
{"category": "HARM_CATEGORY_HARASSMENT", "threshold": "BLOCK_NONE"}
|
||||
],
|
||||
}
|
||||
|
||||
@classmethod
|
||||
def get_cli_user_agent(cls) -> Optional[str]:
|
||||
"""获取Gemini CLI User-Agent"""
|
||||
return config.internal_user_agent_gemini_cli
|
||||
|
||||
|
||||
def build_gemini_cli_adapter(x_app_header: str = "") -> GeminiCliAdapter:
|
||||
"""
|
||||
|
||||
@@ -4,13 +4,14 @@ OpenAI Chat Adapter - 基于 ChatAdapterBase 的 OpenAI Chat API 适配器
|
||||
处理 /v1/chat/completions 端点的 OpenAI Chat 格式请求。
|
||||
"""
|
||||
|
||||
from typing import Any, Dict, Optional, Tuple, Type
|
||||
from typing import Any, AsyncIterator, Dict, Optional, Tuple, Type, Union
|
||||
|
||||
import httpx
|
||||
from fastapi import Request
|
||||
from fastapi.responses import JSONResponse
|
||||
|
||||
from src.api.handlers.base.chat_adapter_base import ChatAdapterBase, register_adapter
|
||||
from src.api.handlers.base.endpoint_checker import build_safe_headers, run_endpoint_check
|
||||
from src.api.handlers.base.chat_handler_base import ChatHandlerBase
|
||||
from src.core.logger import logger
|
||||
from src.models.openai import OpenAIRequest
|
||||
@@ -25,6 +26,7 @@ class OpenAIChatAdapter(ChatAdapterBase):
|
||||
"""
|
||||
|
||||
FORMAT_ID = "OPENAI"
|
||||
BILLING_TEMPLATE = "openai" # 使用 OpenAI 计费模板
|
||||
name = "openai.chat"
|
||||
|
||||
@property
|
||||
@@ -154,5 +156,32 @@ class OpenAIChatAdapter(ChatAdapterBase):
|
||||
logger.warning(f"Failed to fetch models from {models_url}: {e}")
|
||||
return [], error_msg
|
||||
|
||||
@classmethod
|
||||
def build_endpoint_url(cls, base_url: str) -> str:
|
||||
"""构建OpenAI API端点URL"""
|
||||
base_url = base_url.rstrip("/")
|
||||
if base_url.endswith("/v1"):
|
||||
return f"{base_url}/chat/completions"
|
||||
else:
|
||||
return f"{base_url}/v1/chat/completions"
|
||||
|
||||
@classmethod
|
||||
def build_base_headers(cls, api_key: str) -> Dict[str, str]:
|
||||
"""构建OpenAI API认证头"""
|
||||
return {
|
||||
"Authorization": f"Bearer {api_key}",
|
||||
"Content-Type": "application/json",
|
||||
}
|
||||
|
||||
@classmethod
|
||||
def get_protected_header_keys(cls) -> tuple:
|
||||
"""返回OpenAI API的保护头部key"""
|
||||
return ("authorization", "content-type")
|
||||
|
||||
@classmethod
|
||||
def build_request_body(cls, request_data: Dict[str, Any]) -> Dict[str, Any]:
|
||||
"""构建OpenAI API请求体"""
|
||||
return request_data.copy()
|
||||
|
||||
|
||||
__all__ = ["OpenAIChatAdapter"]
|
||||
|
||||
@@ -4,7 +4,7 @@ OpenAI CLI Adapter - 基于通用 CLI Adapter 基类的简化实现
|
||||
继承 CliAdapterBase,只需配置 FORMAT_ID 和 HANDLER_CLASS。
|
||||
"""
|
||||
|
||||
from typing import Dict, Optional, Tuple, Type
|
||||
from typing import Any, AsyncIterator, Dict, Optional, Tuple, Type, Union
|
||||
|
||||
import httpx
|
||||
from fastapi import Request
|
||||
@@ -24,6 +24,7 @@ class OpenAICliAdapter(CliAdapterBase):
|
||||
"""
|
||||
|
||||
FORMAT_ID = "OPENAI_CLI"
|
||||
BILLING_TEMPLATE = "openai" # 使用 OpenAI 计费模板
|
||||
name = "openai.cli"
|
||||
|
||||
@property
|
||||
@@ -68,5 +69,37 @@ class OpenAICliAdapter(CliAdapterBase):
|
||||
m["api_format"] = cls.FORMAT_ID
|
||||
return models, error
|
||||
|
||||
@classmethod
|
||||
def build_endpoint_url(cls, base_url: str, request_data: Dict[str, Any], model_name: Optional[str] = None) -> str:
|
||||
"""构建OpenAI CLI API端点URL"""
|
||||
base_url = base_url.rstrip("/")
|
||||
if base_url.endswith("/v1"):
|
||||
return f"{base_url}/chat/completions"
|
||||
else:
|
||||
return f"{base_url}/v1/chat/completions"
|
||||
|
||||
@classmethod
|
||||
def build_base_headers(cls, api_key: str) -> Dict[str, str]:
|
||||
"""构建OpenAI CLI API认证头"""
|
||||
return {
|
||||
"Authorization": f"Bearer {api_key}",
|
||||
"Content-Type": "application/json",
|
||||
}
|
||||
|
||||
@classmethod
|
||||
def get_protected_header_keys(cls) -> tuple:
|
||||
"""返回OpenAI CLI API的保护头部key"""
|
||||
return ("authorization", "content-type")
|
||||
|
||||
@classmethod
|
||||
def build_request_body(cls, request_data: Dict[str, Any]) -> Dict[str, Any]:
|
||||
"""构建OpenAI CLI API请求体"""
|
||||
return request_data.copy()
|
||||
|
||||
@classmethod
|
||||
def get_cli_user_agent(cls) -> Optional[str]:
|
||||
"""获取OpenAI CLI User-Agent"""
|
||||
return config.internal_user_agent_openai_cli
|
||||
|
||||
|
||||
__all__ = ["OpenAICliAdapter"]
|
||||
|
||||
@@ -14,6 +14,7 @@ from fastapi.responses import JSONResponse
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from src.api.base.models_service import (
|
||||
AccessRestrictions,
|
||||
ModelInfo,
|
||||
find_model_by_id,
|
||||
get_available_provider_ids,
|
||||
@@ -103,6 +104,35 @@ def _get_formats_for_api(api_format: str) -> list[str]:
|
||||
return _OPENAI_FORMATS
|
||||
|
||||
|
||||
def _build_empty_list_response(api_format: str) -> dict:
|
||||
"""根据 API 格式构建空列表响应"""
|
||||
if api_format == "claude":
|
||||
return {"data": [], "has_more": False, "first_id": None, "last_id": None}
|
||||
elif api_format == "gemini":
|
||||
return {"models": []}
|
||||
else:
|
||||
return {"object": "list", "data": []}
|
||||
|
||||
|
||||
def _filter_formats_by_restrictions(
|
||||
formats: list[str], restrictions: AccessRestrictions, api_format: str
|
||||
) -> Tuple[list[str], Optional[dict]]:
|
||||
"""
|
||||
根据访问限制过滤 API 格式
|
||||
|
||||
Returns:
|
||||
(过滤后的格式列表, 空响应或None)
|
||||
如果过滤后为空,返回对应格式的空响应
|
||||
"""
|
||||
if restrictions.allowed_api_formats is None:
|
||||
return formats, None
|
||||
filtered = [f for f in formats if f in restrictions.allowed_api_formats]
|
||||
if not filtered:
|
||||
logger.info(f"[Models] API Key 不允许访问格式 {api_format}")
|
||||
return [], _build_empty_list_response(api_format)
|
||||
return filtered, None
|
||||
|
||||
|
||||
def _authenticate(db: Session, api_key: Optional[str]) -> Tuple[Optional[User], Optional[ApiKey]]:
|
||||
"""
|
||||
认证 API Key
|
||||
@@ -375,22 +405,24 @@ async def list_models(
|
||||
logger.info(f"[Models] GET /v1/models | format={api_format}")
|
||||
|
||||
# 认证
|
||||
user, _ = _authenticate(db, api_key)
|
||||
user, key_record = _authenticate(db, api_key)
|
||||
if not user:
|
||||
return _build_auth_error_response(api_format)
|
||||
|
||||
# 构建访问限制
|
||||
restrictions = AccessRestrictions.from_api_key_and_user(key_record, user)
|
||||
|
||||
# 检查 API 格式限制
|
||||
formats = _get_formats_for_api(api_format)
|
||||
formats, empty_response = _filter_formats_by_restrictions(formats, restrictions, api_format)
|
||||
if empty_response is not None:
|
||||
return empty_response
|
||||
|
||||
available_provider_ids = get_available_provider_ids(db, formats)
|
||||
if not available_provider_ids:
|
||||
if api_format == "claude":
|
||||
return {"data": [], "has_more": False, "first_id": None, "last_id": None}
|
||||
elif api_format == "gemini":
|
||||
return {"models": []}
|
||||
else:
|
||||
return {"object": "list", "data": []}
|
||||
return _build_empty_list_response(api_format)
|
||||
|
||||
models = await list_available_models(db, available_provider_ids, formats)
|
||||
models = await list_available_models(db, available_provider_ids, formats, restrictions)
|
||||
logger.debug(f"[Models] 返回 {len(models)} 个模型")
|
||||
|
||||
if api_format == "claude":
|
||||
@@ -419,14 +451,21 @@ async def retrieve_model(
|
||||
logger.info(f"[Models] GET /v1/models/{model_id} | format={api_format}")
|
||||
|
||||
# 认证
|
||||
user, _ = _authenticate(db, api_key)
|
||||
user, key_record = _authenticate(db, api_key)
|
||||
if not user:
|
||||
return _build_auth_error_response(api_format)
|
||||
|
||||
# 构建访问限制
|
||||
restrictions = AccessRestrictions.from_api_key_and_user(key_record, user)
|
||||
|
||||
# 检查 API 格式限制
|
||||
formats = _get_formats_for_api(api_format)
|
||||
formats, _ = _filter_formats_by_restrictions(formats, restrictions, api_format)
|
||||
if not formats:
|
||||
return _build_404_response(model_id, api_format)
|
||||
|
||||
available_provider_ids = get_available_provider_ids(db, formats)
|
||||
model_info = find_model_by_id(db, model_id, available_provider_ids, formats)
|
||||
model_info = find_model_by_id(db, model_id, available_provider_ids, formats, restrictions)
|
||||
|
||||
if not model_info:
|
||||
return _build_404_response(model_id, api_format)
|
||||
@@ -455,15 +494,25 @@ async def list_models_gemini(
|
||||
api_key = _extract_api_key_from_request(request, gemini_def)
|
||||
|
||||
# 认证
|
||||
user, _ = _authenticate(db, api_key)
|
||||
user, key_record = _authenticate(db, api_key)
|
||||
if not user:
|
||||
return _build_auth_error_response("gemini")
|
||||
|
||||
available_provider_ids = get_available_provider_ids(db, _GEMINI_FORMATS)
|
||||
# 构建访问限制
|
||||
restrictions = AccessRestrictions.from_api_key_and_user(key_record, user)
|
||||
|
||||
# 检查 API 格式限制
|
||||
formats, empty_response = _filter_formats_by_restrictions(
|
||||
_GEMINI_FORMATS, restrictions, "gemini"
|
||||
)
|
||||
if empty_response is not None:
|
||||
return empty_response
|
||||
|
||||
available_provider_ids = get_available_provider_ids(db, formats)
|
||||
if not available_provider_ids:
|
||||
return {"models": []}
|
||||
|
||||
models = await list_available_models(db, available_provider_ids, _GEMINI_FORMATS)
|
||||
models = await list_available_models(db, available_provider_ids, formats, restrictions)
|
||||
logger.debug(f"[Models] 返回 {len(models)} 个模型")
|
||||
response = _build_gemini_list_response(models, page_size, page_token)
|
||||
logger.debug(f"[Models] Gemini 响应: {response}")
|
||||
@@ -486,12 +535,22 @@ async def get_model_gemini(
|
||||
api_key = _extract_api_key_from_request(request, gemini_def)
|
||||
|
||||
# 认证
|
||||
user, _ = _authenticate(db, api_key)
|
||||
user, key_record = _authenticate(db, api_key)
|
||||
if not user:
|
||||
return _build_auth_error_response("gemini")
|
||||
|
||||
available_provider_ids = get_available_provider_ids(db, _GEMINI_FORMATS)
|
||||
model_info = find_model_by_id(db, model_id, available_provider_ids, _GEMINI_FORMATS)
|
||||
# 构建访问限制
|
||||
restrictions = AccessRestrictions.from_api_key_and_user(key_record, user)
|
||||
|
||||
# 检查 API 格式限制
|
||||
formats, _ = _filter_formats_by_restrictions(_GEMINI_FORMATS, restrictions, "gemini")
|
||||
if not formats:
|
||||
return _build_404_response(model_id, "gemini")
|
||||
|
||||
available_provider_ids = get_available_provider_ids(db, formats)
|
||||
model_info = find_model_by_id(
|
||||
db, model_id, available_provider_ids, formats, restrictions
|
||||
)
|
||||
|
||||
if not model_info:
|
||||
return _build_404_response(model_id, "gemini")
|
||||
|
||||
@@ -104,9 +104,14 @@ async def get_my_usage(
|
||||
request: Request,
|
||||
start_date: Optional[datetime] = None,
|
||||
end_date: Optional[datetime] = None,
|
||||
search: Optional[str] = None, # 通用搜索:密钥名、模型名
|
||||
limit: int = Query(100, ge=1, le=200, description="每页记录数,默认100,最大200"),
|
||||
offset: int = Query(0, ge=0, le=2000, description="偏移量,用于分页,最大2000"),
|
||||
db: Session = Depends(get_db),
|
||||
):
|
||||
adapter = GetUsageAdapter(start_date=start_date, end_date=end_date)
|
||||
adapter = GetUsageAdapter(
|
||||
start_date=start_date, end_date=end_date, search=search, limit=limit, offset=offset
|
||||
)
|
||||
return await pipeline.run(adapter=adapter, http_request=request, db=db, mode=adapter.mode)
|
||||
|
||||
|
||||
@@ -133,6 +138,20 @@ async def get_my_interval_timeline(
|
||||
return await pipeline.run(adapter=adapter, http_request=request, db=db, mode=adapter.mode)
|
||||
|
||||
|
||||
@router.get("/usage/heatmap")
|
||||
async def get_my_activity_heatmap(
|
||||
request: Request,
|
||||
db: Session = Depends(get_db),
|
||||
):
|
||||
"""
|
||||
Get user's activity heatmap data for the past 365 days.
|
||||
|
||||
This endpoint is cached for 5 minutes to reduce database load.
|
||||
"""
|
||||
adapter = GetMyActivityHeatmapAdapter()
|
||||
return await pipeline.run(adapter=adapter, http_request=request, db=db, mode=adapter.mode)
|
||||
|
||||
|
||||
@router.get("/providers")
|
||||
async def list_available_providers(request: Request, db: Session = Depends(get_db)):
|
||||
adapter = ListAvailableProvidersAdapter()
|
||||
@@ -471,8 +490,15 @@ class ToggleMyApiKeyAdapter(AuthenticatedApiAdapter):
|
||||
class GetUsageAdapter(AuthenticatedApiAdapter):
|
||||
start_date: Optional[datetime]
|
||||
end_date: Optional[datetime]
|
||||
search: Optional[str] = None
|
||||
limit: int = 100
|
||||
offset: int = 0
|
||||
|
||||
async def handle(self, context): # type: ignore[override]
|
||||
from sqlalchemy import or_
|
||||
|
||||
from src.utils.database_helpers import escape_like_pattern, safe_truncate_escaped
|
||||
|
||||
db = context.db
|
||||
user = context.user
|
||||
summary_list = UsageService.get_usage_summary(
|
||||
@@ -553,7 +579,7 @@ class GetUsageAdapter(AuthenticatedApiAdapter):
|
||||
stats["total_cost_usd"] += item["total_cost_usd"]
|
||||
# 假设 summary 中的都是成功的请求
|
||||
stats["success_count"] += item["requests"]
|
||||
if item.get("avg_response_time_ms"):
|
||||
if item.get("avg_response_time_ms") is not None:
|
||||
stats["total_response_time_ms"] += item["avg_response_time_ms"] * item["requests"]
|
||||
stats["response_time_count"] += item["requests"]
|
||||
|
||||
@@ -577,12 +603,33 @@ class GetUsageAdapter(AuthenticatedApiAdapter):
|
||||
})
|
||||
summary_by_provider = sorted(summary_by_provider, key=lambda x: x["requests"], reverse=True)
|
||||
|
||||
query = db.query(Usage).filter(Usage.user_id == user.id)
|
||||
query = (
|
||||
db.query(Usage, ApiKey)
|
||||
.outerjoin(ApiKey, Usage.api_key_id == ApiKey.id)
|
||||
.filter(Usage.user_id == user.id)
|
||||
)
|
||||
if self.start_date:
|
||||
query = query.filter(Usage.created_at >= self.start_date)
|
||||
if self.end_date:
|
||||
query = query.filter(Usage.created_at <= self.end_date)
|
||||
usage_records = query.order_by(Usage.created_at.desc()).limit(100).all()
|
||||
|
||||
# 通用搜索:密钥名、模型名
|
||||
# 支持空格分隔的组合搜索,多个关键词之间是 AND 关系
|
||||
if self.search and self.search.strip():
|
||||
keywords = [kw for kw in self.search.strip().split() if kw][:10]
|
||||
for keyword in keywords:
|
||||
escaped = safe_truncate_escaped(escape_like_pattern(keyword), 100)
|
||||
search_pattern = f"%{escaped}%"
|
||||
query = query.filter(
|
||||
or_(
|
||||
ApiKey.name.ilike(search_pattern, escape="\\"),
|
||||
Usage.model.ilike(search_pattern, escape="\\"),
|
||||
)
|
||||
)
|
||||
|
||||
# 计算总数用于分页
|
||||
total_records = query.count()
|
||||
usage_records = query.order_by(Usage.created_at.desc()).offset(self.offset).limit(self.limit).all()
|
||||
|
||||
avg_resp_query = db.query(func.avg(Usage.response_time_ms)).filter(
|
||||
Usage.user_id == user.id,
|
||||
@@ -608,6 +655,13 @@ class GetUsageAdapter(AuthenticatedApiAdapter):
|
||||
"used_usd": user.used_usd,
|
||||
"summary_by_model": summary_by_model,
|
||||
"summary_by_provider": summary_by_provider,
|
||||
# 分页信息
|
||||
"pagination": {
|
||||
"total": total_records,
|
||||
"limit": self.limit,
|
||||
"offset": self.offset,
|
||||
"has_more": self.offset + self.limit < total_records,
|
||||
},
|
||||
"records": [
|
||||
{
|
||||
"id": r.id,
|
||||
@@ -631,23 +685,25 @@ class GetUsageAdapter(AuthenticatedApiAdapter):
|
||||
"output_price_per_1m": r.output_price_per_1m,
|
||||
"cache_creation_price_per_1m": r.cache_creation_price_per_1m,
|
||||
"cache_read_price_per_1m": r.cache_read_price_per_1m,
|
||||
"api_key": (
|
||||
{
|
||||
"id": str(api_key.id),
|
||||
"name": api_key.name,
|
||||
"display": api_key.get_display_key(),
|
||||
}
|
||||
for r in usage_records
|
||||
if api_key
|
||||
else None
|
||||
),
|
||||
}
|
||||
for r, api_key in usage_records
|
||||
],
|
||||
}
|
||||
|
||||
response_data["activity_heatmap"] = UsageService.get_daily_activity(
|
||||
db=db,
|
||||
user_id=user.id,
|
||||
window_days=365,
|
||||
include_actual_cost=user.role == "admin",
|
||||
)
|
||||
|
||||
# 管理员可以看到真实成本
|
||||
if user.role == "admin":
|
||||
response_data["total_actual_cost"] = total_actual_cost
|
||||
# 为每条记录添加真实成本和倍率信息
|
||||
for i, r in enumerate(usage_records):
|
||||
for i, (r, _) in enumerate(usage_records):
|
||||
# 确保字段有值,避免前端显示 -
|
||||
actual_cost = (
|
||||
r.actual_total_cost_usd if r.actual_total_cost_usd is not None else 0.0
|
||||
@@ -709,6 +765,20 @@ class GetMyIntervalTimelineAdapter(AuthenticatedApiAdapter):
|
||||
return result
|
||||
|
||||
|
||||
class GetMyActivityHeatmapAdapter(AuthenticatedApiAdapter):
|
||||
"""Activity heatmap adapter with Redis caching for user."""
|
||||
|
||||
async def handle(self, context): # type: ignore[override]
|
||||
user = context.user
|
||||
result = await UsageService.get_cached_heatmap(
|
||||
db=context.db,
|
||||
user_id=user.id,
|
||||
include_actual_cost=user.role == "admin",
|
||||
)
|
||||
context.add_audit_metadata(action="activity_heatmap")
|
||||
return result
|
||||
|
||||
|
||||
class ListAvailableProvidersAdapter(AuthenticatedApiAdapter):
|
||||
async def handle(self, context): # type: ignore[override]
|
||||
from sqlalchemy.orm import selectinload
|
||||
|
||||
@@ -9,6 +9,7 @@ from urllib.parse import quote, urlparse
|
||||
|
||||
import httpx
|
||||
|
||||
from src.config import config
|
||||
from src.core.logger import logger
|
||||
|
||||
|
||||
@@ -83,10 +84,10 @@ class HTTPClientPool:
|
||||
http2=False, # 暂时禁用HTTP/2以提高兼容性
|
||||
verify=True, # 启用SSL验证
|
||||
timeout=httpx.Timeout(
|
||||
connect=10.0, # 连接超时
|
||||
read=300.0, # 读取超时(5分钟,适合流式响应)
|
||||
write=60.0, # 写入超时(60秒,支持大请求体)
|
||||
pool=5.0, # 连接池超时
|
||||
connect=config.http_connect_timeout,
|
||||
read=config.http_read_timeout,
|
||||
write=config.http_write_timeout,
|
||||
pool=config.http_pool_timeout,
|
||||
),
|
||||
limits=httpx.Limits(
|
||||
max_connections=100, # 最大连接数
|
||||
@@ -111,15 +112,20 @@ class HTTPClientPool:
|
||||
"""
|
||||
if name not in cls._clients:
|
||||
# 合并默认配置和自定义配置
|
||||
config = {
|
||||
default_config = {
|
||||
"http2": False,
|
||||
"verify": True,
|
||||
"timeout": httpx.Timeout(10.0, read=300.0),
|
||||
"timeout": httpx.Timeout(
|
||||
connect=config.http_connect_timeout,
|
||||
read=config.http_read_timeout,
|
||||
write=config.http_write_timeout,
|
||||
pool=config.http_pool_timeout,
|
||||
),
|
||||
"follow_redirects": True,
|
||||
}
|
||||
config.update(kwargs)
|
||||
default_config.update(kwargs)
|
||||
|
||||
cls._clients[name] = httpx.AsyncClient(**config)
|
||||
cls._clients[name] = httpx.AsyncClient(**default_config)
|
||||
logger.debug(f"创建命名HTTP客户端: {name}")
|
||||
|
||||
return cls._clients[name]
|
||||
@@ -151,14 +157,19 @@ class HTTPClientPool:
|
||||
async with HTTPClientPool.get_temp_client() as client:
|
||||
response = await client.get('https://example.com')
|
||||
"""
|
||||
config = {
|
||||
default_config = {
|
||||
"http2": False,
|
||||
"verify": True,
|
||||
"timeout": httpx.Timeout(10.0),
|
||||
"timeout": httpx.Timeout(
|
||||
connect=config.http_connect_timeout,
|
||||
read=config.http_read_timeout,
|
||||
write=config.http_write_timeout,
|
||||
pool=config.http_pool_timeout,
|
||||
),
|
||||
}
|
||||
config.update(kwargs)
|
||||
default_config.update(kwargs)
|
||||
|
||||
client = httpx.AsyncClient(**config)
|
||||
client = httpx.AsyncClient(**default_config)
|
||||
try:
|
||||
yield client
|
||||
finally:
|
||||
@@ -182,25 +193,30 @@ class HTTPClientPool:
|
||||
Returns:
|
||||
配置好的 httpx.AsyncClient 实例
|
||||
"""
|
||||
config: Dict[str, Any] = {
|
||||
client_config: Dict[str, Any] = {
|
||||
"http2": False,
|
||||
"verify": True,
|
||||
"follow_redirects": True,
|
||||
}
|
||||
|
||||
if timeout:
|
||||
config["timeout"] = timeout
|
||||
client_config["timeout"] = timeout
|
||||
else:
|
||||
config["timeout"] = httpx.Timeout(10.0, read=300.0)
|
||||
client_config["timeout"] = httpx.Timeout(
|
||||
connect=config.http_connect_timeout,
|
||||
read=config.http_read_timeout,
|
||||
write=config.http_write_timeout,
|
||||
pool=config.http_pool_timeout,
|
||||
)
|
||||
|
||||
# 添加代理配置
|
||||
proxy_url = build_proxy_url(proxy_config) if proxy_config else None
|
||||
if proxy_url:
|
||||
config["proxy"] = proxy_url
|
||||
client_config["proxy"] = proxy_url
|
||||
logger.debug(f"创建带代理的HTTP客户端: {proxy_config.get('url', 'unknown')}")
|
||||
|
||||
config.update(kwargs)
|
||||
return httpx.AsyncClient(**config)
|
||||
client_config.update(kwargs)
|
||||
return httpx.AsyncClient(**client_config)
|
||||
|
||||
|
||||
# 便捷访问函数
|
||||
|
||||
@@ -213,7 +213,7 @@ class RedisClientManager:
|
||||
f"Redis连接失败: {error_msg}\n"
|
||||
"缓存亲和性功能需要Redis支持,请确保Redis服务正常运行。\n"
|
||||
"检查事项:\n"
|
||||
"1. Redis服务是否已启动(docker-compose up -d redis)\n"
|
||||
"1. Redis服务是否已启动(docker compose up -d redis)\n"
|
||||
"2. 环境变量 REDIS_URL 或 REDIS_PASSWORD 是否配置正确\n"
|
||||
"3. Redis端口(默认6379)是否可访问"
|
||||
) from e
|
||||
|
||||
@@ -21,6 +21,9 @@ class CacheTTL:
|
||||
# L1 本地缓存(用于减少 Redis 访问)
|
||||
L1_LOCAL = 3 # 3秒
|
||||
|
||||
# 活跃度热力图缓存 - 历史数据变化不频繁
|
||||
ACTIVITY_HEATMAP = 300 # 5分钟
|
||||
|
||||
# 并发锁 TTL - 防止死锁
|
||||
CONCURRENCY_LOCK = 600 # 10分钟
|
||||
|
||||
@@ -38,8 +41,25 @@ class CacheSize:
|
||||
# ==============================================================================
|
||||
|
||||
|
||||
class StreamDefaults:
|
||||
"""流式处理默认值"""
|
||||
|
||||
# 预读字节上限(避免无换行响应导致内存增长)
|
||||
# 64KB 基于:
|
||||
# 1. SSE 单条消息通常远小于此值
|
||||
# 2. 足够检测 HTML 和 JSON 错误响应
|
||||
# 3. 不会占用过多内存
|
||||
MAX_PREFETCH_BYTES = 64 * 1024 # 64KB
|
||||
|
||||
|
||||
class ConcurrencyDefaults:
|
||||
"""并发控制默认值"""
|
||||
"""并发控制默认值
|
||||
|
||||
算法说明:边界记忆 + 渐进探测
|
||||
- 触发 429 时记录边界(last_concurrent_peak),新限制 = 边界 - 1
|
||||
- 扩容时不超过边界,除非是探测性扩容(长时间无 429)
|
||||
- 这样可以快速收敛到真实限制附近,避免过度保守
|
||||
"""
|
||||
|
||||
# 自适应并发初始限制(宽松起步,遇到 429 再降低)
|
||||
INITIAL_LIMIT = 50
|
||||
@@ -69,10 +89,6 @@ class ConcurrencyDefaults:
|
||||
# 扩容步长 - 每次扩容增加的并发数
|
||||
INCREASE_STEP = 2
|
||||
|
||||
# 缩容乘数 - 遇到 429 时基于当前并发数的缩容比例
|
||||
# 0.85 表示降到触发 429 时并发数的 85%
|
||||
DECREASE_MULTIPLIER = 0.85
|
||||
|
||||
# 最大并发限制上限
|
||||
MAX_CONCURRENT_LIMIT = 200
|
||||
|
||||
@@ -84,6 +100,7 @@ class ConcurrencyDefaults:
|
||||
|
||||
# === 探测性扩容参数 ===
|
||||
# 探测性扩容间隔(分钟)- 长时间无 429 且有流量时尝试扩容
|
||||
# 探测性扩容可以突破已知边界,尝试更高的并发
|
||||
PROBE_INCREASE_INTERVAL_MINUTES = 30
|
||||
|
||||
# 探测性扩容最小请求数 - 在探测间隔内至少需要这么多请求
|
||||
|
||||
@@ -148,6 +148,7 @@ class Config:
|
||||
|
||||
# HTTP 请求超时配置(秒)
|
||||
self.http_connect_timeout = float(os.getenv("HTTP_CONNECT_TIMEOUT", "10.0"))
|
||||
self.http_read_timeout = float(os.getenv("HTTP_READ_TIMEOUT", "300.0"))
|
||||
self.http_write_timeout = float(os.getenv("HTTP_WRITE_TIMEOUT", "60.0"))
|
||||
self.http_pool_timeout = float(os.getenv("HTTP_POOL_TIMEOUT", "10.0"))
|
||||
|
||||
@@ -172,6 +173,16 @@ class Config:
|
||||
"GEMINI_CLI_USER_AGENT", "gemini-cli/0.1.0"
|
||||
)
|
||||
|
||||
# 邮箱验证配置
|
||||
# VERIFICATION_CODE_EXPIRE_MINUTES: 验证码有效期(分钟)
|
||||
# VERIFICATION_SEND_COOLDOWN: 发送冷却时间(秒)
|
||||
self.verification_code_expire_minutes = int(
|
||||
os.getenv("VERIFICATION_CODE_EXPIRE_MINUTES", "5")
|
||||
)
|
||||
self.verification_send_cooldown = int(
|
||||
os.getenv("VERIFICATION_SEND_COOLDOWN", "60")
|
||||
)
|
||||
|
||||
# 验证连接池配置
|
||||
self._validate_pool_config()
|
||||
|
||||
|
||||
28
src/core/error_utils.py
Normal file
28
src/core/error_utils.py
Normal file
@@ -0,0 +1,28 @@
|
||||
"""
|
||||
错误消息处理工具函数
|
||||
"""
|
||||
|
||||
from typing import Optional
|
||||
|
||||
|
||||
def extract_error_message(error: Exception, status_code: Optional[int] = None) -> str:
|
||||
"""
|
||||
从异常中提取错误消息,优先使用上游响应内容
|
||||
|
||||
Args:
|
||||
error: 异常对象
|
||||
status_code: 可选的 HTTP 状态码,用于构建更详细的错误消息
|
||||
|
||||
Returns:
|
||||
错误消息字符串
|
||||
"""
|
||||
# 优先使用 upstream_response 属性(包含上游 Provider 的原始错误)
|
||||
upstream_response = getattr(error, "upstream_response", None)
|
||||
if upstream_response and isinstance(upstream_response, str) and upstream_response.strip():
|
||||
return str(upstream_response)
|
||||
|
||||
# 回退到异常的字符串表示(str 可能为空,如 httpx 超时异常)
|
||||
error_str = str(error) or repr(error)
|
||||
if status_code is not None:
|
||||
return f"HTTP {status_code}: {error_str}"
|
||||
return error_str
|
||||
@@ -547,11 +547,19 @@ class ErrorResponse:
|
||||
- 所有错误都记录到日志,通过错误 ID 关联
|
||||
"""
|
||||
if isinstance(e, ProxyException):
|
||||
details = e.details.copy() if e.details else {}
|
||||
status_code = e.status_code
|
||||
message = e.message
|
||||
# 如果是 ProviderNotAvailableException 且有上游错误,直接透传上游信息
|
||||
if isinstance(e, ProviderNotAvailableException) and e.upstream_response:
|
||||
if e.upstream_status:
|
||||
status_code = e.upstream_status
|
||||
message = e.upstream_response
|
||||
return ErrorResponse.create(
|
||||
error_type=e.error_type,
|
||||
message=e.message,
|
||||
status_code=e.status_code,
|
||||
details=e.details,
|
||||
message=message,
|
||||
status_code=status_code,
|
||||
details=details if details else None,
|
||||
)
|
||||
elif isinstance(e, HTTPException):
|
||||
return ErrorResponse.create(
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user