Compare commits
34 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
6a59b4f847 | ||
|
|
d1ba2c4ae9 | ||
|
|
0693a5c245 | ||
|
|
742db744d1 | ||
|
|
12a84921c1 | ||
|
|
73e98a185d | ||
|
|
73a7c81f85 | ||
|
|
86dba93974 | ||
|
|
439165bc6c | ||
|
|
0dd9dd5380 | ||
|
|
aea2f39952 | ||
|
|
f7cfc8952f | ||
|
|
7b4652c802 | ||
|
|
51bb71bdb5 | ||
|
|
69261e98de | ||
|
|
f05d67939f | ||
|
|
d94d24f96c | ||
|
|
0f28173b0e | ||
|
|
af310ffb6b | ||
|
|
169488851f | ||
|
|
a7dc05a359 | ||
|
|
d0cc48ad63 | ||
|
|
5fc59a00d0 | ||
|
|
619f81cce4 | ||
|
|
a6c162b223 | ||
|
|
4c2f3ed9b0 | ||
|
|
ba38f14cd8 | ||
|
|
47bf47d90e | ||
|
|
cc36ba4c9e | ||
|
|
baf643e884 | ||
|
|
360bc9e48d | ||
|
|
c0a27d0542 | ||
|
|
84052a2179 | ||
|
|
2e7ecd88b5 |
12
.env.example
@@ -1,3 +1,9 @@
|
||||
# MySQL数据库配置
|
||||
MYSQL_HOST=
|
||||
MYSQL_PORT=
|
||||
MYSQL_USER=
|
||||
MYSQL_PASSWORD=
|
||||
MYSQL_DATABASE=default_db
|
||||
API_KEYS=["AIzaSyxxxxxxxxxxxxxxxxxxx","AIzaSyxxxxxxxxxxxxxxxxxxx"]
|
||||
ALLOWED_TOKENS=["sk-123456"]
|
||||
# AUTH_TOKEN=sk-123456
|
||||
@@ -10,6 +16,11 @@ SHOW_SEARCH_LINK=true
|
||||
SHOW_THINKING_PROCESS=true
|
||||
BASE_URL=https://generativelanguage.googleapis.com/v1beta
|
||||
MAX_FAILURES=10
|
||||
MAX_RETRIES=3
|
||||
CHECK_INTERVAL_HOURS=1
|
||||
TIMEZONE=Asia/Shanghai
|
||||
# 请求超时时间(秒)
|
||||
TIME_OUT=300
|
||||
#########################image_generate 相关配置###########################
|
||||
PAID_KEY=AIzaSyxxxxxxxxxxxxxxxxxxx
|
||||
CREATE_IMAGE_MODEL=imagen-3.0-generate-002
|
||||
@@ -20,6 +31,7 @@ CLOUDFLARE_IMGBED_URL=https://xxxxxxx.pages.dev/upload
|
||||
CLOUDFLARE_IMGBED_AUTH_CODE=xxxxxxxxx
|
||||
##########################################################################
|
||||
#########################stream_optimizer 相关配置########################
|
||||
STREAM_OPTIMIZER_ENABLED=false
|
||||
STREAM_MIN_DELAY=0.016
|
||||
STREAM_MAX_DELAY=0.024
|
||||
STREAM_SHORT_TEXT_THRESHOLD=10
|
||||
|
||||
24
.github/workflows/docker-publish.yml
vendored
@@ -2,8 +2,6 @@ name: Docker Image CI
|
||||
|
||||
on:
|
||||
push:
|
||||
# branches: [ "main" ]
|
||||
tags: [ 'v*.*.*' ]
|
||||
pull_request:
|
||||
branches: [ "main" ]
|
||||
|
||||
@@ -43,20 +41,30 @@ jobs:
|
||||
with:
|
||||
images: ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}
|
||||
tags: |
|
||||
type=raw,value=latest,enable={{is_default_branch}}
|
||||
# https://github.com/docker/metadata-action/tree/v5/?tab=readme-ov-file#semver
|
||||
# Event: push, Ref: refs/head/main, Tags: main
|
||||
# Event: push tag, Ref: refs/tags/v1.2.3, Tags: 1.2.3, 1.2, 1, latest
|
||||
# Event: push tag, Ref: refs/tags/v2.0.8-rc1, Tags: 2.0.8-rc1
|
||||
type=ref,event=branch
|
||||
type=semver,pattern={{version}}
|
||||
type=semver,pattern={{major}}.{{minor}}
|
||||
type=sha,format=long
|
||||
type=semver,pattern={{major}}
|
||||
labels: |
|
||||
org.opencontainers.image.description=OpenAI API Compatible Server
|
||||
org.opencontainers.image.source=${{ github.event.repository.html_url }}
|
||||
|
||||
- name: Build and push Docker image
|
||||
uses: docker/build-push-action@v5
|
||||
- name: Set up QEMU
|
||||
uses: docker/setup-qemu-action@v3
|
||||
|
||||
- name: Build and push
|
||||
uses: docker/build-push-action@v6
|
||||
with:
|
||||
file: Dockerfile
|
||||
context: .
|
||||
platforms: linux/amd64,linux/arm64
|
||||
push: ${{ github.event_name != 'pull_request' }}
|
||||
load: false
|
||||
tags: ${{ steps.meta.outputs.tags }}
|
||||
labels: ${{ steps.meta.outputs.labels }}
|
||||
cache-from: type=gha
|
||||
cache-to: type=gha,mode=max
|
||||
cache-from: type=gha,scope=${{ github.workflow }}
|
||||
cache-to: type=gha,scope=${{ github.workflow }}
|
||||
|
||||
17
LICENSE
Normal file
@@ -0,0 +1,17 @@
|
||||
知识共享署名-非商业性使用 4.0 国际 (CC BY-NC 4.0) 协议
|
||||
|
||||
您可以自由地:
|
||||
- 共享 — 在任何媒介以任何形式复制、发行本作品
|
||||
- 演绎 — 修改、转换或以本作品为基础进行创作
|
||||
|
||||
惟须遵守下列条件:
|
||||
- 署名 — 您必须给出适当的署名,提供指向本协议的链接,并指明是否(对原作)作了修改。您可以以任何合理方式进行,但不得以任何方式暗示许可方认可您或您的使用。
|
||||
- 非商业性使用 — 您不得将本作品用于商业目的,包括但不限于任何形式的商业倒卖、SaaS、API 付费接口、二次销售、打包出售、收费分发或其他直接或间接盈利行为。
|
||||
|
||||
如需商业授权,请联系原作者获得书面许可。违者将承担相应法律责任。
|
||||
|
||||
Creative Commons Attribution-NonCommercial 4.0 International Public License
|
||||
|
||||
By exercising the Licensed Rights (defined below), You accept and agree to be bound by the terms and conditions of this Creative Commons Attribution-NonCommercial 4.0 International Public License ("Public License"). To the extent this Public License may be interpreted as a contract, You are granted the Licensed Rights in consideration of Your acceptance of these terms and conditions, and the Licensor grants You such rights in consideration of benefits the Licensor receives from making the Licensed Material available under these terms and conditions.
|
||||
|
||||
Full license text: https://creativecommons.org/licenses/by-nc/4.0/legalcode
|
||||
588
README.md
@@ -1,199 +1,76 @@
|
||||
# 🚀 FastAPI OpenAI (Gemini) 代理服务
|
||||
# Gemini Balance - Gemini API 代理和负载均衡器
|
||||
|
||||
[](https://opensource.org/licenses/MIT)
|
||||
> ⚠️ 本项目采用 CC BY-NC 4.0(署名-非商业性使用)协议,禁止任何形式的商业倒卖服务,详见 LICENSE 文件。
|
||||
|
||||
## 📝 项目简介
|
||||
[](https://www.python.org/)
|
||||
[](https://fastapi.tiangolo.com/)
|
||||
[](https://www.uvicorn.org/)
|
||||
|
||||
本项目是一个基于 FastAPI 框架开发的高性能、易于部署的Gemini OpenAI兼容 和 Gemini API 代理服务。它不仅兼容 OpenAI 的 API 接口,还支持 Google 的 Gemini 原生接口。该代理服务内置了多 API Key 轮询、负载均衡、自动重试、访问控制(Bearer Token 认证)、流式响应等功能,旨在简化 AI 应用的开发和部署流程。
|
||||
## 项目简介
|
||||
|
||||
**核心功能与优势:**
|
||||
Gemini Balance 是一个基于 Python FastAPI 构建的应用程序,旨在提供 Google Gemini API 的代理和负载均衡功能。它允许您管理多个 Gemini API Key,并通过简单的配置实现 Key 的轮询、认证、模型过滤和状态监控。此外,项目还集成了图像生成和多种图床上传功能,并支持 OpenAI API 格式的代理。
|
||||
|
||||
- **多协议支持**: 无缝切换 OpenAI兼容 和 Gemini 协议。
|
||||
- **智能 API Key 管理**: 自动轮询多个 API Key,实现负载均衡和故障转移。
|
||||
- **安全访问控制**: 使用 Bearer Token 进行身份验证,保护 API 访问。
|
||||
- **流式响应支持**: 提供实时的流式数据传输,提升用户体验。
|
||||
- **内置工具支持**: 支持代码执行和 Google 搜索等工具, 丰富模型功能 (可选)。
|
||||
- **灵活配置**: 通过环境变量或 `.env` 文件轻松配置。
|
||||
- **易于部署**: 提供 Docker 一键部署,也支持手动部署。
|
||||
- **健康检查**: 提供健康检查接口,方便监控服务状态。
|
||||
- **图片生成支持**: 支持使用OpenAI的DALL-E模型生成图片
|
||||
**项目结构:**
|
||||
|
||||
## 🛠️ 技术栈
|
||||
```plaintext
|
||||
app/
|
||||
├── config/ # 配置管理
|
||||
├── core/ # 核心应用逻辑 (FastAPI 实例创建, 中间件等)
|
||||
├── database/ # 数据库模型和连接
|
||||
├── domain/ # 业务领域对象 (可选)
|
||||
├── exception/ # 自定义异常
|
||||
├── handler/ # 请求处理器 (可选, 或在 router 中处理)
|
||||
├── log/ # 日志配置
|
||||
├── main.py # 应用入口
|
||||
├── middleware/ # FastAPI 中间件
|
||||
├── router/ # API 路由 (Gemini, OpenAI, 状态页等)
|
||||
├── scheduler/ # 定时任务 (如 Key 状态检查)
|
||||
├── service/ # 业务逻辑服务 (聊天, Key 管理, 统计等)
|
||||
├── static/ # 静态文件 (CSS, JS)
|
||||
├── templates/ # HTML 模板 (如 Key 状态页)
|
||||
├── utils/ # 工具函数
|
||||
```
|
||||
|
||||
- **FastAPI**: 高性能 Web 框架。
|
||||
- **Python 3.9+**: 编程语言。
|
||||
- **Pydantic**: 数据验证和设置管理。
|
||||
- **httpx**: 异步 HTTP 客户端。
|
||||
- **uvicorn**: ASGI 服务器。
|
||||
- **Docker**: 容器化部署 (可选)。
|
||||
## ✨ 功能亮点
|
||||
|
||||
* **多 Key 负载均衡**: 支持配置多个 Gemini API Key (`API_KEYS`),自动按顺序轮询使用,提高可用性和并发能力。
|
||||
* **可视化配置即时生效**: 通过管理后台修改配置后,无需重启服务即可生效,切记要点击保存才会生效。
|
||||

|
||||
* **双协议API 兼容**: 同时支持 Gemini 和 OpenAI 格式的 CHAT API 请求转发。
|
||||
|
||||
```palintext
|
||||
openai baseurl `http://localhost:8000(/hf)/v1`
|
||||
gemini baseurl `http://localhost:8000(/gemini)/v1beta`
|
||||
```
|
||||
|
||||
* **支持图文对话和修改图片**: `IMAGE_MODELS`配置哪个模型可以图文对话和修图的功能,实际调用的时候,用 `配置模型-image`这个模型名对话使用该功能。
|
||||

|
||||

|
||||
* **支持联网搜索**: 支持联网搜索,`SEARCH_MODELS`配置哪些模型可以联网搜索,实际调用的时候,用 `配置模型-search`这个模型名对话使用该功能
|
||||

|
||||
* **Key 状态监控**: 提供 `/keys_status` 页面(需要认证),实时查看各 Key 的状态和使用情况。
|
||||

|
||||
* **详细的日志记录**: 提供详细的错误日志,方便排查。
|
||||

|
||||

|
||||

|
||||
* **支持自定义gemini代理**: 支持自定义gemini代理,比如自行在deno或者cloudflare上搭建gemini代理
|
||||
* **openai画图接口兼容**: 将`imagen-3.0-generate-002`模型接口改造成openai画图接口,支持客户端调用。
|
||||
* **灵活的添加密钥方式**: 灵活的添加密钥方式,采用正则匹配`gemini_key`,密钥去重
|
||||

|
||||
* **兼容openai格式embeddings接口**:完美适配openai格式的`embeddings`接口,可用于本地文档向量化。
|
||||
* **流式响应优化**: 可选的流式输出优化器 (`STREAM_OPTIMIZER_ENABLED`),改善长文本流式响应的体验。
|
||||
* **失败重试与 Key 管理**: 自动处理 API 请求失败,进行重试 (`MAX_RETRIES`),并在 Key 失效次数过多时自动禁用 (`MAX_FAILURES`),定时检查恢复 (`CHECK_INTERVAL_HOURS`)。
|
||||
* **Docker 支持**: 支持AMD,ARM架构的docker部署,也可自行构建docker镜像。
|
||||
>镜像地址: docker pull ghcr.io/snailyp/gemini-balance:latest
|
||||
* **模型列表自动维护**: 支持openai和gemini模型列表获取,与newapi自动获取模型列表完美兼容,无需手动填写。
|
||||
* **支持移除不使用的模型**: 默认提供的模型太多,很多用不上,可以通过`FILTERED_MODELS`过滤掉。
|
||||
|
||||
## 🚀 快速开始
|
||||
|
||||
### 环境要求
|
||||
### 自行构建 Docker (推荐)
|
||||
|
||||
- Python 3.9 或更高版本
|
||||
- Docker (可选,推荐用于生产环境)
|
||||
|
||||
### 📦 安装与配置
|
||||
|
||||
1. **克隆项目**:
|
||||
|
||||
```bash
|
||||
git clone https://github.com/snailyp/gemini-balance.git
|
||||
cd gemini-balance
|
||||
```
|
||||
|
||||
2. **安装依赖**:
|
||||
|
||||
```bash
|
||||
pip install -r requirements.txt
|
||||
```
|
||||
|
||||
3. **配置**:
|
||||
|
||||
创建 `.env` 文件,并按以下分类配置环境变量:
|
||||
|
||||
```env
|
||||
# 基础配置
|
||||
BASE_URL="https://generativelanguage.googleapis.com/v1beta" # Gemini API 基础 URL,默认无需修改
|
||||
MAX_FAILURES=3 # 允许单个key失败的次数,默认3次
|
||||
|
||||
# 认证与安全配置
|
||||
API_KEYS=["your-gemini-api-key-1", "your-gemini-api-key-2"] # Gemini API 密钥列表,用于负载均衡
|
||||
ALLOWED_TOKENS=["your-access-token-1", "your-access-token-2"] # 允许访问的 Token 列表
|
||||
AUTH_TOKEN="" # 超级管理员token,具有所有权限,默认使用 ALLOWED_TOKENS 的第一个
|
||||
|
||||
# 模型功能配置
|
||||
TEST_MODEL="gemini-1.5-flash" # 用于测试密钥是否可用的模型名
|
||||
SEARCH_MODELS=["gemini-2.0-flash-exp"] # 支持搜索功能的模型列表
|
||||
IMAGE_MODELS=["gemini-2.0-flash-exp"] # 支持绘图功能的模型列表
|
||||
TOOLS_CODE_EXECUTION_ENABLED=false # 是否启用代码执行工具,默认false
|
||||
SHOW_SEARCH_LINK=true # 是否在响应中显示搜索结果链接,默认true
|
||||
SHOW_THINKING_PROCESS=true # 是否显示模型思考过程,默认true
|
||||
FILTERED_MODELS=["gemini-1.0-pro-vision-latest", "gemini-pro-vision", "chat-bison-001", "text-bison-001", "embedding-gecko-001"] # 被禁用的模型列表
|
||||
|
||||
# 图片生成配置
|
||||
PAID_KEY="your-paid-api-key" # 付费版API Key,用于图片生成等高级功能
|
||||
CREATE_IMAGE_MODEL="imagen-3.0-generate-002" # 图片生成模型,默认使用imagen-3.0
|
||||
|
||||
# 图片上传配置
|
||||
UPLOAD_PROVIDER="smms" # 图片上传提供商,目前支持smms、picgo、cloudflare_imgbed
|
||||
SMMS_SECRET_TOKEN="your-smms-token" # SM.MS图床的API Token
|
||||
PICGO_API_KEY="your-picogo-apikey" # PicoGo图床的API Key 可在 `https://www.picgo.net/settings/api` 获取
|
||||
CLOUDFLARE_IMGBED_URL="https://xxxxxxx.pages.dev/upload" # CloudFlare 图床上传地址,可自行搭建:`https://github.com/MarSeventh/CloudFlare-ImgBed`
|
||||
CLOUDFLARE_IMGBED_AUTH_CODE="your-cloudflare-imgber-auth-code" # CloudFlare图床的鉴权key,可在项目后台设置,若无鉴权则可直接置空。
|
||||
|
||||
# stream_optimizer 相关配置
|
||||
STREAM_MIN_DELAY=0.016
|
||||
STREAM_MAX_DELAY=0.024
|
||||
STREAM_SHORT_TEXT_THRESHOLD=10
|
||||
STREAM_LONG_TEXT_THRESHOLD=50
|
||||
STREAM_CHUNK_SIZE=5
|
||||
```
|
||||
|
||||
### 配置说明
|
||||
|
||||
#### 基础配置
|
||||
|
||||
- `BASE_URL`: Gemini API 的基础 URL
|
||||
- 默认值: `https://generativelanguage.googleapis.com/v1beta`
|
||||
- 说明: 通常无需修改,除非 API 地址发生变化
|
||||
- `MAX_FAILURES`: API Key 允许的最大失败次数
|
||||
- 默认值: `3`
|
||||
- 说明: 超过此次数后,Key 将被暂时标记为无效
|
||||
|
||||
#### 认证与安全配置
|
||||
|
||||
- `API_KEYS`: Gemini API 密钥列表
|
||||
- 格式: JSON 数组字符串
|
||||
- 用途: 支持多个 Key 轮询,实现负载均衡
|
||||
- 建议: 至少配置 2 个 Key 以保证服务可用性
|
||||
- `ALLOWED_TOKENS`: 访问令牌列表
|
||||
- 格式: JSON 数组字符串
|
||||
- 用途: 用于客户端认证
|
||||
- 安全提示: 请使用足够复杂的令牌
|
||||
- `AUTH_TOKEN`: 超级管理员令牌
|
||||
- 可选配置,留空则使用 ALLOWED_TOKENS 的第一个
|
||||
- 具有查看 API Key 状态等特权操作权限
|
||||
|
||||
#### 模型功能配置
|
||||
|
||||
- `TEST_MODEL`: 用于测试密钥可用性的模型
|
||||
- 默认值: `"gemini-1.5-flash"`
|
||||
- `SEARCH_MODELS`: 搜索功能支持的模型
|
||||
- 默认值: `["gemini-2.0-flash-exp"]`
|
||||
- 说明: 仅列表中的模型可使用搜索功能
|
||||
- `IMAGE_MODELS`: 绘图功能支持的模型
|
||||
- 默认值: `["gemini-2.0-flash-exp"]`
|
||||
- 说明: 仅列表中的模型可使用绘图功能
|
||||
- `FILTERED_MODELS`: 被禁用的模型列表
|
||||
- 默认值: `["gemini-1.0-pro-vision-latest", "gemini-pro-vision", "chat-bison-001", "text-bison-001", "embedding-gecko-001"]`
|
||||
- 说明: 列表中的模型将被禁用
|
||||
- `TOOLS_CODE_EXECUTION_ENABLED`: 代码执行功能
|
||||
- 默认值: `false`
|
||||
- 安全提示: 生产环境建议禁用
|
||||
- `SHOW_SEARCH_LINK`: 搜索结果链接显示
|
||||
- 默认值: `true`
|
||||
- 用途: 控制搜索结果中是否包含原始链接
|
||||
- `SHOW_THINKING_PROCESS`: 思考过程显示
|
||||
- 默认值: `true`
|
||||
- 用途: 显示模型的推理过程,便于调试
|
||||
|
||||
#### 图片生成配置
|
||||
|
||||
- `PAID_KEY`: 付费版 API Key
|
||||
- 用途: 用于图片生成等高级功能
|
||||
- 说明: 需要单独申请的付费版 Key
|
||||
- `CREATE_IMAGE_MODEL`: 图片生成模型
|
||||
- 默认值: `imagen-3.0-generate-002`
|
||||
- 说明: 当前支持的最新图片生成模型
|
||||
|
||||
#### 图片上传配置
|
||||
|
||||
- `UPLOAD_PROVIDER`: 图片上传服务提供商
|
||||
- 默认值: `smms`
|
||||
- 可选值: `smms`, `picgo`, `cloudflare_imgbed`
|
||||
- 说明: 用于选择图片上传的服务提供商。目前支持 SM.MS 图床, PicGo 图床, 以及 Cloudflare ImgBed。
|
||||
|
||||
- `SMMS_SECRET_TOKEN`: SM.MS API Token
|
||||
- 用途: 用于图片上传到 SM.MS 图床的身份验证。
|
||||
- 获取方式: 需要在 [SM.MS 官网](https://sm.ms/) 注册并获取。
|
||||
|
||||
- `PICGO_API_KEY`: PicGo API Key
|
||||
- 用途: 用于图片上传到 PicGo 图床的身份验证。
|
||||
- 获取方式: 可在 [PicGo 官网](https://www.picgo.net/settings/api) 的设置页面 API 选项中获取。
|
||||
|
||||
- `CLOUDFLARE_IMGBED_URL`: Cloudflare ImgBed 上传地址
|
||||
- 用途: 指定 Cloudflare ImgBed 图床的上传 API 地址。
|
||||
- 获取方式: 如果您自行搭建了 Cloudflare ImgBed 服务,请填写您的服务部署地址。参考 [Cloudflare-ImgBed 项目](https://github.com/MarSeventh/CloudFlare-ImgBed) 自行搭建。
|
||||
- 注意: URL 必须以 `https://` 开头,并指向 `/upload` 路径 ,例如 `https://cloudflare-imgbed-7b0.pages.dev/upload`。
|
||||
|
||||
- `CLOUDFLARE_IMGBED_AUTH_CODE`: Cloudflare ImgBed 鉴权 Key
|
||||
- 用途: 用于 Cloudflare ImgBed 图床的身份验证。
|
||||
- 说明: 如果您的 Cloudflare ImgBed 服务启用了鉴权,请填写鉴权 Key。若未启用鉴权,则留空即可。
|
||||
- 获取方式: 在 Cloudflare ImgBed 项目的后台设置中获取,或在搭建时自行设置。
|
||||
|
||||
#### 流式输出优化配置
|
||||
|
||||
- `STREAM_MIN_DELAY`: 最小延迟时间
|
||||
- 默认值: `0.016`(秒)
|
||||
- 说明: 长文本输出时使用的最小延迟时间,值越小输出速度越快
|
||||
- `STREAM_MAX_DELAY`: 最大延迟时间
|
||||
- 默认值: `0.024`(秒)
|
||||
- 说明: 短文本输出时使用的最大延迟时间,值越大输出速度越慢
|
||||
- `STREAM_SHORT_TEXT_THRESHOLD`: 短文本阈值
|
||||
- 默认值: `10`(字符)
|
||||
- 说明: 小于此长度的文本被视为短文本,将使用最大延迟输出
|
||||
- `STREAM_LONG_TEXT_THRESHOLD`: 长文本阈值
|
||||
- 默认值: `50`(字符)
|
||||
- 说明: 大于此长度的文本被视为长文本,将使用最小延迟并分块输出
|
||||
- `STREAM_CHUNK_SIZE`: 长文本分块大小
|
||||
- 默认值: `5`(字符)
|
||||
- 说明: 长文本分块输出时,每个块的大小
|
||||
|
||||
### ▶️ 运行
|
||||
|
||||
#### 使用 Docker (推荐)
|
||||
#### a) dockerfile构建
|
||||
|
||||
1. **构建镜像**:
|
||||
|
||||
@@ -207,282 +84,131 @@
|
||||
docker run -d -p 8000:8000 --env-file .env gemini-balance
|
||||
```
|
||||
|
||||
- `-d`: 后台运行。
|
||||
- `-p 8000:8000`: 将容器的 8000 端口映射到主机的 8000 端口。
|
||||
- `--env-file .env`: 使用 `.env` 文件设置环境变量。
|
||||
* `-d`: 后台运行。
|
||||
* `-p 8000:8000`: 将容器的 8000 端口映射到主机的 8000 端口。
|
||||
* `--env-file .env`: 使用 `.env` 文件设置环境变量。
|
||||
|
||||
#### 手动运行
|
||||
#### b) 用现有的docker镜像部署
|
||||
|
||||
```bash
|
||||
uvicorn app.main:app --host 0.0.0.0 --port 8000 --reload
|
||||
```
|
||||
1. **拉取镜像**:
|
||||
|
||||
- `--reload`: 开启热重载,方便开发调试 (生产环境不建议开启)。
|
||||
```bash
|
||||
docker pull ghcr.io/snailyp/gemini-balance:latest
|
||||
```
|
||||
|
||||
## 🔌 API 接口
|
||||
2. **运行容器**:
|
||||
|
||||
### 认证
|
||||
```bash
|
||||
docker run -d -p 8000:8000 --env-file .env ghcr.io/snailyp/gemini-balance:latest
|
||||
```
|
||||
|
||||
所有 API 请求都需要在 Header 中添加 `Authorization` 字段,值为 `Bearer <your-token>`,其中 `<your-token>` 需要替换为你在 `.env` 文件中配置的 `ALLOWED_TOKENS` 中的一个或者 `AUTH_TOKEN`。
|
||||
* `-d`: 后台运行。
|
||||
* `-p 8000:8000`: 将容器的 8000 端口映射到主机的 8000 端口 (根据需要调整)。
|
||||
* `--env-file .env`: 使用 `.env` 文件设置环境变量 (确保 `.env` 文件存在于执行命令的目录)。
|
||||
|
||||
### API 路由
|
||||
### 本地运行 (适用于开发和测试)
|
||||
|
||||
本服务提供两种API路由:
|
||||
如果您想在本地直接运行源代码进行开发或测试,请按照以下步骤操作:
|
||||
|
||||
1. **OpenAI 兼容路由** (推荐)
|
||||
- 基础路径: `/v1`
|
||||
- 完全兼容OpenAI API格式
|
||||
- 支持所有Gemini模型
|
||||
1. **确保已完成准备工作**:
|
||||
* 克隆仓库到本地。
|
||||
* 安装 Python 3.9 或更高版本。
|
||||
* 在项目根目录下创建并配置好 `.env` 文件 (参考前面的“配置环境变量”部分)。
|
||||
* 安装项目依赖:
|
||||
|
||||
2. **Gemini 原生路由**
|
||||
- 基础路径: `/gemini/v1beta` 或 `/v1beta`
|
||||
- 遵循Google原生API格式
|
||||
- 适用于需要直接使用Gemini API的场景
|
||||
```bash
|
||||
pip install -r requirements.txt
|
||||
```
|
||||
|
||||
### OpenAI兼容路由
|
||||
2. **启动应用**:
|
||||
在项目根目录下运行以下命令:
|
||||
|
||||
#### 获取模型列表
|
||||
|
||||
- **URL**: `/v1/models`
|
||||
- **Method**: `GET`
|
||||
- **Header**: `Authorization: Bearer <your-token>`
|
||||
- **Response**: 返回支持的所有模型列表,包括最新的`gemini-2.0-flash-exp-search`等模型
|
||||
|
||||
#### 聊天补全 (Chat Completions)
|
||||
|
||||
- **URL**: `/v1/chat/completions`
|
||||
- **Method**: `POST`
|
||||
- **Header**: `Authorization: Bearer <your-token>`
|
||||
- **Body** (JSON):
|
||||
|
||||
```json
|
||||
{
|
||||
"messages": [
|
||||
{
|
||||
"role": "user",
|
||||
"content": "你好"
|
||||
}
|
||||
],
|
||||
"model": "gemini-1.5-flash",
|
||||
"temperature": 0.7,
|
||||
"stream": false,
|
||||
"tools": [],
|
||||
"max_tokens": 8192,
|
||||
"stop": [],
|
||||
"top_p": 0.9,
|
||||
"top_k": 40
|
||||
}
|
||||
```bash
|
||||
uvicorn app.main:app --host 0.0.0.0 --port 8000 --reload
|
||||
```
|
||||
|
||||
- `messages`: 消息列表,格式与 OpenAI API 相同
|
||||
- `model`: 模型名称,支持所有Gemini模型,包括:
|
||||
- `gemini-1.5-flash`: 快速响应模型
|
||||
- `gemini-2.0-flash-exp`: 实验性快速响应模型
|
||||
- `gemini-2.0-flash-exp-search`: 支持搜索功能的实验性模型
|
||||
- `stream`: 是否开启流式响应,`true` 或 `false`
|
||||
- `tools`: 使用的工具列表
|
||||
- 其他参数:与 OpenAI API 兼容的参数,如 `temperature`, `max_tokens` 等
|
||||
* `app.main:app`: 指定 FastAPI 应用实例的位置 (`app` 模块中的 `main.py` 文件里的 `app` 对象)。
|
||||
* `--host 0.0.0.0`: 使应用可以从本地网络中的任何 IP 地址访问。
|
||||
* `--port 8000`: 指定应用监听的端口号 (您可以根据需要修改)。
|
||||
* `--reload`: 启用自动重载功能。当您修改代码时,服务会自动重启,非常适合开发环境 (生产环境请移除此选项)。
|
||||
|
||||
### Gemini原生路由
|
||||
3. **访问应用**:
|
||||
应用启动后,您可以通过浏览器或 API 工具访问 `http://localhost:8000` (或您指定的主机和端口)。
|
||||
|
||||
#### 获取模型列表
|
||||
### 完整配置项列表
|
||||
|
||||
- **URL**: `/gemini/v1beta/models` 或 `/v1beta/models`
|
||||
- **Method**: `GET`
|
||||
- **Header**: `Authorization: Bearer <your-token>`
|
||||
| 配置项 | 说明 | 默认值 |
|
||||
| :--------------------------- | :------------------------------------------------------- | :---------------------------------------------------- |
|
||||
| **数据库配置** | | |
|
||||
| `MYSQL_HOST` | 必填,MySQL 数据库主机地址 | `localhost` |
|
||||
| `MYSQL_PORT` | 必填,MySQL 数据库端口 | `3306` |
|
||||
| `MYSQL_USER` | 必填,MySQL 数据库用户名 | `your_db_user` |
|
||||
| `MYSQL_PASSWORD` | 必填,MySQL 数据库密码 | `your_db_password` |
|
||||
| `MYSQL_DATABASE` | 必填,MySQL 数据库名称 | `defaultdb` |
|
||||
| **API 相关配置** | | |
|
||||
| `API_KEYS` | 必填,Gemini API 密钥列表,用于负载均衡 | `["your-gemini-api-key-1", "your-gemini-api-key-2"]` |
|
||||
| `ALLOWED_TOKENS` | 必填,允许访问的 Token 列表 | `["your-access-token-1", "your-access-token-2"]` |
|
||||
| `AUTH_TOKEN` | 可选,超级管理员token,具有所有权限,不填默认使用 ALLOWED_TOKENS 的第一个 | `""` |
|
||||
| `TEST_MODEL` | 可选,用于测试密钥是否可用的模型名 | `gemini-1.5-flash` |
|
||||
| `IMAGE_MODELS` | 可选,支持绘图功能的模型列表 | `["gemini-2.0-flash-exp"]` |
|
||||
| `SEARCH_MODELS` | 可选,支持搜索功能的模型列表 | `["gemini-2.0-flash-exp"]` |
|
||||
| `FILTERED_MODELS` | 可选,被禁用的模型列表 | `["gemini-1.0-pro-vision-latest", ...]` |
|
||||
| `TOOLS_CODE_EXECUTION_ENABLED` | 可选,是否启用代码执行工具 | `false` |
|
||||
| `SHOW_SEARCH_LINK` | 可选,是否在响应中显示搜索结果链接 | `true` |
|
||||
| `SHOW_THINKING_PROCESS` | 可选,是否显示模型思考过程 | `true` |
|
||||
| `BASE_URL` | 可选,Gemini API 基础 URL,默认无需修改 | `https://generativelanguage.googleapis.com/v1beta` |
|
||||
| `MAX_FAILURES` | 可选,允许单个key失败的次数 | `3` |
|
||||
| `MAX_RETRIES` | 可选,API 请求失败时的最大重试次数 | `3` |
|
||||
| `CHECK_INTERVAL_HOURS` | 可选,检查禁用 Key 是否恢复的时间间隔 (小时) | `1` |
|
||||
| `TIMEZONE` | 可选,应用程序使用的时区 | `Asia/Shanghai` |
|
||||
| `TIME_OUT` | 可选,请求超时时间 (秒) | `300` |
|
||||
| **图像生成相关** | | |
|
||||
| `PAID_KEY` | 可选,付费版API Key,用于图片生成等高级功能 | `your-paid-api-key` |
|
||||
| `CREATE_IMAGE_MODEL` | 可选,图片生成模型 | `imagen-3.0-generate-002` |
|
||||
| `UPLOAD_PROVIDER` | 可选,图片上传提供商: `smms`, `picgo`, `cloudflare_imgbed` | `smms` |
|
||||
| `SMMS_SECRET_TOKEN` | 可选,SM.MS图床的API Token | `your-smms-token` |
|
||||
| `PICGO_API_KEY` | 可选,PicoGo图床的API Key | `your-picogo-apikey` |
|
||||
| `CLOUDFLARE_IMGBED_URL` | 可选,CloudFlare 图床上传地址 | `https://xxxxxxx.pages.dev/upload` |
|
||||
| `CLOUDFLARE_IMGBED_AUTH_CODE`| 可选,CloudFlare图床的鉴权key | `your-cloudflare-imgber-auth-code` |
|
||||
| **流式优化器相关** | | |
|
||||
| `STREAM_OPTIMIZER_ENABLED` | 可选,是否启用流式输出优化 | `false` |
|
||||
| `STREAM_MIN_DELAY` | 可选,流式输出最小延迟 | `0.016` |
|
||||
| `STREAM_MAX_DELAY` | 可选,流式输出最大延迟 | `0.024` |
|
||||
| `STREAM_SHORT_TEXT_THRESHOLD`| 可选,短文本阈值 | `10` |
|
||||
| `STREAM_LONG_TEXT_THRESHOLD` | 可选,长文本阈值 | `50` |
|
||||
| `STREAM_CHUNK_SIZE` | 可选,流式输出块大小 | `5` |
|
||||
|
||||
#### 生成内容
|
||||
## ⚙️ API 端点
|
||||
|
||||
- **URL**: `/gemini/v1beta/models/{model_name}:generateContent`
|
||||
- **Method**: `POST`
|
||||
- **Header**: `Authorization: Bearer <your-token>`
|
||||
以下是服务提供的主要 API 端点:
|
||||
|
||||
#### 流式生成内容
|
||||
### Gemini API 相关 (`(/gemini)/v1beta`)
|
||||
|
||||
- **URL**: `/gemini/v1beta/models/{model_name}:streamGenerateContent`
|
||||
- **Method**: `POST`
|
||||
- **Header**: `Authorization: Bearer <your-token>`
|
||||
* `GET /models`: 列出可用的 Gemini 模型。
|
||||
* `POST /models/{model_name}:generateContent`: 使用指定的 Gemini 模型生成内容。
|
||||
* `POST /models/{model_name}:streamGenerateContent`: 使用指定的 Gemini 模型流式生成内容。
|
||||
|
||||
### 获取词向量 (Embeddings)
|
||||
### OpenAI API 相关 (`(/hf)/v1`)
|
||||
|
||||
- **URL**: `/v1/embeddings`
|
||||
- **Method**: `POST`
|
||||
- **Header**: `Authorization: Bearer <your-token>`
|
||||
- **Body** (JSON):
|
||||
|
||||
```json
|
||||
{
|
||||
"input": "你的文本",
|
||||
"model": "text-embedding-004"
|
||||
}
|
||||
```
|
||||
|
||||
- `input`: 输入文本。
|
||||
- `model`: 模型名称。
|
||||
|
||||
### 健康检查
|
||||
|
||||
- **URL**: `/health`
|
||||
- **Method**: `GET`
|
||||
|
||||
### Web界面功能
|
||||
|
||||
#### 验证页面 (auth.html)
|
||||
|
||||
- **URL**: `/auth`
|
||||
- **说明**: 提供了一个简洁的Web界面用于验证访问令牌
|
||||
- **功能特点**:
|
||||
- 现代化的渐变背景设计
|
||||
- 响应式布局,完美支持移动端
|
||||
- 毛玻璃效果的卡片设计
|
||||
- 优雅的动画效果(淡入、滑动、悬浮)
|
||||
- 安全的令牌验证机制
|
||||
- 清晰的错误提示功能
|
||||
- PWA支持,可安装为本地应用
|
||||
- 底部版权信息和GitHub链接
|
||||
- 支持暗色主题适配
|
||||
|
||||
#### API密钥状态管理 (keys_status.html)
|
||||
|
||||
- **URL**: `/v1/keys/list`
|
||||
- **Method**: `GET`
|
||||
- **Header**: `Authorization: Bearer <your-auth-token>`
|
||||
- **功能特点**:
|
||||
- 只有使用 `AUTH_TOKEN` 才能访问此接口
|
||||
- 分类展示API密钥状态(有效/无效)
|
||||
- 可折叠的密钥列表分组
|
||||
- 每个密钥显示:
|
||||
- 状态标识(有效/无效)
|
||||
- 密钥内容
|
||||
- 失败次数统计
|
||||
- 高级功能:
|
||||
- 一键复制单个密钥
|
||||
- 批量复制分组密钥(JSON格式)
|
||||
- 实时刷新功能
|
||||
- 回到顶部/底部快捷按钮
|
||||
- 界面特性:
|
||||
- 响应式设计,适配各种屏幕
|
||||
- 优雅的动画效果
|
||||
- 操作反馈(复制成功提示)
|
||||
- PWA支持
|
||||
- 暗色主题适配
|
||||
|
||||
### 图片生成 (Image Generation)
|
||||
|
||||
- **URL**: `/v1/images/generations`
|
||||
- **Method**: `POST`
|
||||
- **Header**: `Authorization: Bearer <your-auth-token>`
|
||||
- **说明**: Body示例和参数说明
|
||||
|
||||
```json
|
||||
{
|
||||
"model": "dall-e-3",
|
||||
"prompt": "{n:2} {ratio:16:9} 汉服美女",
|
||||
"n": 1,
|
||||
"size": "1024x1024"
|
||||
}
|
||||
```
|
||||
|
||||
**Prompt参数说明:**
|
||||
|
||||
prompt支持通过特殊标记来控制生成参数:
|
||||
|
||||
1. 图片数量控制:
|
||||
- 格式: `{n:数量}`
|
||||
- 示例: `{n:2} 一只可爱的猫` - 生成2张图片
|
||||
- 取值范围: 1-4
|
||||
- 说明: 如果在prompt中指定了n,将覆盖请求body中的n参数
|
||||
|
||||
2. 图片比例控制:
|
||||
- 格式: `{ratio:宽:高}`
|
||||
- 示例: `{ratio:16:9} 一片森林` - 生成16:9比例的图片
|
||||
- 支持的比例: "1:1"、"3:4"、"4:3"、"9:16"、"16:9"
|
||||
- 说明: 如果指定了size参数,将优先使用size对应的比例
|
||||
|
||||
3. 参数组合:
|
||||
- 示例: `{n:2} {ratio:16:9} 一片美丽的森林` - 生成2张16:9比例的图片
|
||||
- 说明: 这些参数标记会自动从prompt中移除,不会影响实际的图片生成提示词
|
||||
|
||||
> 注意:n的取值范围[1,4], ratio取值范围"1:1"、"3:4"、"4:3"、"9:16" 和 "16:9"
|
||||
|
||||
## 📚 代码结构
|
||||
|
||||
```plaintext
|
||||
.
|
||||
├── app/
|
||||
│ ├── api/ # API 路由
|
||||
│ │ ├── gemini_routes.py # Gemini 模型路由
|
||||
│ │ └── openai_routes.py # OpenAI 兼容路由
|
||||
│ ├── core/ # 核心组件
|
||||
│ │ ├── config.py # 配置管理
|
||||
│ │ ├── logger.py # 日志配置
|
||||
│ │ └── security.py # 安全认证
|
||||
│ ├── middleware/ # 中间件
|
||||
│ │ └── request_logging_middleware.py # 请求日志中间件
|
||||
│ ├── schemas/ # 数据模型
|
||||
│ │ ├── gemini_models.py # Gemini 原始请求/响应模型
|
||||
│ │ └── openai_models.py # OpenAI 兼容请求/响应模型
|
||||
│ ├── services/ # 服务层
|
||||
│ │ ├── chat/ # 聊天相关服务
|
||||
│ │ │ ├── api_client.py # API 客户端
|
||||
│ │ │ ├── message_converter.py # 消息转换器
|
||||
│ │ │ ├── response_handler.py # 响应处理器
|
||||
│ │ │ └── retry_handler.py #重试处理器
|
||||
│ │ ├── gemini_chat_service.py # Gemini 原始聊天服务
|
||||
│ │ ├── openai_chat_service.py # OpenAI 兼容聊天服务
|
||||
│ │ ├── embedding_service.py # 向量服务
|
||||
│ │ ├── key_manager.py # API Key 管理
|
||||
│ │ └── model_service.py # 模型服务
|
||||
│ └── main.py # 主程序入口
|
||||
├── Dockerfile # Dockerfile
|
||||
├── requirements.txt # 项目依赖
|
||||
└── README.md # 项目说明
|
||||
```
|
||||
|
||||
## 🔒 安全性
|
||||
|
||||
- **API Key 轮询**: 自动轮换 API Key,提高可用性和负载均衡。
|
||||
- **Bearer Token 认证**: 保护 API 端点,防止未经授权的访问。
|
||||
- **请求日志记录**: 记录详细的请求信息,便于调试和审计 (可选,通过取消 `app.add_middleware(RequestLoggingMiddleware)` 的注释来启用)。
|
||||
- **自动重试**: 在 API 请求失败时自动重试,提高服务的稳定性。
|
||||
* `GET /v1/models`: 列出可用的 OpenAI 模型。
|
||||
* `POST /v1/chat/completions`: 通过 OpenAI API 进行聊天补全。
|
||||
* `POST /v1/images/generations`: 通过 OpenAI API 生成图像。
|
||||
* `POST /v1/embeddings`: 通过 OpenAI API 创建文本嵌入。
|
||||
|
||||
## 🤝 贡献
|
||||
|
||||
欢迎任何形式的贡献!如果你发现 bug、有新功能建议或者想改进代码,请随时提交 Issue 或 Pull Request。
|
||||
欢迎提交 Pull Request 或 Issue。
|
||||
|
||||
1. Fork 本项目。
|
||||
2. 创建你的特性分支 (`git checkout -b feature/AmazingFeature`)。
|
||||
3. 提交你的改动 (`git commit -m 'Add some AmazingFeature'`)。
|
||||
4. 推送到你的分支 (`git push origin feature/AmazingFeature`)。
|
||||
5. 创建一个新的 Pull Request。
|
||||
## 🙏 感谢贡献者
|
||||
|
||||
## ❓ 常见问题解答 (FAQ)
|
||||
感谢所有为本项目做出贡献的开发者!
|
||||
|
||||
**Q: 如何获取 Gemini API Key?**
|
||||
[](https://github.com/snailyp/gemini-balance/graphs/contributors)
|
||||
|
||||
A: 请参考 Gemini API 的官方文档,申请 API Key。
|
||||
## 💖 友情项目
|
||||
|
||||
**Q: 如何配置多个 API Key?**
|
||||
* **[OneLine](https://github.com/chengtx809/OneLine)** by [chengtx809](https://github.com/chengtx809) - OneLine一线:AI驱动的热点事件时间轴生成工具
|
||||
|
||||
A: 在 `.env` 文件的 `API_KEYS` 变量中,用列表的形式添加多个 Key,例如:`API_KEYS=["key1", "key2", "key3"]`。
|
||||
## 许可证
|
||||
|
||||
**Q: 为什么我的 API Key 总是失败?**
|
||||
|
||||
A: 请检查以下几点:
|
||||
|
||||
- API Key 是否正确。
|
||||
- API Key 是否已过期或被禁用。
|
||||
- 是否超出了 API Key 的速率限制或配额。
|
||||
- 网络连接是否正常。
|
||||
|
||||
**Q: 如何启用流式响应?**
|
||||
|
||||
A: 在请求的 Body 中,将 `stream` 参数设置为 `true` 即可。
|
||||
|
||||
**Q: 如何启用代码执行工具?**
|
||||
|
||||
A: 在 `.env` 文件的 `TOOLS_CODE_EXECUTION_ENABLED` 变量中, 设置为 `true` 即可。
|
||||
|
||||
## 📄 许可证
|
||||
|
||||
本项目采用 MIT 许可证。有关详细信息,请参阅 [LICENSE](LICENSE) 文件 (你需要创建一个 LICENSE 文件)。
|
||||
本项目采用 CC BY-NC 4.0(署名-非商业性使用)协议,禁止任何形式的商业倒卖服务,详见 LICENSE 文件。
|
||||
|
||||
@@ -1,14 +1,33 @@
|
||||
"""
|
||||
应用程序配置模块
|
||||
"""
|
||||
from typing import List
|
||||
from pydantic_settings import BaseSettings
|
||||
import datetime
|
||||
import json
|
||||
from typing import List, Any, Dict, Type
|
||||
|
||||
from app.core.constants import API_VERSION, DEFAULT_CREATE_IMAGE_MODEL, DEFAULT_FILTER_MODELS, DEFAULT_MODEL, DEFAULT_STREAM_CHUNK_SIZE, DEFAULT_STREAM_LONG_TEXT_THRESHOLD, DEFAULT_STREAM_MAX_DELAY, DEFAULT_STREAM_MIN_DELAY, DEFAULT_STREAM_SHORT_TEXT_THRESHOLD
|
||||
from pydantic import ValidationError
|
||||
from pydantic_settings import BaseSettings
|
||||
from sqlalchemy import insert, update, select
|
||||
|
||||
from app.core.constants import API_VERSION, DEFAULT_CREATE_IMAGE_MODEL, DEFAULT_FILTER_MODELS, DEFAULT_MODEL, DEFAULT_STREAM_CHUNK_SIZE, DEFAULT_STREAM_LONG_TEXT_THRESHOLD, DEFAULT_STREAM_MAX_DELAY, DEFAULT_STREAM_MIN_DELAY, DEFAULT_STREAM_SHORT_TEXT_THRESHOLD, DEFAULT_TIMEOUT, MAX_RETRIES
|
||||
from app.log.logger import get_config_logger
|
||||
# 延迟导入以避免循环依赖,仅在 sync_initial_settings 中使用
|
||||
# from app.database.connection import database
|
||||
# from app.database.models import Settings as SettingsModel
|
||||
# from app.database.services import get_all_settings # get_all_settings 可能不适合启动时调用,直接查询
|
||||
|
||||
logger = get_config_logger()
|
||||
|
||||
|
||||
class Settings(BaseSettings):
|
||||
"""应用程序配置"""
|
||||
# 数据库配置
|
||||
MYSQL_HOST: str
|
||||
MYSQL_PORT: int
|
||||
MYSQL_USER: str
|
||||
MYSQL_PASSWORD: str
|
||||
MYSQL_DATABASE: str
|
||||
|
||||
# API相关配置
|
||||
API_KEYS: List[str]
|
||||
ALLOWED_TOKENS: List[str]
|
||||
@@ -16,6 +35,8 @@ class Settings(BaseSettings):
|
||||
AUTH_TOKEN: str = ""
|
||||
MAX_FAILURES: int = 3
|
||||
TEST_MODEL: str = DEFAULT_MODEL
|
||||
TIME_OUT: int = DEFAULT_TIMEOUT
|
||||
MAX_RETRIES: int = MAX_RETRIES
|
||||
|
||||
# 模型相关配置
|
||||
SEARCH_MODELS: List[str] = ["gemini-2.0-flash-exp"]
|
||||
@@ -35,21 +56,218 @@ class Settings(BaseSettings):
|
||||
CLOUDFLARE_IMGBED_AUTH_CODE: str = ""
|
||||
|
||||
# 流式输出优化器配置
|
||||
STREAM_OPTIMIZER_ENABLED: bool = False
|
||||
STREAM_MIN_DELAY: float = DEFAULT_STREAM_MIN_DELAY
|
||||
STREAM_MAX_DELAY: float = DEFAULT_STREAM_MAX_DELAY
|
||||
STREAM_SHORT_TEXT_THRESHOLD: int = DEFAULT_STREAM_SHORT_TEXT_THRESHOLD
|
||||
STREAM_LONG_TEXT_THRESHOLD: int = DEFAULT_STREAM_LONG_TEXT_THRESHOLD
|
||||
STREAM_CHUNK_SIZE: int = DEFAULT_STREAM_CHUNK_SIZE
|
||||
|
||||
|
||||
# 调度器配置
|
||||
CHECK_INTERVAL_HOURS: int = 1 # 默认检查间隔为1小时
|
||||
TIMEZONE: str = "Asia/Shanghai" # 默认时区
|
||||
|
||||
def __init__(self, **kwargs):
|
||||
super().__init__(**kwargs)
|
||||
# 设置默认AUTH_TOKEN(如果未提供)
|
||||
if not self.AUTH_TOKEN and self.ALLOWED_TOKENS:
|
||||
self.AUTH_TOKEN = self.ALLOWED_TOKENS[0]
|
||||
|
||||
class Config:
|
||||
env_file = ".env"
|
||||
|
||||
|
||||
# 创建全局配置实例
|
||||
settings = Settings()
|
||||
|
||||
def _parse_db_value(key: str, db_value: str, target_type: Type) -> Any:
|
||||
"""尝试将数据库字符串值解析为目标 Python 类型"""
|
||||
try:
|
||||
if target_type == List[str]:
|
||||
# 尝试解析 JSON 列表,如果失败则按逗号分割
|
||||
try:
|
||||
parsed = json.loads(db_value)
|
||||
if isinstance(parsed, list):
|
||||
return [str(item) for item in parsed]
|
||||
except json.JSONDecodeError:
|
||||
# 回退到逗号分割,去除空格
|
||||
return [item.strip() for item in db_value.split(',') if item.strip()]
|
||||
# 如果解析后不是列表或解析失败,返回空列表或进行其他处理
|
||||
logger.warning(f"Could not parse '{db_value}' as List[str] for key '{key}', falling back to comma split or empty list.")
|
||||
return [item.strip() for item in db_value.split(',') if item.strip()] # Fallback
|
||||
elif target_type == bool:
|
||||
return db_value.lower() in ('true', '1', 'yes', 'on')
|
||||
elif target_type == int:
|
||||
return int(db_value)
|
||||
elif target_type == float:
|
||||
return float(db_value)
|
||||
else: # 默认为 str 或其他 pydantic 能处理的类型
|
||||
return db_value
|
||||
except (ValueError, TypeError, json.JSONDecodeError) as e:
|
||||
logger.warning(f"Failed to parse db_value '{db_value}' for key '{key}' as type {target_type}: {e}. Using original string value.")
|
||||
return db_value # 解析失败则返回原始字符串
|
||||
|
||||
async def sync_initial_settings():
|
||||
"""
|
||||
应用启动时同步配置:
|
||||
1. 从数据库加载设置。
|
||||
2. 将数据库设置合并到内存 settings (数据库优先)。
|
||||
3. 将最终的内存 settings 同步回数据库。
|
||||
"""
|
||||
# 延迟导入以避免循环依赖和确保数据库连接已初始化
|
||||
from app.database.connection import database
|
||||
from app.database.models import Settings as SettingsModel
|
||||
|
||||
global settings
|
||||
logger.info("Starting initial settings synchronization...")
|
||||
|
||||
if not database.is_connected:
|
||||
try:
|
||||
await database.connect()
|
||||
logger.info("Database connection established for initial sync.")
|
||||
except Exception as e:
|
||||
logger.error(f"Failed to connect to database for initial settings sync: {e}. Skipping sync.")
|
||||
return
|
||||
|
||||
try:
|
||||
# 1. 从数据库加载设置
|
||||
db_settings_raw: List[Dict[str, Any]] = []
|
||||
try:
|
||||
query = select(SettingsModel.key, SettingsModel.value)
|
||||
results = await database.fetch_all(query)
|
||||
db_settings_raw = [{"key": row["key"], "value": row["value"]} for row in results]
|
||||
logger.info(f"Fetched {len(db_settings_raw)} settings from database.")
|
||||
except Exception as e:
|
||||
logger.error(f"Failed to fetch settings from database: {e}. Proceeding with environment/dotenv settings.")
|
||||
# 即使数据库读取失败,也要继续执行,确保基于 env/dotenv 的配置能同步到数据库
|
||||
|
||||
db_settings_map: Dict[str, str] = {s['key']: s['value'] for s in db_settings_raw}
|
||||
|
||||
# 2. 将数据库设置合并到内存 settings (数据库优先)
|
||||
updated_in_memory = False
|
||||
|
||||
for key, db_value in db_settings_map.items():
|
||||
if hasattr(settings, key):
|
||||
target_type = Settings.__annotations__.get(key)
|
||||
if target_type:
|
||||
try:
|
||||
parsed_db_value = _parse_db_value(key, db_value, target_type)
|
||||
memory_value = getattr(settings, key)
|
||||
|
||||
# 比较解析后的值和内存中的值
|
||||
# 注意:对于列表等复杂类型,直接比较可能不够健壮,但这里简化处理
|
||||
if parsed_db_value != memory_value:
|
||||
# 检查类型是否匹配,以防解析函数返回了不兼容的类型
|
||||
# 优先处理 List[str] 类型,避免直接对泛型使用 isinstance
|
||||
if target_type == List[str]:
|
||||
if isinstance(parsed_db_value, list):
|
||||
# 可以选择性地添加对列表元素的检查,但这里保持简化
|
||||
setattr(settings, key, parsed_db_value)
|
||||
logger.info(f"Updated setting '{key}' in memory from database value (List[str]).")
|
||||
updated_in_memory = True
|
||||
else:
|
||||
logger.warning(f"Parsed DB value type mismatch for key '{key}'. Expected List[str], got {type(parsed_db_value)}. Skipping update.")
|
||||
# 对于其他非泛型类型,使用常规的 isinstance 检查
|
||||
elif isinstance(parsed_db_value, target_type):
|
||||
setattr(settings, key, parsed_db_value)
|
||||
logger.info(f"Updated setting '{key}' in memory from database value.")
|
||||
updated_in_memory = True
|
||||
else:
|
||||
logger.warning(f"Parsed DB value type mismatch for key '{key}'. Expected {target_type}, got {type(parsed_db_value)}. Skipping update.")
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Error processing database setting for key '{key}': {e}")
|
||||
else:
|
||||
logger.warning(f"Database setting '{key}' not found in Settings model definition. Ignoring.")
|
||||
|
||||
|
||||
# 如果内存中有更新,重新验证 Pydantic 模型(可选但推荐)
|
||||
if updated_in_memory:
|
||||
try:
|
||||
# 重新加载以确保类型转换和验证
|
||||
settings = Settings(**settings.model_dump())
|
||||
logger.info("Settings object re-validated after merging database values.")
|
||||
except ValidationError as e:
|
||||
logger.error(f"Validation error after merging database settings: {e}. Settings might be inconsistent.")
|
||||
|
||||
|
||||
# 3. 将最终的内存 settings 同步回数据库
|
||||
final_memory_settings = settings.model_dump()
|
||||
settings_to_update: List[Dict[str, Any]] = []
|
||||
settings_to_insert: List[Dict[str, Any]] = []
|
||||
now = datetime.datetime.now(datetime.timezone.utc)
|
||||
|
||||
existing_db_keys = set(db_settings_map.keys())
|
||||
|
||||
for key, value in final_memory_settings.items():
|
||||
# 序列化值为字符串或 JSON 字符串
|
||||
if isinstance(value, list):
|
||||
db_value = json.dumps(value)
|
||||
elif isinstance(value, bool):
|
||||
db_value = str(value).lower()
|
||||
else:
|
||||
db_value = str(value)
|
||||
|
||||
data = {
|
||||
'key': key,
|
||||
'value': db_value,
|
||||
'description': f"{key} configuration setting", # 默认描述
|
||||
'updated_at': now
|
||||
}
|
||||
|
||||
if key in existing_db_keys:
|
||||
# 仅当值与数据库中的不同时才更新
|
||||
if db_settings_map[key] != db_value:
|
||||
settings_to_update.append(data)
|
||||
else:
|
||||
# 如果键不在数据库中,则插入
|
||||
data['created_at'] = now
|
||||
settings_to_insert.append(data)
|
||||
|
||||
# 在事务中执行批量插入和更新
|
||||
if settings_to_insert or settings_to_update:
|
||||
try:
|
||||
async with database.transaction():
|
||||
if settings_to_insert:
|
||||
# 获取现有描述以避免覆盖
|
||||
query_existing = select(SettingsModel.key, SettingsModel.description).where(SettingsModel.key.in_([s['key'] for s in settings_to_insert]))
|
||||
existing_desc = {row['key']: row['description'] for row in await database.fetch_all(query_existing)}
|
||||
for item in settings_to_insert:
|
||||
item['description'] = existing_desc.get(item['key'], item['description'])
|
||||
|
||||
query_insert = insert(SettingsModel).values(settings_to_insert)
|
||||
await database.execute(query=query_insert)
|
||||
logger.info(f"Synced (inserted) {len(settings_to_insert)} settings to database.")
|
||||
|
||||
if settings_to_update:
|
||||
# 获取现有描述以避免覆盖
|
||||
query_existing = select(SettingsModel.key, SettingsModel.description).where(SettingsModel.key.in_([s['key'] for s in settings_to_update]))
|
||||
existing_desc = {row['key']: row['description'] for row in await database.fetch_all(query_existing)}
|
||||
|
||||
for setting_data in settings_to_update:
|
||||
setting_data['description'] = existing_desc.get(setting_data['key'], setting_data['description'])
|
||||
query_update = (
|
||||
update(SettingsModel)
|
||||
.where(SettingsModel.key == setting_data['key'])
|
||||
.values(
|
||||
value=setting_data['value'],
|
||||
description=setting_data['description'],
|
||||
updated_at=setting_data['updated_at']
|
||||
)
|
||||
)
|
||||
await database.execute(query=query_update)
|
||||
logger.info(f"Synced (updated) {len(settings_to_update)} settings to database.")
|
||||
except Exception as e:
|
||||
logger.error(f"Failed to sync settings to database during startup: {str(e)}")
|
||||
else:
|
||||
logger.info("No setting changes detected between memory and database during initial sync.")
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"An unexpected error occurred during initial settings sync: {e}")
|
||||
finally:
|
||||
if database.is_connected:
|
||||
try:
|
||||
# Don't disconnect if it's managed elsewhere (e.g., FastAPI lifespan)
|
||||
# await database.disconnect()
|
||||
# logger.info("Database connection closed after initial sync.")
|
||||
pass # Assume connection lifecycle is managed by the application lifespan
|
||||
except Exception as e:
|
||||
logger.error(f"Error disconnecting database after initial sync: {e}")
|
||||
|
||||
logger.info("Initial settings synchronization finished.")
|
||||
|
||||
@@ -5,13 +5,16 @@ from contextlib import asynccontextmanager
|
||||
from fastapi import FastAPI
|
||||
from fastapi.staticfiles import StaticFiles
|
||||
|
||||
from app.config.config import settings
|
||||
from app.config.config import settings, sync_initial_settings
|
||||
from app.log.logger import get_application_logger
|
||||
from app.middleware.middleware import setup_middlewares
|
||||
from app.exception.exceptions import setup_exception_handlers
|
||||
from app.router.routes import setup_routers
|
||||
from app.service.key.key_manager import get_key_manager_instance
|
||||
from app.core.initialization import initialize_app
|
||||
from app.database.connection import connect_to_db, disconnect_from_db
|
||||
from app.database.initialization import initialize_database
|
||||
from app.scheduler.key_checker import start_scheduler, stop_scheduler # 导入调度器函数
|
||||
|
||||
logger = get_application_logger()
|
||||
|
||||
@@ -26,17 +29,38 @@ async def lifespan(app: FastAPI):
|
||||
# 启动事件
|
||||
logger.info("Application starting up...")
|
||||
try:
|
||||
# 初始化KeyManager
|
||||
# 初始化数据库
|
||||
initialize_database()
|
||||
logger.info("Database initialized successfully")
|
||||
|
||||
# 连接到数据库
|
||||
await connect_to_db()
|
||||
|
||||
# 同步初始配置(DB优先,然后同步回DB)
|
||||
await sync_initial_settings()
|
||||
|
||||
# 初始化KeyManager (使用可能已从DB更新的settings)
|
||||
await get_key_manager_instance(settings.API_KEYS)
|
||||
logger.info("KeyManager initialized successfully")
|
||||
except Exception as e:
|
||||
logger.error(f"Failed to initialize KeyManager: {str(e)}")
|
||||
logger.error(f"Failed to initialize application: {str(e)}")
|
||||
raise
|
||||
|
||||
|
||||
# 启动调度器
|
||||
start_scheduler()
|
||||
logger.info("Scheduler started successfully.")
|
||||
|
||||
yield # 应用程序运行期间
|
||||
|
||||
# 关闭事件
|
||||
logger.info("Application shutting down...")
|
||||
|
||||
# 停止调度器
|
||||
stop_scheduler()
|
||||
logger.info("Scheduler stopped.")
|
||||
|
||||
# 断开数据库连接
|
||||
await disconnect_from_db()
|
||||
|
||||
def create_app() -> FastAPI:
|
||||
"""
|
||||
|
||||
@@ -5,6 +5,7 @@
|
||||
# API相关常量
|
||||
API_VERSION = "v1beta"
|
||||
DEFAULT_TIMEOUT = 300 # 秒
|
||||
MAX_RETRIES = 3 # 最大重试次数
|
||||
|
||||
# 模型相关常量
|
||||
SUPPORTED_ROLES = ["user", "model", "system"]
|
||||
|
||||
@@ -37,4 +37,4 @@ def initialize_app() -> None:
|
||||
]
|
||||
|
||||
ensure_directories_exist(required_directories)
|
||||
logger.info("Application initialization completed")
|
||||
logger.info("core initialization completed")
|
||||
|
||||
@@ -13,12 +13,9 @@ def verify_auth_token(token: str) -> bool:
|
||||
|
||||
|
||||
class SecurityService:
|
||||
def __init__(self, allowed_tokens: list, auth_token: str):
|
||||
self.allowed_tokens = allowed_tokens
|
||||
self.auth_token = auth_token
|
||||
|
||||
async def verify_key(self, key: str):
|
||||
if key not in self.allowed_tokens and key != self.auth_token:
|
||||
if key not in settings.ALLOWED_TOKENS and key != settings.AUTH_TOKEN:
|
||||
logger.error("Invalid key")
|
||||
raise HTTPException(status_code=401, detail="Invalid key")
|
||||
return key
|
||||
@@ -37,7 +34,7 @@ class SecurityService:
|
||||
)
|
||||
|
||||
token = authorization.replace("Bearer ", "")
|
||||
if token not in self.allowed_tokens and token != self.auth_token:
|
||||
if token not in settings.ALLOWED_TOKENS and token != settings.AUTH_TOKEN:
|
||||
logger.error("Invalid token")
|
||||
raise HTTPException(status_code=401, detail="Invalid token")
|
||||
|
||||
@@ -52,8 +49,8 @@ class SecurityService:
|
||||
raise HTTPException(status_code=401, detail="Missing x-goog-api-key header")
|
||||
|
||||
if (
|
||||
x_goog_api_key not in self.allowed_tokens
|
||||
and x_goog_api_key != self.auth_token
|
||||
x_goog_api_key not in settings.ALLOWED_TOKENS
|
||||
and x_goog_api_key != settings.AUTH_TOKEN
|
||||
):
|
||||
logger.error("Invalid x-goog-api-key")
|
||||
raise HTTPException(status_code=401, detail="Invalid x-goog-api-key")
|
||||
@@ -67,7 +64,7 @@ class SecurityService:
|
||||
logger.error("Missing auth_token header")
|
||||
raise HTTPException(status_code=401, detail="Missing auth_token header")
|
||||
token = authorization.replace("Bearer ", "")
|
||||
if token != self.auth_token:
|
||||
if token != settings.AUTH_TOKEN:
|
||||
logger.error("Invalid auth_token")
|
||||
raise HTTPException(status_code=401, detail="Invalid auth_token")
|
||||
|
||||
@@ -78,7 +75,7 @@ class SecurityService:
|
||||
) -> str:
|
||||
"""验证URL中的key或请求头中的x-goog-api-key"""
|
||||
# 如果URL中的key有效,直接返回
|
||||
if key in self.allowed_tokens or key == self.auth_token:
|
||||
if key in settings.ALLOWED_TOKENS or key == settings.AUTH_TOKEN:
|
||||
return key
|
||||
|
||||
# 否则检查请求头中的x-goog-api-key
|
||||
@@ -86,7 +83,7 @@ class SecurityService:
|
||||
logger.error("Invalid key and missing x-goog-api-key header")
|
||||
raise HTTPException(status_code=401, detail="Invalid key and missing x-goog-api-key header")
|
||||
|
||||
if x_goog_api_key not in self.allowed_tokens and x_goog_api_key != self.auth_token:
|
||||
if x_goog_api_key not in settings.ALLOWED_TOKENS and x_goog_api_key != settings.AUTH_TOKEN:
|
||||
logger.error("Invalid key and invalid x-goog-api-key")
|
||||
raise HTTPException(status_code=401, detail="Invalid key and invalid x-goog-api-key")
|
||||
|
||||
|
||||
3
app/database/__init__.py
Normal file
@@ -0,0 +1,3 @@
|
||||
"""
|
||||
数据库模块
|
||||
"""
|
||||
55
app/database/connection.py
Normal file
@@ -0,0 +1,55 @@
|
||||
"""
|
||||
数据库连接池模块
|
||||
"""
|
||||
from databases import Database
|
||||
from sqlalchemy import create_engine, MetaData
|
||||
from sqlalchemy.ext.declarative import declarative_base
|
||||
|
||||
from app.config.config import settings
|
||||
from app.log.logger import get_database_logger
|
||||
|
||||
logger = get_database_logger()
|
||||
|
||||
# 数据库URL
|
||||
DATABASE_URL = f"mysql+pymysql://{settings.MYSQL_USER}:{settings.MYSQL_PASSWORD}@{settings.MYSQL_HOST}:{settings.MYSQL_PORT}/{settings.MYSQL_DATABASE}"
|
||||
|
||||
# 创建数据库引擎
|
||||
# pool_pre_ping=True: 在从连接池获取连接前执行简单的 "ping" 测试,确保连接有效
|
||||
engine = create_engine(DATABASE_URL, pool_pre_ping=True)
|
||||
|
||||
# 创建元数据对象
|
||||
metadata = MetaData()
|
||||
|
||||
# 创建基类
|
||||
Base = declarative_base(metadata=metadata)
|
||||
|
||||
# 创建数据库连接池,并配置连接池参数
|
||||
# min_size/max_size: 连接池的最小/最大连接数
|
||||
# pool_recycle=3600: 连接在池中允许存在的最大秒数(生命周期)。
|
||||
# 设置为 3600 秒(1小时),确保在 MySQL 默认的 wait_timeout (通常8小时) 或其他网络超时之前回收连接。
|
||||
# 如果遇到连接失效问题,可以尝试调低此值,使其小于实际的 wait_timeout 或网络超时时间。
|
||||
# databases 库会自动处理连接失效后的重连尝试。
|
||||
database = Database(DATABASE_URL, min_size=5, max_size=20, pool_recycle=1800) # Reduced recycle time to 30 mins
|
||||
|
||||
|
||||
async def connect_to_db():
|
||||
"""
|
||||
连接到数据库
|
||||
"""
|
||||
try:
|
||||
await database.connect()
|
||||
logger.info("Connected to database")
|
||||
except Exception as e:
|
||||
logger.error(f"Failed to connect to database: {str(e)}")
|
||||
raise
|
||||
|
||||
|
||||
async def disconnect_from_db():
|
||||
"""
|
||||
断开数据库连接
|
||||
"""
|
||||
try:
|
||||
await database.disconnect()
|
||||
logger.info("Disconnected from database")
|
||||
except Exception as e:
|
||||
logger.error(f"Failed to disconnect from database: {str(e)}")
|
||||
77
app/database/initialization.py
Normal file
@@ -0,0 +1,77 @@
|
||||
"""
|
||||
数据库初始化模块
|
||||
"""
|
||||
from dotenv import dotenv_values
|
||||
|
||||
from sqlalchemy import inspect
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from app.database.connection import engine, Base
|
||||
from app.database.models import Settings
|
||||
from app.log.logger import get_database_logger
|
||||
|
||||
logger = get_database_logger()
|
||||
|
||||
|
||||
def create_tables():
|
||||
"""
|
||||
创建数据库表
|
||||
"""
|
||||
try:
|
||||
# 创建所有表
|
||||
Base.metadata.create_all(engine)
|
||||
logger.info("Database tables created successfully")
|
||||
except Exception as e:
|
||||
logger.error(f"Failed to create database tables: {str(e)}")
|
||||
raise
|
||||
|
||||
|
||||
def import_env_to_settings():
|
||||
"""
|
||||
将.env文件中的配置项导入到t_settings表中
|
||||
"""
|
||||
try:
|
||||
# 获取.env文件中的所有配置项
|
||||
env_values = dotenv_values(".env")
|
||||
|
||||
# 获取检查器
|
||||
inspector = inspect(engine)
|
||||
|
||||
# 检查t_settings表是否存在
|
||||
if "t_settings" in inspector.get_table_names():
|
||||
# 使用Session进行数据库操作
|
||||
with Session(engine) as session:
|
||||
# 获取所有现有的配置项
|
||||
current_settings = {setting.key: setting for setting in session.query(Settings).all()}
|
||||
|
||||
# 遍历所有配置项
|
||||
for key, value in env_values.items():
|
||||
# 检查配置项是否已存在
|
||||
if key not in current_settings:
|
||||
# 插入配置项
|
||||
new_setting = Settings(key=key, value=value)
|
||||
session.add(new_setting)
|
||||
logger.info(f"Inserted setting: {key}")
|
||||
|
||||
# 提交事务
|
||||
session.commit()
|
||||
|
||||
logger.info("Environment variables imported to settings table successfully")
|
||||
except Exception as e:
|
||||
logger.error(f"Failed to import environment variables to settings table: {str(e)}")
|
||||
raise
|
||||
|
||||
|
||||
def initialize_database():
|
||||
"""
|
||||
初始化数据库
|
||||
"""
|
||||
try:
|
||||
# 创建表
|
||||
create_tables()
|
||||
|
||||
# 导入环境变量
|
||||
import_env_to_settings()
|
||||
except Exception as e:
|
||||
logger.error(f"Failed to initialize database: {str(e)}")
|
||||
raise
|
||||
61
app/database/models.py
Normal file
@@ -0,0 +1,61 @@
|
||||
"""
|
||||
数据库模型模块
|
||||
"""
|
||||
import datetime
|
||||
from sqlalchemy import Column, Integer, String, Text, DateTime, JSON, Boolean # 添加 Boolean
|
||||
|
||||
from app.database.connection import Base
|
||||
|
||||
|
||||
class Settings(Base):
|
||||
"""
|
||||
设置表,对应.env中的配置项
|
||||
"""
|
||||
__tablename__ = "t_settings"
|
||||
|
||||
id = Column(Integer, primary_key=True, autoincrement=True)
|
||||
key = Column(String(100), nullable=False, unique=True, comment="配置项键名")
|
||||
value = Column(Text, nullable=True, comment="配置项值")
|
||||
description = Column(String(255), nullable=True, comment="配置项描述")
|
||||
created_at = Column(DateTime, default=datetime.datetime.now, comment="创建时间")
|
||||
updated_at = Column(DateTime, default=datetime.datetime.now, onupdate=datetime.datetime.now, comment="更新时间")
|
||||
|
||||
def __repr__(self):
|
||||
return f"<Settings(key='{self.key}', value='{self.value}')>"
|
||||
|
||||
|
||||
class ErrorLog(Base):
|
||||
"""
|
||||
错误日志表
|
||||
"""
|
||||
__tablename__ = "t_error_logs"
|
||||
|
||||
id = Column(Integer, primary_key=True, autoincrement=True)
|
||||
gemini_key = Column(String(100), nullable=True, comment="Gemini API密钥")
|
||||
model_name = Column(String(100), nullable=True, comment="模型名称")
|
||||
error_type = Column(String(50), nullable=True, comment="错误类型")
|
||||
error_log = Column(Text, nullable=True, comment="错误日志")
|
||||
error_code = Column(Integer, nullable=True, comment="错误代码")
|
||||
request_msg = Column(JSON, nullable=True, comment="请求消息")
|
||||
request_time = Column(DateTime, default=datetime.datetime.now, comment="请求时间")
|
||||
|
||||
def __repr__(self):
|
||||
return f"<ErrorLog(id='{self.id}', gemini_key='{self.gemini_key}')>"
|
||||
|
||||
# 新增 RequestLog 模型
|
||||
class RequestLog(Base):
|
||||
"""
|
||||
API 请求日志表
|
||||
"""
|
||||
__tablename__ = "t_request_log"
|
||||
|
||||
id = Column(Integer, primary_key=True, autoincrement=True)
|
||||
request_time = Column(DateTime, default=datetime.datetime.now, comment="请求时间")
|
||||
model_name = Column(String(100), nullable=True, comment="模型名称")
|
||||
api_key = Column(String(100), nullable=True, comment="使用的API密钥") # 考虑安全性,后续可优化
|
||||
is_success = Column(Boolean, nullable=False, comment="请求是否成功")
|
||||
status_code = Column(Integer, nullable=True, comment="API响应状态码")
|
||||
latency_ms = Column(Integer, nullable=True, comment="请求耗时(毫秒)")
|
||||
|
||||
def __repr__(self):
|
||||
return f"<RequestLog(id='{self.id}', key='{self.api_key[:4]}...', success='{self.is_success}')>"
|
||||
323
app/database/services.py
Normal file
@@ -0,0 +1,323 @@
|
||||
"""
|
||||
数据库服务模块
|
||||
"""
|
||||
import json
|
||||
from typing import Dict, List, Optional, Any, Union
|
||||
from datetime import datetime # Keep this import
|
||||
|
||||
from sqlalchemy import select, insert, update, func
|
||||
|
||||
from app.database.connection import database
|
||||
from app.database.models import Settings, ErrorLog, RequestLog # Import RequestLog
|
||||
from app.log.logger import get_database_logger
|
||||
|
||||
logger = get_database_logger()
|
||||
|
||||
|
||||
async def get_all_settings() -> List[Dict[str, Any]]:
|
||||
"""
|
||||
获取所有设置
|
||||
|
||||
Returns:
|
||||
List[Dict[str, Any]]: 设置列表
|
||||
"""
|
||||
try:
|
||||
query = select(Settings)
|
||||
result = await database.fetch_all(query)
|
||||
return [dict(row) for row in result]
|
||||
except Exception as e:
|
||||
logger.error(f"Failed to get all settings: {str(e)}")
|
||||
raise
|
||||
|
||||
|
||||
async def get_setting(key: str) -> Optional[Dict[str, Any]]:
|
||||
"""
|
||||
获取指定键的设置
|
||||
|
||||
Args:
|
||||
key: 设置键名
|
||||
|
||||
Returns:
|
||||
Optional[Dict[str, Any]]: 设置信息,如果不存在则返回None
|
||||
"""
|
||||
try:
|
||||
query = select(Settings).where(Settings.key == key)
|
||||
result = await database.fetch_one(query)
|
||||
return dict(result) if result else None
|
||||
except Exception as e:
|
||||
logger.error(f"Failed to get setting {key}: {str(e)}")
|
||||
raise
|
||||
|
||||
|
||||
async def update_setting(key: str, value: str, description: Optional[str] = None) -> bool:
|
||||
"""
|
||||
更新设置
|
||||
|
||||
Args:
|
||||
key: 设置键名
|
||||
value: 设置值
|
||||
description: 设置描述
|
||||
|
||||
Returns:
|
||||
bool: 是否更新成功
|
||||
"""
|
||||
try:
|
||||
# 检查设置是否存在
|
||||
setting = await get_setting(key)
|
||||
|
||||
if setting:
|
||||
# 更新设置
|
||||
query = (
|
||||
update(Settings)
|
||||
.where(Settings.key == key)
|
||||
.values(
|
||||
value=value,
|
||||
description=description if description else setting["description"],
|
||||
updated_at=datetime.now() # Use datetime.now()
|
||||
)
|
||||
)
|
||||
await database.execute(query)
|
||||
logger.info(f"Updated setting: {key}")
|
||||
return True
|
||||
else:
|
||||
# 插入设置
|
||||
query = (
|
||||
insert(Settings)
|
||||
.values(
|
||||
key=key,
|
||||
value=value,
|
||||
description=description,
|
||||
created_at=datetime.now(), # Use datetime.now()
|
||||
updated_at=datetime.now() # Use datetime.now()
|
||||
)
|
||||
)
|
||||
await database.execute(query)
|
||||
logger.info(f"Inserted setting: {key}")
|
||||
return True
|
||||
except Exception as e:
|
||||
logger.error(f"Failed to update setting {key}: {str(e)}")
|
||||
return False
|
||||
|
||||
|
||||
async def add_error_log(
|
||||
gemini_key: Optional[str] = None,
|
||||
model_name: Optional[str] = None,
|
||||
error_type: Optional[str] = None,
|
||||
error_log: Optional[str] = None,
|
||||
error_code: Optional[int] = None,
|
||||
request_msg: Optional[Union[Dict[str, Any], str]] = None
|
||||
) -> bool:
|
||||
"""
|
||||
添加错误日志
|
||||
|
||||
Args:
|
||||
gemini_key: Gemini API密钥
|
||||
error_log: 错误日志
|
||||
error_code: 错误代码 (例如 HTTP 状态码)
|
||||
request_msg: 请求消息
|
||||
|
||||
Returns:
|
||||
bool: 是否添加成功
|
||||
"""
|
||||
try:
|
||||
# 如果request_msg是字典,则转换为JSON字符串
|
||||
if isinstance(request_msg, dict):
|
||||
request_msg_json = request_msg
|
||||
elif isinstance(request_msg, str):
|
||||
try:
|
||||
request_msg_json = json.loads(request_msg)
|
||||
except json.JSONDecodeError:
|
||||
request_msg_json = {"message": request_msg}
|
||||
else:
|
||||
request_msg_json = None
|
||||
|
||||
# 插入错误日志
|
||||
query = (
|
||||
insert(ErrorLog)
|
||||
.values(
|
||||
gemini_key=gemini_key,
|
||||
error_type=error_type,
|
||||
error_log=error_log,
|
||||
model_name=model_name,
|
||||
error_code=error_code,
|
||||
request_msg=request_msg_json,
|
||||
request_time=datetime.now()
|
||||
)
|
||||
)
|
||||
await database.execute(query)
|
||||
logger.info(f"Added error log for key: {gemini_key}")
|
||||
return True
|
||||
except Exception as e:
|
||||
logger.error(f"Failed to add error log: {str(e)}")
|
||||
return False
|
||||
|
||||
|
||||
async def get_error_logs(
|
||||
limit: int = 20,
|
||||
offset: int = 0,
|
||||
key_search: Optional[str] = None,
|
||||
error_search: Optional[str] = None,
|
||||
start_date: Optional[datetime] = None,
|
||||
end_date: Optional[datetime] = None
|
||||
) -> List[Dict[str, Any]]:
|
||||
"""
|
||||
获取错误日志,支持搜索和日期过滤
|
||||
|
||||
Args:
|
||||
limit (int): 限制数量
|
||||
offset (int): 偏移量
|
||||
key_search (Optional[str]): Gemini密钥搜索词 (模糊匹配)
|
||||
error_search (Optional[str]): 错误类型或日志内容搜索词 (模糊匹配)
|
||||
start_date (Optional[datetime]): 开始日期时间
|
||||
end_date (Optional[datetime]): 结束日期时间
|
||||
|
||||
Returns:
|
||||
List[Dict[str, Any]]: 错误日志列表
|
||||
"""
|
||||
try:
|
||||
query = select(
|
||||
ErrorLog.id,
|
||||
ErrorLog.gemini_key,
|
||||
ErrorLog.model_name,
|
||||
ErrorLog.error_type,
|
||||
ErrorLog.error_log,
|
||||
ErrorLog.error_code,
|
||||
ErrorLog.request_time
|
||||
)
|
||||
|
||||
# Apply filters
|
||||
if key_search:
|
||||
query = query.where(ErrorLog.gemini_key.ilike(f"%{key_search}%"))
|
||||
if error_search:
|
||||
query = query.where(
|
||||
(ErrorLog.error_type.ilike(f"%{error_search}%")) |
|
||||
(ErrorLog.error_log.ilike(f"%{error_search}%"))
|
||||
)
|
||||
if start_date:
|
||||
query = query.where(ErrorLog.request_time >= start_date)
|
||||
if end_date:
|
||||
# Use the datetime object directly for comparison
|
||||
query = query.where(ErrorLog.request_time < end_date)
|
||||
|
||||
# Apply ordering, limit, and offset
|
||||
query = query.order_by(ErrorLog.id.desc()).limit(limit).offset(offset)
|
||||
|
||||
result = await database.fetch_all(query)
|
||||
return [dict(row) for row in result]
|
||||
except Exception as e:
|
||||
logger.exception(f"Failed to get error logs with filters: {str(e)}") # Use exception for stack trace
|
||||
raise
|
||||
|
||||
|
||||
async def get_error_logs_count(
|
||||
key_search: Optional[str] = None,
|
||||
error_search: Optional[str] = None,
|
||||
start_date: Optional[datetime] = None,
|
||||
end_date: Optional[datetime] = None
|
||||
) -> int:
|
||||
"""
|
||||
获取符合条件的错误日志总数
|
||||
|
||||
Args:
|
||||
key_search (Optional[str]): Gemini密钥搜索词 (模糊匹配)
|
||||
error_search (Optional[str]): 错误类型或日志内容搜索词 (模糊匹配)
|
||||
start_date (Optional[datetime]): 开始日期时间
|
||||
end_date (Optional[datetime]): 结束日期时间
|
||||
|
||||
Returns:
|
||||
int: 日志总数
|
||||
"""
|
||||
try:
|
||||
query = select(func.count()).select_from(ErrorLog)
|
||||
|
||||
# Apply the same filters as get_error_logs
|
||||
if key_search:
|
||||
query = query.where(ErrorLog.gemini_key.ilike(f"%{key_search}%"))
|
||||
if error_search:
|
||||
query = query.where(
|
||||
(ErrorLog.error_type.ilike(f"%{error_search}%")) |
|
||||
(ErrorLog.error_log.ilike(f"%{error_search}%"))
|
||||
)
|
||||
if start_date:
|
||||
query = query.where(ErrorLog.request_time >= start_date)
|
||||
if end_date:
|
||||
# Use the datetime object directly for comparison
|
||||
query = query.where(ErrorLog.request_time < end_date)
|
||||
|
||||
count_result = await database.fetch_one(query)
|
||||
return count_result[0] if count_result else 0
|
||||
except Exception as e:
|
||||
logger.exception(f"Failed to count error logs with filters: {str(e)}") # Use exception for stack trace
|
||||
raise
|
||||
|
||||
|
||||
# 新增函数:获取单条错误日志详情
|
||||
async def get_error_log_details(log_id: int) -> Optional[Dict[str, Any]]:
|
||||
"""
|
||||
根据 ID 获取单个错误日志的详细信息
|
||||
|
||||
Args:
|
||||
log_id (int): 错误日志的 ID
|
||||
|
||||
Returns:
|
||||
Optional[Dict[str, Any]]: 包含日志详细信息的字典,如果未找到则返回 None
|
||||
"""
|
||||
try:
|
||||
query = select(ErrorLog).where(ErrorLog.id == log_id)
|
||||
result = await database.fetch_one(query)
|
||||
if result:
|
||||
# 将 request_msg (JSONB) 转换为字符串以便在 API 中返回
|
||||
log_dict = dict(result)
|
||||
if 'request_msg' in log_dict and log_dict['request_msg'] is not None:
|
||||
# 确保即使是 None 或非 JSON 数据也能处理
|
||||
try:
|
||||
log_dict['request_msg'] = json.dumps(log_dict['request_msg'], ensure_ascii=False, indent=2)
|
||||
except TypeError:
|
||||
log_dict['request_msg'] = str(log_dict['request_msg']) # Fallback to string
|
||||
return log_dict
|
||||
else:
|
||||
return None
|
||||
except Exception as e:
|
||||
logger.exception(f"Failed to get error log details for ID {log_id}: {str(e)}")
|
||||
raise
|
||||
|
||||
# 新增函数:添加请求日志
|
||||
async def add_request_log(
|
||||
model_name: Optional[str],
|
||||
api_key: Optional[str],
|
||||
is_success: bool,
|
||||
status_code: Optional[int] = None,
|
||||
latency_ms: Optional[int] = None,
|
||||
request_time: Optional[datetime] = None
|
||||
) -> bool:
|
||||
"""
|
||||
添加 API 请求日志
|
||||
|
||||
Args:
|
||||
model_name: 模型名称
|
||||
api_key: 使用的 API 密钥
|
||||
is_success: 请求是否成功
|
||||
status_code: API 响应状态码
|
||||
latency_ms: 请求耗时(毫秒)
|
||||
request_time: 请求发生时间 (如果为 None, 则使用当前时间)
|
||||
|
||||
Returns:
|
||||
bool: 是否添加成功
|
||||
"""
|
||||
try:
|
||||
log_time = request_time if request_time else datetime.now()
|
||||
|
||||
query = insert(RequestLog).values(
|
||||
request_time=log_time,
|
||||
model_name=model_name,
|
||||
api_key=api_key,
|
||||
is_success=is_success,
|
||||
status_code=status_code,
|
||||
latency_ms=latency_ms
|
||||
)
|
||||
await database.execute(query)
|
||||
# logger.debug(f"Added request log: key={api_key[:4]}..., success={is_success}, model={model_name}") # Use debug level
|
||||
return True
|
||||
except Exception as e:
|
||||
logger.error(f"Failed to add request log: {str(e)}")
|
||||
return False
|
||||
@@ -1,6 +1,8 @@
|
||||
from typing import List, Optional, Dict, Any, Literal
|
||||
from typing import List, Optional, Dict, Any, Literal, Union
|
||||
from pydantic import BaseModel
|
||||
|
||||
from app.core.constants import DEFAULT_TEMPERATURE, DEFAULT_TOP_K, DEFAULT_TOP_P
|
||||
|
||||
|
||||
class SafetySetting(BaseModel):
|
||||
category: Optional[Literal["HARM_CATEGORY_HATE_SPEECH", "HARM_CATEGORY_DANGEROUS_CONTENT", "HARM_CATEGORY_HARASSMENT", "HARM_CATEGORY_SEXUALLY_EXPLICIT", "HARM_CATEGORY_CIVIC_INTEGRITY"]] = None
|
||||
@@ -13,9 +15,9 @@ class GenerationConfig(BaseModel):
|
||||
responseSchema: Optional[Dict[str, Any]] = None
|
||||
candidateCount: Optional[int] = 1
|
||||
maxOutputTokens: Optional[int] = None
|
||||
temperature: Optional[float] = None
|
||||
topP: Optional[float] = None
|
||||
topK: Optional[int] = None
|
||||
temperature: Optional[float] = DEFAULT_TEMPERATURE
|
||||
topP: Optional[float] = DEFAULT_TOP_P
|
||||
topK: Optional[int] = DEFAULT_TOP_K
|
||||
presencePenalty: Optional[float] = None
|
||||
frequencyPenalty: Optional[float] = None
|
||||
responseLogprobs: Optional[bool] = None
|
||||
@@ -34,7 +36,16 @@ class GeminiContent(BaseModel):
|
||||
|
||||
class GeminiRequest(BaseModel):
|
||||
contents: List[GeminiContent] = []
|
||||
tools: Optional[List[Dict[str, Any]]] = []
|
||||
tools: Optional[Union[List[Dict[str, Any]], Dict[str, Any]]] = []
|
||||
safetySettings: Optional[List[SafetySetting]] = None
|
||||
generationConfig: Optional[GenerationConfig] = None
|
||||
systemInstruction: Optional[SystemInstruction] = None
|
||||
|
||||
|
||||
class ResetSelectedKeysRequest(BaseModel):
|
||||
keys: List[str]
|
||||
key_type: str
|
||||
|
||||
|
||||
class VerifySelectedKeysRequest(BaseModel):
|
||||
keys: List[str]
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
from pydantic import BaseModel
|
||||
from typing import List, Optional, Union
|
||||
|
||||
from app.core.constants import DEFAULT_MAX_TOKENS, DEFAULT_MODEL, DEFAULT_TEMPERATURE, DEFAULT_TOP_K, DEFAULT_TOP_P
|
||||
from app.core.constants import DEFAULT_MODEL, DEFAULT_TEMPERATURE, DEFAULT_TOP_K, DEFAULT_TOP_P
|
||||
|
||||
|
||||
class ChatRequest(BaseModel):
|
||||
@@ -10,7 +10,7 @@ class ChatRequest(BaseModel):
|
||||
temperature: Optional[float] = DEFAULT_TEMPERATURE
|
||||
stream: Optional[bool] = False
|
||||
tools: Optional[List[dict]] = []
|
||||
max_tokens: Optional[int] = DEFAULT_MAX_TOKENS
|
||||
max_tokens: Optional[int] = None
|
||||
top_p: Optional[float] = DEFAULT_TOP_P
|
||||
top_k: Optional[int] = DEFAULT_TOP_K
|
||||
stop: Optional[List[str]] = []
|
||||
|
||||
@@ -3,6 +3,7 @@
|
||||
from functools import wraps
|
||||
from typing import Callable, TypeVar
|
||||
|
||||
from app.core.constants import MAX_RETRIES
|
||||
from app.log.logger import get_retry_logger
|
||||
|
||||
T = TypeVar("T")
|
||||
@@ -12,7 +13,7 @@ logger = get_retry_logger()
|
||||
class RetryHandler:
|
||||
"""重试处理装饰器"""
|
||||
|
||||
def __init__(self, max_retries: int = 3, key_arg: str = "api_key"):
|
||||
def __init__(self, max_retries: int = MAX_RETRIES, key_arg: str = "api_key"):
|
||||
self.max_retries = max_retries
|
||||
self.key_arg = key_arg
|
||||
|
||||
|
||||
@@ -107,15 +107,15 @@ class StreamOptimizer:
|
||||
|
||||
# 计算智能延迟时间
|
||||
delay = self.calculate_delay(len(text))
|
||||
if self.logger:
|
||||
self.logger.info(f"Text length: {len(text)}, delay: {delay:.4f}s")
|
||||
# if self.logger:
|
||||
# self.logger.info(f"Text length: {len(text)}, delay: {delay:.4f}s")
|
||||
|
||||
# 根据文本长度决定输出方式
|
||||
if len(text) >= self.long_text_threshold:
|
||||
# 长文本:分块输出
|
||||
chunks = self.split_text_into_chunks(text)
|
||||
if self.logger:
|
||||
self.logger.info(f"Long text: splitting into {len(chunks)} chunks")
|
||||
# if self.logger:
|
||||
# self.logger.info(f"Long text: splitting into {len(chunks)} chunks")
|
||||
for chunk_text in chunks:
|
||||
chunk_response = create_response_chunk(chunk_text)
|
||||
yield format_chunk(chunk_response)
|
||||
|
||||
@@ -152,4 +152,24 @@ def get_middleware_logger():
|
||||
|
||||
|
||||
def get_routes_logger():
|
||||
return Logger.setup_logger("routes")
|
||||
return Logger.setup_logger("routes")
|
||||
|
||||
|
||||
def get_config_routes_logger():
|
||||
return Logger.setup_logger("config_routes")
|
||||
|
||||
|
||||
def get_config_logger():
|
||||
return Logger.setup_logger("config")
|
||||
|
||||
|
||||
def get_database_logger():
|
||||
return Logger.setup_logger("database")
|
||||
|
||||
|
||||
def get_log_routes_logger():
|
||||
return Logger.setup_logger("log_routes")
|
||||
|
||||
|
||||
def get_stats_logger():
|
||||
return Logger.setup_logger("stats")
|
||||
48
app/router/config_routes.py
Normal file
@@ -0,0 +1,48 @@
|
||||
"""
|
||||
配置路由模块
|
||||
"""
|
||||
from typing import Any, Dict
|
||||
from fastapi import APIRouter, HTTPException, Request
|
||||
from fastapi.responses import RedirectResponse
|
||||
|
||||
from app.core.security import verify_auth_token
|
||||
from app.log.logger import get_config_routes_logger
|
||||
from app.service.config.config_service import ConfigService
|
||||
|
||||
# 创建路由
|
||||
router = APIRouter(prefix="/api/config", tags=["config"])
|
||||
|
||||
logger = get_config_routes_logger()
|
||||
|
||||
|
||||
@router.get("", response_model=Dict[str, Any])
|
||||
async def get_config(request: Request):
|
||||
auth_token = request.cookies.get("auth_token")
|
||||
if not auth_token or not verify_auth_token(auth_token):
|
||||
logger.warning("Unauthorized access attempt to config page")
|
||||
return RedirectResponse(url="/", status_code=302)
|
||||
return await ConfigService.get_config()
|
||||
|
||||
|
||||
@router.put("", response_model=Dict[str, Any])
|
||||
async def update_config(config_data: Dict[str, Any], request: Request):
|
||||
auth_token = request.cookies.get("auth_token")
|
||||
if not auth_token or not verify_auth_token(auth_token):
|
||||
logger.warning("Unauthorized access attempt to config page")
|
||||
return RedirectResponse(url="/", status_code=302)
|
||||
try:
|
||||
return await ConfigService.update_config(config_data)
|
||||
except Exception as e:
|
||||
raise HTTPException(status_code=400, detail=str(e))
|
||||
|
||||
|
||||
@router.post("/reset", response_model=Dict[str, Any])
|
||||
async def reset_config(request: Request):
|
||||
auth_token = request.cookies.get("auth_token")
|
||||
if not auth_token or not verify_auth_token(auth_token):
|
||||
logger.warning("Unauthorized access attempt to config page")
|
||||
return RedirectResponse(url="/", status_code=302)
|
||||
try:
|
||||
return await ConfigService.reset_config()
|
||||
except Exception as e:
|
||||
raise HTTPException(status_code=400, detail=str(e))
|
||||
@@ -4,7 +4,8 @@ from copy import deepcopy
|
||||
from app.config.config import settings
|
||||
from app.log.logger import get_gemini_logger
|
||||
from app.core.security import SecurityService
|
||||
from app.domain.gemini_models import GeminiContent, GeminiRequest
|
||||
import asyncio # 导入 asyncio
|
||||
from app.domain.gemini_models import GeminiContent, GeminiRequest, ResetSelectedKeysRequest, VerifySelectedKeysRequest # 添加导入
|
||||
from app.service.chat.gemini_chat_service import GeminiChatService
|
||||
from app.service.key.key_manager import KeyManager, get_key_manager_instance
|
||||
from app.service.model.model_service import ModelService
|
||||
@@ -17,8 +18,8 @@ router_v1beta = APIRouter(prefix=f"/{API_VERSION}")
|
||||
logger = get_gemini_logger()
|
||||
|
||||
# 初始化服务
|
||||
security_service = SecurityService(settings.ALLOWED_TOKENS, settings.AUTH_TOKEN)
|
||||
model_service = ModelService(settings.SEARCH_MODELS, settings.IMAGE_MODELS)
|
||||
security_service = SecurityService()
|
||||
model_service = ModelService()
|
||||
|
||||
|
||||
async def get_key_manager():
|
||||
@@ -31,6 +32,11 @@ async def get_next_working_key(key_manager: KeyManager = Depends(get_key_manager
|
||||
return await key_manager.get_next_working_key()
|
||||
|
||||
|
||||
async def get_chat_service(key_manager: KeyManager = Depends(get_key_manager)):
|
||||
"""获取Gemini聊天服务实例"""
|
||||
return GeminiChatService(settings.BASE_URL, key_manager)
|
||||
|
||||
|
||||
@router.get("/models")
|
||||
@router_v1beta.get("/models")
|
||||
async def list_models(
|
||||
@@ -41,15 +47,15 @@ async def list_models(
|
||||
logger.info("-" * 50 + "list_gemini_models" + "-" * 50)
|
||||
logger.info("Handling Gemini models list request")
|
||||
|
||||
api_key = await key_manager.get_next_working_key()
|
||||
api_key = await key_manager.get_first_valid_key()
|
||||
logger.info(f"Using API key: {api_key}")
|
||||
|
||||
models_json = model_service.get_gemini_models(api_key)
|
||||
model_mapping = {x.get("name", "").split("/", maxsplit=1)[1]: x for x in models_json["models"]}
|
||||
|
||||
# 添加搜索模型
|
||||
if model_service.search_models:
|
||||
for name in model_service.search_models:
|
||||
if settings.SEARCH_MODELS:
|
||||
for name in settings.SEARCH_MODELS:
|
||||
model = model_mapping.get(name)
|
||||
if not model:
|
||||
continue
|
||||
@@ -63,8 +69,8 @@ async def list_models(
|
||||
models_json["models"].append(item)
|
||||
|
||||
# 添加图像生成模型
|
||||
if model_service.image_models:
|
||||
for name in model_service.image_models:
|
||||
if settings.IMAGE_MODELS:
|
||||
for name in settings.IMAGE_MODELS:
|
||||
model = model_mapping.get(name)
|
||||
if not model:
|
||||
continue
|
||||
@@ -82,13 +88,13 @@ async def list_models(
|
||||
|
||||
@router.post("/models/{model_name}:generateContent")
|
||||
@router_v1beta.post("/models/{model_name}:generateContent")
|
||||
@RetryHandler(max_retries=3, key_arg="api_key")
|
||||
@RetryHandler(max_retries=settings.MAX_RETRIES, key_arg="api_key")
|
||||
async def generate_content(
|
||||
model_name: str,
|
||||
request: GeminiRequest,
|
||||
_=Depends(security_service.verify_goog_api_key),
|
||||
_=Depends(security_service.verify_key_or_goog_api_key),
|
||||
api_key: str = Depends(get_next_working_key),
|
||||
key_manager: KeyManager = Depends(get_key_manager)
|
||||
chat_service: GeminiChatService = Depends(get_chat_service)
|
||||
):
|
||||
"""非流式生成内容"""
|
||||
logger.info("-" * 50 + "gemini_generate_content" + "-" * 50)
|
||||
@@ -100,7 +106,6 @@ async def generate_content(
|
||||
raise HTTPException(status_code=400, detail=f"Model {model_name} is not supported")
|
||||
|
||||
try:
|
||||
chat_service = GeminiChatService(settings.BASE_URL, key_manager)
|
||||
response = await chat_service.generate_content(
|
||||
model=model_name,
|
||||
request=request,
|
||||
@@ -114,13 +119,13 @@ async def generate_content(
|
||||
|
||||
@router.post("/models/{model_name}:streamGenerateContent")
|
||||
@router_v1beta.post("/models/{model_name}:streamGenerateContent")
|
||||
@RetryHandler(max_retries=3, key_arg="api_key")
|
||||
@RetryHandler(max_retries=settings.MAX_RETRIES, key_arg="api_key")
|
||||
async def stream_generate_content(
|
||||
model_name: str,
|
||||
request: GeminiRequest,
|
||||
_=Depends(security_service.verify_goog_api_key),
|
||||
_=Depends(security_service.verify_key_or_goog_api_key),
|
||||
api_key: str = Depends(get_next_working_key),
|
||||
key_manager: KeyManager = Depends(get_key_manager)
|
||||
chat_service: GeminiChatService = Depends(get_chat_service)
|
||||
):
|
||||
"""流式生成内容"""
|
||||
logger.info("-" * 50 + "gemini_stream_generate_content" + "-" * 50)
|
||||
@@ -132,7 +137,6 @@ async def stream_generate_content(
|
||||
raise HTTPException(status_code=400, detail=f"Model {model_name} is not supported")
|
||||
|
||||
try:
|
||||
chat_service = GeminiChatService(settings.BASE_URL, key_manager)
|
||||
response_stream = chat_service.stream_generate_content(
|
||||
model=model_name,
|
||||
request=request,
|
||||
@@ -143,17 +147,123 @@ async def stream_generate_content(
|
||||
logger.error(f"Streaming request failed: {str(e)}")
|
||||
raise HTTPException(status_code=500, detail="Streaming request failed") from e
|
||||
|
||||
@router.post("/reset-all-fail-counts")
|
||||
async def reset_all_key_fail_counts(key_type: str = None, key_manager: KeyManager = Depends(get_key_manager)):
|
||||
"""批量重置Gemini API密钥的失败计数,可选择性地仅重置有效或无效密钥"""
|
||||
logger.info("-" * 50 + "reset_all_gemini_key_fail_counts" + "-" * 50)
|
||||
logger.info(f"Received reset request with key_type: {key_type}")
|
||||
|
||||
try:
|
||||
# 获取分类后的密钥
|
||||
keys_by_status = await key_manager.get_keys_by_status()
|
||||
valid_keys = keys_by_status.get("valid_keys", {})
|
||||
invalid_keys = keys_by_status.get("invalid_keys", {})
|
||||
|
||||
# 根据类型选择要重置的密钥
|
||||
keys_to_reset = []
|
||||
if key_type == "valid":
|
||||
keys_to_reset = list(valid_keys.keys())
|
||||
logger.info(f"Resetting only valid keys, count: {len(keys_to_reset)}")
|
||||
elif key_type == "invalid":
|
||||
keys_to_reset = list(invalid_keys.keys())
|
||||
logger.info(f"Resetting only invalid keys, count: {len(keys_to_reset)}")
|
||||
else:
|
||||
# 重置所有密钥
|
||||
await key_manager.reset_failure_counts()
|
||||
return JSONResponse({"success": True, "message": "所有密钥的失败计数已重置"})
|
||||
|
||||
# 批量重置指定类型的密钥
|
||||
for key in keys_to_reset:
|
||||
await key_manager.reset_key_failure_count(key)
|
||||
|
||||
return JSONResponse({
|
||||
"success": True,
|
||||
"message": f"{key_type}密钥的失败计数已重置",
|
||||
"reset_count": len(keys_to_reset)
|
||||
})
|
||||
except Exception as e:
|
||||
logger.error(f"Failed to reset key failure counts: {str(e)}")
|
||||
return JSONResponse({"success": False, "message": f"批量重置失败: {str(e)}"}, status_code=500)
|
||||
|
||||
|
||||
@router.post("/reset-selected-fail-counts")
|
||||
async def reset_selected_key_fail_counts(
|
||||
request: ResetSelectedKeysRequest,
|
||||
key_manager: KeyManager = Depends(get_key_manager)
|
||||
):
|
||||
"""批量重置选定Gemini API密钥的失败计数"""
|
||||
logger.info("-" * 50 + "reset_selected_gemini_key_fail_counts" + "-" * 50)
|
||||
keys_to_reset = request.keys
|
||||
key_type = request.key_type # 获取类型用于日志记录和响应消息
|
||||
logger.info(f"Received reset request for {len(keys_to_reset)} selected {key_type} keys.")
|
||||
|
||||
if not keys_to_reset:
|
||||
return JSONResponse({"success": False, "message": "没有提供需要重置的密钥"}, status_code=400)
|
||||
|
||||
reset_count = 0
|
||||
errors = []
|
||||
|
||||
try:
|
||||
for key in keys_to_reset:
|
||||
try:
|
||||
result = await key_manager.reset_key_failure_count(key)
|
||||
if result:
|
||||
reset_count += 1
|
||||
else:
|
||||
# 记录未找到的密钥,但不视为致命错误
|
||||
logger.warning(f"Key not found during selective reset: {key}")
|
||||
except Exception as key_error:
|
||||
# 记录单个密钥重置时的错误
|
||||
logger.error(f"Error resetting key {key}: {str(key_error)}")
|
||||
errors.append(f"Key {key}: {str(key_error)}")
|
||||
|
||||
if errors:
|
||||
# 如果有错误,报告部分成功或完全失败
|
||||
error_message = f"批量重置完成,但出现错误: {'; '.join(errors)}"
|
||||
# 确定最终状态码和成功标志
|
||||
final_success = reset_count > 0
|
||||
status_code = 207 if final_success and errors else 500 # 207 Multi-Status if partially successful, 500 if completely failed
|
||||
return JSONResponse({
|
||||
"success": final_success,
|
||||
"message": error_message,
|
||||
"reset_count": reset_count
|
||||
}, status_code=status_code)
|
||||
|
||||
# 完全成功的情况
|
||||
return JSONResponse({
|
||||
"success": True,
|
||||
"message": f"成功重置 {reset_count} 个选定 {key_type} 密钥的失败计数",
|
||||
"reset_count": reset_count
|
||||
})
|
||||
except Exception as e:
|
||||
# 捕获循环外的意外错误
|
||||
logger.error(f"Failed to process reset selected key failure counts request: {str(e)}")
|
||||
return JSONResponse({"success": False, "message": f"批量重置处理失败: {str(e)}"}, status_code=500)
|
||||
|
||||
|
||||
|
||||
@router.post("/reset-fail-count/{api_key}")
|
||||
async def reset_key_fail_count(api_key: str, key_manager: KeyManager = Depends(get_key_manager)):
|
||||
"""重置指定Gemini API密钥的失败计数"""
|
||||
logger.info("-" * 50 + "reset_gemini_key_fail_count" + "-" * 50)
|
||||
logger.info(f"Resetting failure count for API key: {api_key}")
|
||||
|
||||
try:
|
||||
result = await key_manager.reset_key_failure_count(api_key)
|
||||
if result:
|
||||
return JSONResponse({"success": True, "message": "失败计数已重置"})
|
||||
return JSONResponse({"success": False, "message": "未找到指定密钥"}, status_code=404)
|
||||
except Exception as e:
|
||||
logger.error(f"Failed to reset key failure count: {str(e)}")
|
||||
return JSONResponse({"success": False, "message": f"重置失败: {str(e)}"}, status_code=500)
|
||||
|
||||
@router.post("/verify-key/{api_key}")
|
||||
async def verify_key(api_key: str):
|
||||
async def verify_key(api_key: str, chat_service: GeminiChatService = Depends(get_chat_service), key_manager: KeyManager = Depends(get_key_manager)):
|
||||
"""验证Gemini API密钥的有效性"""
|
||||
logger.info("-" * 50 + "verify_gemini_key" + "-" * 50)
|
||||
logger.info("Verifying API key validity")
|
||||
|
||||
try:
|
||||
key_manager = await get_key_manager()
|
||||
chat_service = GeminiChatService(settings.BASE_URL, key_manager)
|
||||
|
||||
# 使用generate_content接口测试key的有效性
|
||||
gemini_request = GeminiRequest(
|
||||
contents=[
|
||||
@@ -166,13 +276,108 @@ async def verify_key(api_key: str):
|
||||
|
||||
response = await chat_service.generate_content(
|
||||
settings.TEST_MODEL,
|
||||
gemini_request,
|
||||
gemini_request,
|
||||
api_key
|
||||
)
|
||||
|
||||
if response:
|
||||
return JSONResponse({"status": "valid"})
|
||||
return JSONResponse({"status": "invalid"})
|
||||
return JSONResponse({"status": "valid"})
|
||||
except Exception as e:
|
||||
logger.error(f"Key verification failed: {str(e)}")
|
||||
return JSONResponse({"status": "invalid", "error": str(e)})
|
||||
|
||||
# 验证出现异常时增加失败计数
|
||||
async with key_manager.failure_count_lock:
|
||||
if api_key in key_manager.key_failure_counts:
|
||||
key_manager.key_failure_counts[api_key] += 1
|
||||
logger.warning(f"Verification exception for key: {api_key}, incrementing failure count")
|
||||
|
||||
return JSONResponse({"status": "invalid", "error": str(e)})
|
||||
|
||||
|
||||
@router.post("/verify-selected-keys")
|
||||
async def verify_selected_keys(
|
||||
request: VerifySelectedKeysRequest,
|
||||
chat_service: GeminiChatService = Depends(get_chat_service),
|
||||
key_manager: KeyManager = Depends(get_key_manager)
|
||||
):
|
||||
"""批量验证选定Gemini API密钥的有效性"""
|
||||
logger.info("-" * 50 + "verify_selected_gemini_keys" + "-" * 50)
|
||||
keys_to_verify = request.keys
|
||||
logger.info(f"Received verification request for {len(keys_to_verify)} selected keys.")
|
||||
|
||||
if not keys_to_verify:
|
||||
return JSONResponse({"success": False, "message": "没有提供需要验证的密钥"}, status_code=400)
|
||||
|
||||
valid_count = 0
|
||||
invalid_count = 0
|
||||
verification_errors = {} # 存储验证过程中的错误
|
||||
|
||||
async def _verify_single_key(api_key: str):
|
||||
"""内部函数,用于验证单个密钥并处理异常"""
|
||||
nonlocal valid_count, invalid_count # 允许修改外部计数器
|
||||
try:
|
||||
# 重用单密钥验证逻辑的核心部分
|
||||
gemini_request = GeminiRequest(
|
||||
contents=[GeminiContent(role="user", parts=[{"text": "hi"}])]
|
||||
)
|
||||
# 注意:这里直接调用 chat_service.generate_content,不依赖于 key_manager 获取密钥
|
||||
await chat_service.generate_content(
|
||||
settings.TEST_MODEL,
|
||||
gemini_request,
|
||||
api_key
|
||||
)
|
||||
# 如果上面没有抛出异常,则认为密钥有效
|
||||
valid_count += 1
|
||||
return api_key, "valid", None
|
||||
except Exception as e:
|
||||
error_message = str(e)
|
||||
logger.warning(f"Key verification failed for {api_key}: {error_message}")
|
||||
# 验证失败时增加失败计数 (使用与 /verify-key 一致的逻辑)
|
||||
async with key_manager.failure_count_lock:
|
||||
if api_key in key_manager.key_failure_counts:
|
||||
key_manager.key_failure_counts[api_key] += 1
|
||||
logger.warning(f"Bulk verification exception for key: {api_key}, incrementing failure count")
|
||||
else:
|
||||
# 如果密钥不在计数中(可能刚添加或从未失败),初始化为1
|
||||
key_manager.key_failure_counts[api_key] = 1
|
||||
logger.warning(f"Bulk verification exception for key: {api_key}, initializing failure count to 1")
|
||||
invalid_count += 1
|
||||
return api_key, "invalid", error_message
|
||||
|
||||
# 并发执行所有密钥的验证
|
||||
tasks = [_verify_single_key(key) for key in keys_to_verify]
|
||||
results = await asyncio.gather(*tasks, return_exceptions=True) # return_exceptions=True 捕获任务本身的异常
|
||||
|
||||
# 处理并发执行的结果
|
||||
for result in results:
|
||||
if isinstance(result, Exception):
|
||||
# 捕获 asyncio.gather 可能遇到的异常(例如任务被取消)
|
||||
logger.error(f"An unexpected error occurred during bulk verification task: {result}")
|
||||
# 可以选择如何处理这种任务级别的错误,这里我们简单记录
|
||||
# 也可以将其计入 invalid_count 或单独记录
|
||||
elif result:
|
||||
key, status, error = result
|
||||
if status == "invalid" and error:
|
||||
verification_errors[key] = error # 记录具体的验证错误信息
|
||||
|
||||
logger.info(f"Bulk verification finished. Valid: {valid_count}, Invalid: {invalid_count}")
|
||||
|
||||
# 根据是否有错误决定最终消息和状态
|
||||
if verification_errors or valid_count + invalid_count != len(keys_to_verify): # 检查是否有错误或任务异常
|
||||
error_summary = "; ".join([f"{k}: {v}" for k, v in verification_errors.items()])
|
||||
message = f"批量验证完成,但出现问题。有效: {valid_count}, 无效: {invalid_count}。错误详情: {error_summary or '任务执行异常'}"
|
||||
return JSONResponse({
|
||||
"success": False, # 标记为失败,因为有错误
|
||||
"message": message,
|
||||
"valid_count": valid_count,
|
||||
"invalid_count": invalid_count,
|
||||
"errors": verification_errors
|
||||
}, status_code=207) # 207 Multi-Status 表示部分成功/失败
|
||||
else:
|
||||
# 完全成功
|
||||
return JSONResponse({
|
||||
"success": True,
|
||||
"message": f"批量验证成功完成。有效: {valid_count}, 无效: {invalid_count}",
|
||||
"valid_count": valid_count,
|
||||
"invalid_count": invalid_count
|
||||
})
|
||||
125
app/router/log_routes.py
Normal file
@@ -0,0 +1,125 @@
|
||||
"""
|
||||
日志路由模块
|
||||
"""
|
||||
from typing import List, Optional
|
||||
from datetime import datetime
|
||||
from pydantic import BaseModel
|
||||
from fastapi import APIRouter, HTTPException, Request, Query, Path
|
||||
|
||||
from app.core.security import verify_auth_token
|
||||
from app.log.logger import get_log_routes_logger
|
||||
# 假设这些服务函数已更新或添加
|
||||
from app.database.services import get_error_logs, get_error_logs_count, get_error_log_details
|
||||
|
||||
# 创建路由
|
||||
router = APIRouter(prefix="/api/logs", tags=["logs"])
|
||||
|
||||
logger = get_log_routes_logger()
|
||||
|
||||
|
||||
# Define a response model that includes the total count for pagination
|
||||
# 用于列表响应的模型,假设 get_error_logs 返回包含 error_code 的字典
|
||||
class ErrorLogListItem(BaseModel):
|
||||
id: int
|
||||
gemini_key: Optional[str] = None
|
||||
error_type: Optional[str] = None
|
||||
error_code: Optional[int] = None # 列表显示错误码 (应为整数)
|
||||
model_name: Optional[str] = None
|
||||
request_time: Optional[datetime] = None
|
||||
|
||||
class ErrorLogListResponse(BaseModel):
|
||||
logs: List[ErrorLogListItem] # 使用定义的模型列表
|
||||
total: int
|
||||
|
||||
@router.get("/errors", response_model=ErrorLogListResponse)
|
||||
async def get_error_logs_api(
|
||||
request: Request,
|
||||
limit: int = Query(10, ge=1, le=1000),
|
||||
offset: int = Query(0, ge=0),
|
||||
key_search: Optional[str] = Query(None, description="Search term for Gemini key (partial match)"),
|
||||
error_search: Optional[str] = Query(None, description="Search term for error type or log message"), # 数据库查询需处理
|
||||
start_date: Optional[datetime] = Query(None, description="Start datetime for filtering"),
|
||||
end_date: Optional[datetime] = Query(None, description="End datetime for filtering")
|
||||
):
|
||||
"""
|
||||
获取错误日志列表 (返回错误码)
|
||||
|
||||
Args:
|
||||
request: 请求对象
|
||||
limit: 限制数量
|
||||
offset: 偏移量
|
||||
key_search: 密钥搜索
|
||||
error_search: 错误搜索 (可能搜索类型或日志内容,由DB层决定)
|
||||
start_date: 开始日期
|
||||
end_date: 结束日期
|
||||
|
||||
Returns:
|
||||
ErrorLogListResponse: An object containing the list of logs (with error_code) and the total count.
|
||||
"""
|
||||
auth_token = request.cookies.get("auth_token")
|
||||
if not auth_token or not verify_auth_token(auth_token):
|
||||
logger.warning("Unauthorized access attempt to error logs list")
|
||||
# API 返回 401 更合适
|
||||
raise HTTPException(status_code=401, detail="Not authenticated")
|
||||
|
||||
try:
|
||||
# 假设 get_error_logs 现在返回包含 error_code 的字典列表
|
||||
# 并且可以接受 include_error_code 参数 (如果需要显式指定)
|
||||
logs_data = await get_error_logs(
|
||||
limit=limit,
|
||||
offset=offset,
|
||||
key_search=key_search,
|
||||
error_search=error_search, # 数据库查询需要处理这个
|
||||
start_date=start_date,
|
||||
end_date=end_date,
|
||||
# include_error_code=True # 如果需要显式传递
|
||||
)
|
||||
# Fetch total count with the same search parameters
|
||||
total_count = await get_error_logs_count(
|
||||
key_search=key_search,
|
||||
error_search=error_search,
|
||||
start_date=start_date,
|
||||
end_date=end_date
|
||||
)
|
||||
# 验证并转换数据以匹配 Pydantic 模型
|
||||
validated_logs = [ErrorLogListItem(**log) for log in logs_data]
|
||||
return ErrorLogListResponse(logs=validated_logs, total=total_count)
|
||||
except Exception as e:
|
||||
logger.exception(f"Failed to get error logs list: {str(e)}")
|
||||
raise HTTPException(status_code=500, detail=f"Failed to get error logs list: {str(e)}")
|
||||
|
||||
|
||||
# 新增:获取错误日志详情的路由
|
||||
class ErrorLogDetailResponse(BaseModel):
|
||||
id: int
|
||||
gemini_key: Optional[str] = None
|
||||
error_type: Optional[str] = None
|
||||
error_log: Optional[str] = None # 详情接口返回完整的 error_log
|
||||
request_msg: Optional[str] = None # 详情接口返回 request_msg
|
||||
model_name: Optional[str] = None
|
||||
request_time: Optional[datetime] = None
|
||||
|
||||
@router.get("/errors/{log_id}/details", response_model=ErrorLogDetailResponse)
|
||||
async def get_error_log_detail_api(request: Request, log_id: int = Path(..., ge=1)):
|
||||
"""
|
||||
根据日志 ID 获取错误日志的详细信息 (包括 error_log 和 request_msg)
|
||||
"""
|
||||
auth_token = request.cookies.get("auth_token")
|
||||
if not auth_token or not verify_auth_token(auth_token):
|
||||
logger.warning(f"Unauthorized access attempt to error log details for ID: {log_id}")
|
||||
raise HTTPException(status_code=401, detail="Not authenticated")
|
||||
|
||||
try:
|
||||
# 假设存在一个函数 get_error_log_details(log_id) 来获取完整信息
|
||||
log_details = await get_error_log_details(log_id=log_id)
|
||||
if not log_details:
|
||||
raise HTTPException(status_code=404, detail="Error log not found")
|
||||
|
||||
# 假设 get_error_log_details 返回一个字典或兼容 Pydantic 的对象
|
||||
return ErrorLogDetailResponse(**log_details)
|
||||
except HTTPException as http_exc:
|
||||
# Re-raise HTTPException (like 404)
|
||||
raise http_exc
|
||||
except Exception as e:
|
||||
logger.exception(f"Failed to get error log details for ID {log_id}: {str(e)}")
|
||||
raise HTTPException(status_code=500, detail=f"Failed to get error log details: {str(e)}")
|
||||
@@ -20,9 +20,9 @@ router = APIRouter()
|
||||
logger = get_openai_logger()
|
||||
|
||||
# 初始化服务
|
||||
security_service = SecurityService(settings.ALLOWED_TOKENS, settings.AUTH_TOKEN)
|
||||
model_service = ModelService(settings.SEARCH_MODELS, settings.IMAGE_MODELS)
|
||||
embedding_service = EmbeddingService(settings.BASE_URL)
|
||||
security_service = SecurityService()
|
||||
model_service = ModelService()
|
||||
embedding_service = EmbeddingService()
|
||||
image_create_service = ImageCreateService()
|
||||
|
||||
|
||||
@@ -36,6 +36,11 @@ async def get_next_working_key_wrapper(
|
||||
return await key_manager.get_next_working_key()
|
||||
|
||||
|
||||
async def get_openai_chat_service(key_manager: KeyManager = Depends(get_key_manager)):
|
||||
"""获取OpenAI聊天服务实例"""
|
||||
return OpenAIChatService(settings.BASE_URL, key_manager)
|
||||
|
||||
|
||||
@router.get("/v1/models")
|
||||
@router.get("/hf/v1/models")
|
||||
async def list_models(
|
||||
@@ -44,7 +49,7 @@ async def list_models(
|
||||
):
|
||||
logger.info("-" * 50 + "list_models" + "-" * 50)
|
||||
logger.info("Handling models list request")
|
||||
api_key = await key_manager.get_next_working_key()
|
||||
api_key = await key_manager.get_first_valid_key()
|
||||
logger.info(f"Using API key: {api_key}")
|
||||
try:
|
||||
return model_service.get_gemini_openai_models(api_key)
|
||||
@@ -57,17 +62,17 @@ async def list_models(
|
||||
|
||||
@router.post("/v1/chat/completions")
|
||||
@router.post("/hf/v1/chat/completions")
|
||||
@RetryHandler(max_retries=3, key_arg="api_key")
|
||||
@RetryHandler(max_retries=settings.MAX_RETRIES, key_arg="api_key")
|
||||
async def chat_completion(
|
||||
request: ChatRequest,
|
||||
_=Depends(security_service.verify_authorization),
|
||||
api_key: str = Depends(get_next_working_key_wrapper),
|
||||
key_manager: KeyManager = Depends(get_key_manager),
|
||||
key_manager: KeyManager = Depends(get_key_manager), # 保留 key_manager 用于获取 paid_key
|
||||
chat_service: OpenAIChatService = Depends(get_openai_chat_service),
|
||||
):
|
||||
# 如果model是imagen3,使用paid_key
|
||||
if request.model == f"{settings.CREATE_IMAGE_MODEL}-chat":
|
||||
api_key = await key_manager.get_paid_key()
|
||||
chat_service = OpenAIChatService(settings.BASE_URL, key_manager)
|
||||
logger.info("-" * 50 + "chat_completion" + "-" * 50)
|
||||
logger.info(f"Handling chat completion request for model: {request.model}")
|
||||
logger.info(f"Request: \n{request.model_dump_json(indent=2)}")
|
||||
|
||||
@@ -8,8 +8,9 @@ from fastapi.templating import Jinja2Templates
|
||||
|
||||
from app.core.security import verify_auth_token
|
||||
from app.log.logger import get_routes_logger
|
||||
from app.router import gemini_routes, openai_routes
|
||||
from app.router import gemini_routes, openai_routes, config_routes, log_routes, scheduler_routes # 新增导入
|
||||
from app.service.key.key_manager import get_key_manager_instance
|
||||
from app.service.stats_service import get_api_usage_stats, get_api_call_details # <-- Import stats service and details function
|
||||
|
||||
logger = get_routes_logger()
|
||||
|
||||
@@ -28,12 +29,16 @@ def setup_routers(app: FastAPI) -> None:
|
||||
app.include_router(openai_routes.router)
|
||||
app.include_router(gemini_routes.router)
|
||||
app.include_router(gemini_routes.router_v1beta)
|
||||
app.include_router(config_routes.router)
|
||||
app.include_router(log_routes.router)
|
||||
app.include_router(scheduler_routes.router) # 新增包含 scheduler 路由
|
||||
|
||||
# 添加页面路由
|
||||
setup_page_routes(app)
|
||||
|
||||
# 添加健康检查路由
|
||||
setup_health_routes(app)
|
||||
setup_api_stats_routes(app) # Add API stats routes
|
||||
|
||||
|
||||
def setup_page_routes(app: FastAPI) -> None:
|
||||
@@ -61,7 +66,7 @@ def setup_page_routes(app: FastAPI) -> None:
|
||||
|
||||
if verify_auth_token(auth_token):
|
||||
logger.info("Successful authentication")
|
||||
response = RedirectResponse(url="/keys", status_code=302)
|
||||
response = RedirectResponse(url="/config", status_code=302)
|
||||
response.set_cookie(
|
||||
key="auth_token", value=auth_token, httponly=True, max_age=3600
|
||||
)
|
||||
@@ -83,19 +88,61 @@ def setup_page_routes(app: FastAPI) -> None:
|
||||
|
||||
key_manager = await get_key_manager_instance()
|
||||
keys_status = await key_manager.get_keys_by_status()
|
||||
total = len(keys_status["valid_keys"]) + len(keys_status["invalid_keys"])
|
||||
logger.info(f"Keys status retrieved successfully. Total keys: {total}")
|
||||
total_keys = len(keys_status["valid_keys"]) + len(keys_status["invalid_keys"])
|
||||
valid_key_count = len(keys_status["valid_keys"])
|
||||
invalid_key_count = len(keys_status["invalid_keys"])
|
||||
|
||||
# Get API usage stats
|
||||
api_stats = await get_api_usage_stats()
|
||||
logger.info(f"API stats retrieved: {api_stats}")
|
||||
|
||||
logger.info(f"Keys status retrieved successfully. Total keys: {total_keys}")
|
||||
return templates.TemplateResponse(
|
||||
"keys_status.html",
|
||||
{
|
||||
"request": request,
|
||||
"valid_keys": keys_status["valid_keys"],
|
||||
"invalid_keys": keys_status["invalid_keys"],
|
||||
"total": total,
|
||||
"total_keys": total_keys, # Renamed for clarity
|
||||
"valid_key_count": valid_key_count, # Added count
|
||||
"invalid_key_count": invalid_key_count, # Added count
|
||||
"api_stats": api_stats, # <-- Pass stats to template
|
||||
},
|
||||
)
|
||||
except Exception as e:
|
||||
logger.error(f"Error retrieving keys status: {str(e)}")
|
||||
logger.error(f"Error retrieving keys status or API stats: {str(e)}")
|
||||
# Optionally, render template with error or default stats
|
||||
# For now, re-raise to show error page
|
||||
raise
|
||||
|
||||
@app.get("/config", response_class=HTMLResponse)
|
||||
async def config_page(request: Request):
|
||||
"""配置编辑页面"""
|
||||
try:
|
||||
auth_token = request.cookies.get("auth_token")
|
||||
if not auth_token or not verify_auth_token(auth_token):
|
||||
logger.warning("Unauthorized access attempt to config page")
|
||||
return RedirectResponse(url="/", status_code=302)
|
||||
|
||||
logger.info("Config page accessed successfully")
|
||||
return templates.TemplateResponse("config_editor.html", {"request": request})
|
||||
except Exception as e:
|
||||
logger.error(f"Error accessing config page: {str(e)}")
|
||||
raise
|
||||
|
||||
@app.get("/logs", response_class=HTMLResponse)
|
||||
async def logs_page(request: Request):
|
||||
"""错误日志页面"""
|
||||
try:
|
||||
auth_token = request.cookies.get("auth_token")
|
||||
if not auth_token or not verify_auth_token(auth_token):
|
||||
logger.warning("Unauthorized access attempt to logs page")
|
||||
return RedirectResponse(url="/", status_code=302)
|
||||
|
||||
logger.info("Logs page accessed successfully")
|
||||
return templates.TemplateResponse("error_logs.html", {"request": request})
|
||||
except Exception as e:
|
||||
logger.error(f"Error accessing logs page: {str(e)}")
|
||||
raise
|
||||
|
||||
|
||||
@@ -112,3 +159,32 @@ def setup_health_routes(app: FastAPI) -> None:
|
||||
"""健康检查端点"""
|
||||
logger.info("Health check endpoint called")
|
||||
return {"status": "healthy"}
|
||||
|
||||
|
||||
def setup_api_stats_routes(app: FastAPI) -> None:
|
||||
"""
|
||||
设置 API 统计相关的路由
|
||||
|
||||
Args:
|
||||
app: FastAPI应用程序实例
|
||||
"""
|
||||
@app.get("/api/stats/details")
|
||||
async def api_stats_details(request: Request, period: str):
|
||||
"""获取指定时间段内的 API 调用详情"""
|
||||
try:
|
||||
# 验证认证
|
||||
auth_token = request.cookies.get("auth_token")
|
||||
if not auth_token or not verify_auth_token(auth_token):
|
||||
logger.warning("Unauthorized access attempt to API stats details")
|
||||
# Returning JSON error instead of redirect for API endpoint
|
||||
return {"error": "Unauthorized"}, 401
|
||||
|
||||
logger.info(f"Fetching API call details for period: {period}")
|
||||
details = await get_api_call_details(period)
|
||||
return details
|
||||
except ValueError as e:
|
||||
logger.warning(f"Invalid period requested for API stats details: {period} - {str(e)}")
|
||||
return {"error": str(e)}, 400
|
||||
except Exception as e:
|
||||
logger.error(f"Error fetching API stats details for period {period}: {str(e)}")
|
||||
return {"error": "Internal server error"}, 500
|
||||
|
||||
63
app/router/scheduler_routes.py
Normal file
@@ -0,0 +1,63 @@
|
||||
"""
|
||||
定时任务控制路由模块
|
||||
"""
|
||||
|
||||
from fastapi import APIRouter, Request, HTTPException, status # 移除 Depends, 添加 Request
|
||||
from fastapi.responses import JSONResponse
|
||||
|
||||
from app.core.security import verify_auth_token # 导入 verify_auth_token
|
||||
from app.scheduler.key_checker import start_scheduler, stop_scheduler
|
||||
from app.log.logger import get_routes_logger # 使用路由日志记录器
|
||||
|
||||
logger = get_routes_logger()
|
||||
|
||||
router = APIRouter(
|
||||
prefix="/api/scheduler",
|
||||
tags=["Scheduler"]
|
||||
# 移除全局依赖
|
||||
)
|
||||
|
||||
# 认证检查的辅助函数
|
||||
async def verify_token(request: Request):
|
||||
auth_token = request.cookies.get("auth_token")
|
||||
if not auth_token or not verify_auth_token(auth_token):
|
||||
logger.warning("Unauthorized access attempt to scheduler API")
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_401_UNAUTHORIZED,
|
||||
detail="Not authenticated",
|
||||
headers={"WWW-Authenticate": "Bearer"},
|
||||
)
|
||||
|
||||
@router.post("/start", summary="启动定时任务")
|
||||
async def start_scheduler_endpoint(request: Request): # 添加 request 参数
|
||||
"""Start the background scheduler task"""
|
||||
"""
|
||||
await verify_token(request) # 在函数开始处进行认证检查
|
||||
"""
|
||||
try:
|
||||
logger.info("Received request to start scheduler.")
|
||||
start_scheduler() # 调用 key_checker 中的函数
|
||||
return JSONResponse(content={"message": "Scheduler started successfully."}, status_code=status.HTTP_200_OK)
|
||||
except Exception as e:
|
||||
logger.error(f"Error starting scheduler: {str(e)}", exc_info=True)
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
||||
detail=f"Failed to start scheduler: {str(e)}"
|
||||
)
|
||||
|
||||
@router.post("/stop", summary="停止定时任务")
|
||||
async def stop_scheduler_endpoint(request: Request): # 添加 request 参数
|
||||
"""Stop the background scheduler task"""
|
||||
"""
|
||||
await verify_token(request) # 在函数开始处进行认证检查
|
||||
"""
|
||||
try:
|
||||
logger.info("Received request to stop scheduler.")
|
||||
stop_scheduler() # 调用 key_checker 中的函数
|
||||
return JSONResponse(content={"message": "Scheduler stopped successfully."}, status_code=status.HTTP_200_OK)
|
||||
except Exception as e:
|
||||
logger.error(f"Error stopping scheduler: {str(e)}", exc_info=True)
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
||||
detail=f"Failed to stop scheduler: {str(e)}"
|
||||
)
|
||||
100
app/scheduler/key_checker.py
Normal file
@@ -0,0 +1,100 @@
|
||||
from apscheduler.schedulers.asyncio import AsyncIOScheduler
|
||||
from app.service.key.key_manager import get_key_manager_instance
|
||||
from app.service.chat.gemini_chat_service import GeminiChatService
|
||||
from app.domain.gemini_models import GeminiRequest, GeminiContent
|
||||
from app.config.config import settings
|
||||
from app.log.logger import Logger # 导入 Logger 类
|
||||
|
||||
logger = Logger.setup_logger("scheduler") # 使用 Logger.setup_logger
|
||||
|
||||
async def check_failed_keys():
|
||||
"""
|
||||
定时检查失败次数大于0的API密钥,并尝试验证它们。
|
||||
如果验证成功,重置失败计数;如果失败,增加失败计数。
|
||||
"""
|
||||
logger.info("Starting scheduled check for failed API keys...")
|
||||
try:
|
||||
key_manager = await get_key_manager_instance()
|
||||
# 确保 KeyManager 已经初始化
|
||||
if not key_manager or not hasattr(key_manager, 'key_failure_counts'):
|
||||
logger.warning("KeyManager instance not available or not initialized. Skipping check.")
|
||||
return
|
||||
|
||||
# 创建 GeminiChatService 实例用于验证
|
||||
# 注意:这里直接创建实例,而不是通过依赖注入,因为这是后台任务
|
||||
chat_service = GeminiChatService(settings.BASE_URL, key_manager)
|
||||
|
||||
# 获取需要检查的 key 列表 (失败次数 > 0)
|
||||
keys_to_check = []
|
||||
async with key_manager.failure_count_lock: # 访问共享数据需要加锁
|
||||
# 复制一份以避免在迭代时修改字典
|
||||
failure_counts_copy = key_manager.key_failure_counts.copy()
|
||||
keys_to_check = [key for key, count in failure_counts_copy.items() if count > 0] # 检查所有失败次数大于0的key
|
||||
|
||||
if not keys_to_check:
|
||||
logger.info("No keys with failure count > 0 found. Skipping verification.")
|
||||
return
|
||||
|
||||
logger.info(f"Found {len(keys_to_check)} keys with failure count > 0 to verify.")
|
||||
|
||||
for key in keys_to_check:
|
||||
# 隐藏部分 key 用于日志记录
|
||||
log_key = f"{key[:4]}...{key[-4:]}" if len(key) > 8 else key
|
||||
logger.info(f"Verifying key: {log_key}...")
|
||||
try:
|
||||
# 构造测试请求
|
||||
gemini_request = GeminiRequest(
|
||||
contents=[
|
||||
GeminiContent(
|
||||
role="user",
|
||||
parts=[{"text": "hi"}] # 使用简单的文本进行验证
|
||||
)
|
||||
]
|
||||
)
|
||||
# 调用 generate_content 进行验证
|
||||
await chat_service.generate_content(
|
||||
settings.TEST_MODEL, # 使用配置中定义的测试模型
|
||||
gemini_request,
|
||||
key
|
||||
)
|
||||
# 如果没有抛出异常,说明 key 有效
|
||||
logger.info(f"Key {log_key} verification successful. Resetting failure count.")
|
||||
await key_manager.reset_key_failure_count(key)
|
||||
except Exception as e:
|
||||
# 验证失败,增加失败计数
|
||||
logger.warning(f"Key {log_key} verification failed: {str(e)}. Incrementing failure count.")
|
||||
# 直接操作计数器,需要加锁
|
||||
async with key_manager.failure_count_lock:
|
||||
# 再次检查 key 是否存在且失败次数未达上限
|
||||
if key in key_manager.key_failure_counts and key_manager.key_failure_counts[key] < key_manager.MAX_FAILURES:
|
||||
key_manager.key_failure_counts[key] += 1
|
||||
logger.info(f"Failure count for key {log_key} incremented to {key_manager.key_failure_counts[key]}.")
|
||||
elif key in key_manager.key_failure_counts:
|
||||
logger.warning(f"Key {log_key} reached MAX_FAILURES ({key_manager.MAX_FAILURES}). Not incrementing further.")
|
||||
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"An error occurred during the scheduled key check: {str(e)}", exc_info=True)
|
||||
|
||||
def setup_scheduler():
|
||||
"""设置并启动 APScheduler"""
|
||||
scheduler = AsyncIOScheduler(timezone=str(settings.TIMEZONE)) # 从配置读取时区
|
||||
# 添加定时任务,例如每小时执行一次 (可以调整)
|
||||
scheduler.add_job(check_failed_keys, 'interval', hours=settings.CHECK_INTERVAL_HOURS)
|
||||
scheduler.start()
|
||||
logger.info(f"Scheduler started. Key check job scheduled to run every {settings.CHECK_INTERVAL_HOURS} hour(s).")
|
||||
return scheduler
|
||||
|
||||
# 可以在这里添加一个全局的 scheduler 实例,以便在应用关闭时优雅地停止
|
||||
scheduler_instance = None
|
||||
|
||||
def start_scheduler():
|
||||
global scheduler_instance
|
||||
if scheduler_instance is None:
|
||||
scheduler_instance = setup_scheduler()
|
||||
|
||||
def stop_scheduler():
|
||||
global scheduler_instance
|
||||
if scheduler_instance and scheduler_instance.running:
|
||||
scheduler_instance.shutdown()
|
||||
logger.info("Scheduler stopped.")
|
||||
@@ -1,8 +1,10 @@
|
||||
# app/services/chat_service.py
|
||||
|
||||
import json
|
||||
import re
|
||||
import datetime # Add datetime import
|
||||
import time # Add time import
|
||||
from typing import Any, AsyncGenerator, Dict, List
|
||||
|
||||
from app.config.config import settings
|
||||
from app.domain.gemini_models import GeminiRequest
|
||||
from app.handler.response_handler import GeminiResponseHandler
|
||||
@@ -10,6 +12,7 @@ from app.handler.stream_optimizer import gemini_optimizer
|
||||
from app.log.logger import get_gemini_logger
|
||||
from app.service.client.api_client import GeminiApiClient
|
||||
from app.service.key.key_manager import KeyManager
|
||||
from app.database.services import add_error_log, add_request_log # Import add_request_log
|
||||
|
||||
logger = get_gemini_logger()
|
||||
|
||||
@@ -44,6 +47,8 @@ def _build_tools(model: str, payload: Dict[str, Any]) -> List[Dict[str, Any]]:
|
||||
|
||||
tool = dict()
|
||||
if payload and isinstance(payload, dict) and "tools" in payload:
|
||||
if payload.get("tools") and isinstance(payload.get("tools"), dict):
|
||||
payload["tools"] = [payload.get("tools")]
|
||||
items = payload.get("tools", [])
|
||||
if items and isinstance(items, list):
|
||||
tool.update(_merge_tools(items))
|
||||
@@ -87,6 +92,11 @@ def _get_safety_settings(model: str) -> List[Dict[str, str]]:
|
||||
def _build_payload(model: str, request: GeminiRequest) -> Dict[str, Any]:
|
||||
"""构建请求payload"""
|
||||
request_dict = request.model_dump()
|
||||
if request.generationConfig:
|
||||
if request.generationConfig.maxOutputTokens is None:
|
||||
# 如果未指定最大输出长度,则不传递该字段,解决截断的问题
|
||||
request_dict["generationConfig"].pop("maxOutputTokens")
|
||||
|
||||
payload = {
|
||||
"contents": request_dict.get("contents", []),
|
||||
"tools": _build_tools(model, request_dict),
|
||||
@@ -105,7 +115,7 @@ class GeminiChatService:
|
||||
"""聊天服务"""
|
||||
|
||||
def __init__(self, base_url: str, key_manager: KeyManager):
|
||||
self.api_client = GeminiApiClient(base_url)
|
||||
self.api_client = GeminiApiClient(base_url, settings.TIME_OUT)
|
||||
self.key_manager = key_manager
|
||||
self.response_handler = GeminiResponseHandler()
|
||||
|
||||
@@ -138,54 +148,152 @@ class GeminiChatService:
|
||||
) -> Dict[str, Any]:
|
||||
"""生成内容"""
|
||||
payload = _build_payload(model, request)
|
||||
response = await self.api_client.generate_content(payload, model, api_key)
|
||||
return self.response_handler.handle_response(response, model, stream=False)
|
||||
start_time = time.perf_counter()
|
||||
request_datetime = datetime.datetime.now() # Record request time
|
||||
is_success = False
|
||||
status_code = None
|
||||
response = None
|
||||
|
||||
try:
|
||||
response = await self.api_client.generate_content(payload, model, api_key)
|
||||
# Assuming success if no exception is raised and response is received
|
||||
# The actual status code might be within the response structure or headers,
|
||||
# but api_client doesn't seem to expose it directly here.
|
||||
# We'll assume 200 for success if no exception.
|
||||
is_success = True
|
||||
status_code = 200 # Assume 200 on success
|
||||
return self.response_handler.handle_response(response, model, stream=False)
|
||||
except Exception as e:
|
||||
is_success = False
|
||||
error_log_msg = str(e)
|
||||
logger.error(f"Normal API call failed with error: {error_log_msg}")
|
||||
# Try to parse status code from exception
|
||||
match = re.search(r"status code (\d+)", error_log_msg)
|
||||
if match:
|
||||
status_code = int(match.group(1))
|
||||
else:
|
||||
status_code = 500 # Default to 500 if parsing fails
|
||||
|
||||
# Log error to error log table
|
||||
await add_error_log(
|
||||
gemini_key=api_key,
|
||||
model_name=model,
|
||||
error_type="gemini_chat_service",
|
||||
error_log=error_log_msg,
|
||||
error_code=status_code,
|
||||
request_msg=payload
|
||||
)
|
||||
raise e # Re-throw exception for upstream handling
|
||||
finally:
|
||||
end_time = time.perf_counter()
|
||||
latency_ms = int((end_time - start_time) * 1000)
|
||||
# Log request to request log table
|
||||
await add_request_log(
|
||||
model_name=model,
|
||||
api_key=api_key,
|
||||
is_success=is_success,
|
||||
status_code=status_code,
|
||||
latency_ms=latency_ms,
|
||||
request_time=request_datetime
|
||||
)
|
||||
|
||||
async def stream_generate_content(
|
||||
self, model: str, request: GeminiRequest, api_key: str
|
||||
) -> AsyncGenerator[str, None]:
|
||||
"""流式生成内容"""
|
||||
retries = 0
|
||||
max_retries = 3
|
||||
max_retries = settings.MAX_RETRIES
|
||||
payload = _build_payload(model, request)
|
||||
while retries < max_retries:
|
||||
try:
|
||||
async for line in self.api_client.stream_generate_content(
|
||||
payload, model, api_key
|
||||
):
|
||||
# print(line)
|
||||
if line.startswith("data:"):
|
||||
line = line[6:]
|
||||
response_data = self.response_handler.handle_response(
|
||||
json.loads(line), model, stream=True
|
||||
)
|
||||
text = self._extract_text_from_response(response_data)
|
||||
start_time = time.perf_counter() # Record start time before loop
|
||||
request_datetime = datetime.datetime.now()
|
||||
is_success = False
|
||||
status_code = None
|
||||
final_api_key = api_key # Store the initial key
|
||||
|
||||
# 如果有文本内容,使用流式输出优化器处理
|
||||
if text:
|
||||
# 使用流式输出优化器处理文本输出
|
||||
async for (
|
||||
optimized_chunk
|
||||
) in gemini_optimizer.optimize_stream_output(
|
||||
text,
|
||||
lambda t: self._create_char_response(response_data, t),
|
||||
lambda c: "data: " + json.dumps(c) + "\n\n",
|
||||
):
|
||||
yield optimized_chunk
|
||||
else:
|
||||
# 如果没有文本内容(如工具调用等),整块输出
|
||||
yield "data: " + json.dumps(response_data) + "\n\n"
|
||||
logger.info("Streaming completed successfully")
|
||||
break
|
||||
except Exception as e:
|
||||
retries += 1
|
||||
logger.warning(
|
||||
f"Streaming API call failed with error: {str(e)}. Attempt {retries} of {max_retries}"
|
||||
)
|
||||
api_key = await self.key_manager.handle_api_failure(api_key)
|
||||
logger.info(f"Switched to new API key: {api_key}")
|
||||
if retries >= max_retries:
|
||||
logger.error(
|
||||
f"Max retries ({max_retries}) reached for streaming. Raising error"
|
||||
try:
|
||||
while retries < max_retries:
|
||||
current_attempt_key = api_key # Key used for this attempt
|
||||
final_api_key = current_attempt_key # Update final key used
|
||||
try:
|
||||
async for line in self.api_client.stream_generate_content(
|
||||
payload, model, current_attempt_key
|
||||
):
|
||||
# print(line)
|
||||
if line.startswith("data:"):
|
||||
line = line[6:]
|
||||
response_data = self.response_handler.handle_response(
|
||||
json.loads(line), model, stream=True
|
||||
)
|
||||
text = self._extract_text_from_response(response_data)
|
||||
# 如果有文本内容,且开启了流式输出优化器,则使用流式输出优化器处理
|
||||
if text and settings.STREAM_OPTIMIZER_ENABLED:
|
||||
# 使用流式输出优化器处理文本输出
|
||||
async for (
|
||||
optimized_chunk
|
||||
) in gemini_optimizer.optimize_stream_output(
|
||||
text,
|
||||
lambda t: self._create_char_response(response_data, t),
|
||||
lambda c: "data: " + json.dumps(c) + "\n\n",
|
||||
):
|
||||
yield optimized_chunk
|
||||
else:
|
||||
# 如果没有文本内容(如工具调用等),整块输出
|
||||
yield "data: " + json.dumps(response_data) + "\n\n"
|
||||
logger.info("Streaming completed successfully")
|
||||
is_success = True
|
||||
status_code = 200 # Assume 200 on success
|
||||
break # Exit loop on success
|
||||
except Exception as e:
|
||||
retries += 1
|
||||
is_success = False # Mark as failed for this attempt
|
||||
error_log_msg = str(e)
|
||||
logger.warning(
|
||||
f"Streaming API call failed with error: {error_log_msg}. Attempt {retries} of {max_retries}"
|
||||
)
|
||||
break
|
||||
# Parse error code for logging
|
||||
match = re.search(r"status code (\d+)", error_log_msg)
|
||||
if match:
|
||||
status_code = int(match.group(1))
|
||||
else:
|
||||
status_code = 500 # Default if parsing fails
|
||||
|
||||
# Log error to error log table
|
||||
await add_error_log(
|
||||
gemini_key=current_attempt_key, # Log key used for this failed attempt
|
||||
model_name=model,
|
||||
error_log=error_log_msg,
|
||||
error_code=status_code,
|
||||
request_msg=payload
|
||||
)
|
||||
|
||||
# Attempt to switch API Key
|
||||
api_key = await self.key_manager.handle_api_failure(current_attempt_key, retries)
|
||||
if api_key:
|
||||
logger.info(f"Switched to new API key: {api_key}")
|
||||
else: # No more keys or retries exceeded by handle_api_failure logic
|
||||
logger.error(f"No valid API key available after {retries} retries.")
|
||||
break # Exit loop if no key available
|
||||
|
||||
if retries >= max_retries:
|
||||
logger.error(
|
||||
f"Max retries ({max_retries}) reached for streaming."
|
||||
)
|
||||
break # Exit loop after max retries
|
||||
finally:
|
||||
# Log the final outcome of the streaming request
|
||||
end_time = time.perf_counter()
|
||||
latency_ms = int((end_time - start_time) * 1000)
|
||||
await add_request_log(
|
||||
model_name=model,
|
||||
api_key=final_api_key, # Log the last key used
|
||||
is_success=is_success, # Log the final success status
|
||||
status_code=status_code, # Log the last known status code
|
||||
latency_ms=latency_ms, # Log total time including retries
|
||||
request_time=request_datetime
|
||||
)
|
||||
# If the loop finished due to failure, ensure an exception is raised if not already handled
|
||||
if not is_success and retries >= max_retries:
|
||||
# We need to raise an exception here if the loop exited due to max retries failure
|
||||
# However, the original code structure doesn't explicitly raise here after the loop.
|
||||
# For now, we just log. Consider raising HTTPException if needed.
|
||||
pass
|
||||
|
||||
@@ -1,6 +1,9 @@
|
||||
# app/services/chat_service.py
|
||||
|
||||
import json
|
||||
import re
|
||||
import datetime # Add datetime import
|
||||
import time # Add time import
|
||||
from copy import deepcopy
|
||||
from typing import Any, AsyncGenerator, Dict, List, Optional, Union
|
||||
|
||||
@@ -13,6 +16,7 @@ from app.log.logger import get_openai_logger
|
||||
from app.service.client.api_client import GeminiApiClient
|
||||
from app.service.image.image_create_service import ImageCreateService
|
||||
from app.service.key.key_manager import KeyManager
|
||||
from app.database.services import add_error_log, add_request_log # Import add_request_log
|
||||
|
||||
logger = get_openai_logger()
|
||||
|
||||
@@ -115,7 +119,6 @@ def _build_payload(
|
||||
"contents": messages,
|
||||
"generationConfig": {
|
||||
"temperature": request.temperature,
|
||||
"maxOutputTokens": request.max_tokens,
|
||||
"stopSequences": request.stop,
|
||||
"topP": request.top_p,
|
||||
"topK": request.top_k,
|
||||
@@ -123,6 +126,8 @@ def _build_payload(
|
||||
"tools": _build_tools(request, messages),
|
||||
"safetySettings": _get_safety_settings(request.model),
|
||||
}
|
||||
if request.max_tokens is not None:
|
||||
payload["generationConfig"]["maxOutputTokens"] = request.max_tokens
|
||||
if request.model.endswith("-image") or request.model.endswith("-image-generation"):
|
||||
payload["generationConfig"]["responseModalities"] = ["Text", "Image"]
|
||||
|
||||
@@ -145,7 +150,7 @@ class OpenAIChatService:
|
||||
def __init__(self, base_url: str, key_manager: KeyManager = None):
|
||||
self.message_converter = OpenAIMessageConverter()
|
||||
self.response_handler = OpenAIResponseHandler(config=None)
|
||||
self.api_client = GeminiApiClient(base_url)
|
||||
self.api_client = GeminiApiClient(base_url, settings.TIME_OUT)
|
||||
self.key_manager = key_manager
|
||||
self.image_create_service = ImageCreateService()
|
||||
|
||||
@@ -188,70 +193,164 @@ class OpenAIChatService:
|
||||
self, model: str, payload: Dict[str, Any], api_key: str
|
||||
) -> Dict[str, Any]:
|
||||
"""处理普通聊天完成"""
|
||||
response = await self.api_client.generate_content(payload, model, api_key)
|
||||
return self.response_handler.handle_response(
|
||||
response, model, stream=False, finish_reason="stop"
|
||||
)
|
||||
start_time = time.perf_counter()
|
||||
request_datetime = datetime.datetime.now()
|
||||
is_success = False
|
||||
status_code = None
|
||||
response = None
|
||||
try:
|
||||
response = await self.api_client.generate_content(payload, model, api_key)
|
||||
is_success = True
|
||||
status_code = 200 # Assume 200 on success
|
||||
return self.response_handler.handle_response(
|
||||
response, model, stream=False, finish_reason="stop"
|
||||
)
|
||||
except Exception as e:
|
||||
is_success = False
|
||||
error_log_msg = str(e)
|
||||
logger.error(f"Normal API call failed with error: {error_log_msg}")
|
||||
# Try to parse status code from exception
|
||||
match = re.search(r"status code (\d+)", error_log_msg)
|
||||
if match:
|
||||
status_code = int(match.group(1))
|
||||
else:
|
||||
status_code = 500 # Default if parsing fails
|
||||
|
||||
await add_error_log(
|
||||
gemini_key=api_key, # Note: Parameter name is gemini_key in add_error_log
|
||||
model_name=model,
|
||||
error_type="openai_chat_service", # Indicate service type
|
||||
error_log=error_log_msg,
|
||||
error_code=status_code,
|
||||
request_msg=payload
|
||||
)
|
||||
raise e # Re-throw exception
|
||||
finally:
|
||||
end_time = time.perf_counter()
|
||||
latency_ms = int((end_time - start_time) * 1000)
|
||||
await add_request_log(
|
||||
model_name=model,
|
||||
api_key=api_key,
|
||||
is_success=is_success,
|
||||
status_code=status_code,
|
||||
latency_ms=latency_ms,
|
||||
request_time=request_datetime
|
||||
)
|
||||
|
||||
async def _handle_stream_completion(
|
||||
self, model: str, payload: Dict[str, Any], api_key: str
|
||||
) -> AsyncGenerator[str, None]:
|
||||
"""处理流式聊天完成,添加重试逻辑"""
|
||||
retries = 0
|
||||
max_retries = 3
|
||||
while retries < max_retries:
|
||||
try:
|
||||
tool_call_flag = False
|
||||
async for line in self.api_client.stream_generate_content(
|
||||
payload, model, api_key
|
||||
):
|
||||
# print(line)
|
||||
if line.startswith("data:"):
|
||||
chunk = json.loads(line[6:])
|
||||
openai_chunk = self.response_handler.handle_response(
|
||||
chunk, model, stream=True, finish_reason=None
|
||||
)
|
||||
if openai_chunk:
|
||||
# 提取文本内容
|
||||
text = self._extract_text_from_openai_chunk(openai_chunk)
|
||||
if text:
|
||||
# 使用流式输出优化器处理文本输出
|
||||
async for (
|
||||
optimized_chunk
|
||||
) in openai_optimizer.optimize_stream_output(
|
||||
text,
|
||||
lambda t: self._create_char_openai_chunk(
|
||||
openai_chunk, t
|
||||
),
|
||||
lambda c: f"data: {json.dumps(c)}\n\n",
|
||||
):
|
||||
yield optimized_chunk
|
||||
else:
|
||||
# 如果没有文本内容(如工具调用等),整块输出
|
||||
if "tool_calls" in json.dumps(openai_chunk):
|
||||
tool_call_flag = True
|
||||
yield f"data: {json.dumps(openai_chunk)}\n\n"
|
||||
if tool_call_flag:
|
||||
yield f"data: {json.dumps(self.response_handler.handle_response({}, model, stream=True, finish_reason='tool_calls'))}\n\n"
|
||||
else:
|
||||
yield f"data: {json.dumps(self.response_handler.handle_response({}, model, stream=True, finish_reason='stop'))}\n\n"
|
||||
yield "data: [DONE]\n\n"
|
||||
logger.info("Streaming completed successfully")
|
||||
break # 成功后退出循环
|
||||
except Exception as e:
|
||||
retries += 1
|
||||
logger.warning(
|
||||
f"Streaming API call failed with error: {str(e)}. Attempt {retries} of {max_retries}"
|
||||
)
|
||||
api_key = await self.key_manager.handle_api_failure(api_key)
|
||||
logger.info(f"Switched to new API key: {api_key}")
|
||||
if retries >= max_retries:
|
||||
logger.error(
|
||||
f"Max retries ({max_retries}) reached for streaming. Raising error"
|
||||
)
|
||||
yield f"data: {json.dumps({'error': 'Streaming failed after retries'})}\n\n"
|
||||
max_retries = settings.MAX_RETRIES
|
||||
start_time = time.perf_counter() # Record start time before loop
|
||||
request_datetime = datetime.datetime.now()
|
||||
is_success = False
|
||||
status_code = None
|
||||
final_api_key = api_key # Store the initial key
|
||||
|
||||
try:
|
||||
while retries < max_retries:
|
||||
current_attempt_key = api_key # Key used for this attempt
|
||||
final_api_key = current_attempt_key # Update final key used
|
||||
try:
|
||||
tool_call_flag = False
|
||||
async for line in self.api_client.stream_generate_content(
|
||||
payload, model, current_attempt_key
|
||||
):
|
||||
# print(line)
|
||||
if line.startswith("data:"):
|
||||
chunk = json.loads(line[6:])
|
||||
openai_chunk = self.response_handler.handle_response(
|
||||
chunk, model, stream=True, finish_reason=None
|
||||
)
|
||||
if openai_chunk:
|
||||
# 提取文本内容
|
||||
text = self._extract_text_from_openai_chunk(openai_chunk)
|
||||
if text and settings.STREAM_OPTIMIZER_ENABLED:
|
||||
# 使用流式输出优化器处理文本输出
|
||||
async for (
|
||||
optimized_chunk
|
||||
) in openai_optimizer.optimize_stream_output(
|
||||
text,
|
||||
lambda t: self._create_char_openai_chunk(
|
||||
openai_chunk, t
|
||||
),
|
||||
lambda c: f"data: {json.dumps(c)}\n\n",
|
||||
):
|
||||
yield optimized_chunk
|
||||
else:
|
||||
# 如果没有文本内容(如工具调用等),整块输出
|
||||
if "tool_calls" in json.dumps(openai_chunk):
|
||||
tool_call_flag = True
|
||||
yield f"data: {json.dumps(openai_chunk)}\n\n"
|
||||
if tool_call_flag:
|
||||
yield f"data: {json.dumps(self.response_handler.handle_response({}, model, stream=True, finish_reason='tool_calls'))}\n\n"
|
||||
else:
|
||||
yield f"data: {json.dumps(self.response_handler.handle_response({}, model, stream=True, finish_reason='stop'))}\n\n"
|
||||
yield "data: [DONE]\n\n"
|
||||
break
|
||||
logger.info("Streaming completed successfully")
|
||||
is_success = True
|
||||
status_code = 200 # Assume 200 on success
|
||||
break # 成功后退出循环
|
||||
except Exception as e:
|
||||
retries += 1
|
||||
is_success = False # Mark as failed for this attempt
|
||||
error_log_msg = str(e)
|
||||
logger.warning(
|
||||
f"Streaming API call failed with error: {error_log_msg}. Attempt {retries} of {max_retries}"
|
||||
)
|
||||
# Parse error code for logging
|
||||
match = re.search(r"status code (\d+)", error_log_msg)
|
||||
if match:
|
||||
status_code = int(match.group(1))
|
||||
else:
|
||||
status_code = 500 # Default if parsing fails
|
||||
|
||||
# Log error to error log table
|
||||
await add_error_log(
|
||||
gemini_key=current_attempt_key, # Note: Parameter name is gemini_key
|
||||
model_name=model,
|
||||
error_type="openai_chat_service", # Indicate service type
|
||||
error_log=error_log_msg,
|
||||
error_code=status_code,
|
||||
request_msg=payload
|
||||
)
|
||||
|
||||
# Attempt to switch API Key
|
||||
# Ensure key_manager is available (might need adjustment if not always passed)
|
||||
if self.key_manager:
|
||||
api_key = await self.key_manager.handle_api_failure(current_attempt_key, retries)
|
||||
if api_key:
|
||||
logger.info(f"Switched to new API key: {api_key}")
|
||||
else:
|
||||
logger.error(f"No valid API key available after {retries} retries.")
|
||||
break # Exit loop if no key available
|
||||
else:
|
||||
logger.error("KeyManager not available for retry logic.")
|
||||
break # Exit loop if key manager is missing
|
||||
|
||||
if retries >= max_retries:
|
||||
logger.error(
|
||||
f"Max retries ({max_retries}) reached for streaming."
|
||||
)
|
||||
break # Exit loop after max retries
|
||||
finally:
|
||||
# Log the final outcome of the streaming request
|
||||
end_time = time.perf_counter()
|
||||
latency_ms = int((end_time - start_time) * 1000)
|
||||
await add_request_log(
|
||||
model_name=model,
|
||||
api_key=final_api_key, # Log the last key used
|
||||
is_success=is_success, # Log the final success status
|
||||
status_code=status_code, # Log the last known status code
|
||||
latency_ms=latency_ms, # Log total time including retries
|
||||
request_time=request_datetime
|
||||
)
|
||||
# If the loop finished due to failure, yield error and DONE
|
||||
if not is_success and retries >= max_retries:
|
||||
yield f"data: {json.dumps({'error': 'Streaming failed after retries'})}\n\n"
|
||||
yield "data: [DONE]\n\n"
|
||||
|
||||
async def create_image_chat_completion(
|
||||
self,
|
||||
|
||||
148
app/service/config/config_service.py
Normal file
@@ -0,0 +1,148 @@
|
||||
"""
|
||||
配置服务模块
|
||||
"""
|
||||
import datetime
|
||||
import json
|
||||
from typing import Any, Dict, List
|
||||
|
||||
from dotenv import find_dotenv, load_dotenv
|
||||
from sqlalchemy import insert, update
|
||||
|
||||
from app.config.config import settings
|
||||
from app.database.connection import database
|
||||
from app.database.models import Settings
|
||||
from app.config.config import Settings as ConfigSettings
|
||||
from app.database.services import get_all_settings
|
||||
from app.service.key.key_manager import get_key_manager_instance, reset_key_manager_instance
|
||||
from app.log.logger import get_config_routes_logger
|
||||
|
||||
logger = get_config_routes_logger()
|
||||
|
||||
|
||||
class ConfigService:
|
||||
"""配置服务类,用于管理应用程序配置"""
|
||||
|
||||
@staticmethod
|
||||
async def get_config() -> Dict[str, Any]:
|
||||
return settings.model_dump()
|
||||
|
||||
@staticmethod
|
||||
async def update_config(config_data: Dict[str, Any]) -> Dict[str, Any]:
|
||||
for key, value in config_data.items():
|
||||
if hasattr(settings, key):
|
||||
setattr(settings, key, value)
|
||||
logger.info(f"Updated setting in memory: {key}")
|
||||
|
||||
# 获取现有设置
|
||||
existing_settings_raw: List[Dict[str, Any]] = await get_all_settings()
|
||||
existing_settings_map: Dict[str, Dict[str, Any]] = {s['key']: s for s in existing_settings_raw}
|
||||
existing_keys = set(existing_settings_map.keys())
|
||||
|
||||
settings_to_update: List[Dict[str, Any]] = []
|
||||
settings_to_insert: List[Dict[str, Any]] = []
|
||||
now = datetime.datetime.now(datetime.timezone(datetime.timedelta(hours=8)))
|
||||
|
||||
# 准备要更新或插入的数据
|
||||
for key, value in config_data.items():
|
||||
# 处理不同类型的值
|
||||
if isinstance(value, list):
|
||||
db_value = json.dumps(value)
|
||||
elif isinstance(value, bool):
|
||||
db_value = str(value).lower()
|
||||
else:
|
||||
db_value = str(value)
|
||||
|
||||
# 仅当值发生变化时才更新
|
||||
if key in existing_keys and existing_settings_map[key]['value'] == db_value:
|
||||
continue
|
||||
|
||||
description = f"{key}配置项"
|
||||
|
||||
data = {
|
||||
'key': key,
|
||||
'value': db_value,
|
||||
'description': description,
|
||||
'updated_at': now
|
||||
}
|
||||
|
||||
if key in existing_keys:
|
||||
# Preserve original description if not explicitly provided
|
||||
data['description'] = existing_settings_map[key].get('description', description)
|
||||
settings_to_update.append(data)
|
||||
else:
|
||||
data['created_at'] = now
|
||||
settings_to_insert.append(data)
|
||||
|
||||
# 在事务中执行批量插入和更新
|
||||
if settings_to_insert or settings_to_update:
|
||||
try:
|
||||
async with database.transaction():
|
||||
if settings_to_insert:
|
||||
query_insert = insert(Settings).values(settings_to_insert)
|
||||
await database.execute(query=query_insert)
|
||||
logger.info(f"Bulk inserted {len(settings_to_insert)} settings.")
|
||||
|
||||
if settings_to_update:
|
||||
for setting_data in settings_to_update:
|
||||
query_update = (
|
||||
update(Settings)
|
||||
.where(Settings.key == setting_data['key'])
|
||||
.values(
|
||||
value=setting_data['value'],
|
||||
description=setting_data['description'],
|
||||
updated_at=setting_data['updated_at']
|
||||
)
|
||||
)
|
||||
await database.execute(query=query_update)
|
||||
logger.info(f"Updated {len(settings_to_update)} settings.")
|
||||
except Exception as e:
|
||||
logger.error(f"Failed to bulk update/insert settings: {str(e)}")
|
||||
raise # Re-raise the exception after logging
|
||||
|
||||
# 重置并重新初始化 KeyManager
|
||||
try:
|
||||
await reset_key_manager_instance()
|
||||
await get_key_manager_instance(settings.API_KEYS)
|
||||
logger.info("KeyManager instance re-initialized with updated settings.")
|
||||
except Exception as e:
|
||||
logger.error(f"Failed to re-initialize KeyManager: {str(e)}")
|
||||
# Decide if this error should prevent returning the updated config
|
||||
# For now, we log the error and continue
|
||||
|
||||
return await ConfigService.get_config()
|
||||
|
||||
@staticmethod
|
||||
async def reset_config() -> Dict[str, Any]:
|
||||
"""
|
||||
重置配置:优先从系统环境变量加载,然后从 .env 文件加载,
|
||||
更新内存中的 settings 对象,并刷新 KeyManager。
|
||||
|
||||
Returns:
|
||||
Dict[str, Any]: 重置后的配置字典
|
||||
"""
|
||||
# 1. 重新加载配置对象,它应该处理环境变量和 .env 的优先级
|
||||
_reload_settings()
|
||||
logger.info("Settings object reloaded, prioritizing system environment variables then .env file.")
|
||||
|
||||
# 2. 重置并重新初始化 KeyManager
|
||||
try:
|
||||
await reset_key_manager_instance()
|
||||
# 确保使用更新后的 settings 中的 API_KEYS
|
||||
await get_key_manager_instance(settings.API_KEYS)
|
||||
logger.info("KeyManager instance re-initialized with reloaded settings.")
|
||||
except Exception as e:
|
||||
logger.error(f"Failed to re-initialize KeyManager during reset: {str(e)}")
|
||||
# 根据需要决定是否抛出异常或继续
|
||||
# 这里选择记录错误并继续
|
||||
|
||||
# 3. 返回更新后的配置
|
||||
return await ConfigService.get_config()
|
||||
|
||||
# 重新加载配置的函数
|
||||
def _reload_settings():
|
||||
"""重新加载环境变量并更新配置"""
|
||||
# 显式加载 .env 文件,覆盖现有环境变量
|
||||
load_dotenv(find_dotenv(), override=True)
|
||||
# 更新现有 settings 对象的属性,而不是新建实例
|
||||
for key, value in ConfigSettings().model_dump().items():
|
||||
setattr(settings, key, value)
|
||||
@@ -2,22 +2,20 @@ from typing import List, Union
|
||||
|
||||
import openai
|
||||
from openai.types import CreateEmbeddingResponse
|
||||
|
||||
from app.config.config import settings
|
||||
from app.log.logger import get_embeddings_logger
|
||||
|
||||
logger = get_embeddings_logger()
|
||||
|
||||
|
||||
class EmbeddingService:
|
||||
def __init__(self, base_url: str):
|
||||
self.base_url = base_url
|
||||
|
||||
async def create_embedding(
|
||||
self, input_text: Union[str, List[str]], model: str, api_key: str
|
||||
) -> CreateEmbeddingResponse:
|
||||
"""Create embeddings using OpenAI API"""
|
||||
try:
|
||||
client = openai.OpenAI(api_key=api_key, base_url=self.base_url)
|
||||
client = openai.OpenAI(api_key=api_key, base_url=settings.BASE_URL)
|
||||
response = client.embeddings.create(input=input_text, model=model)
|
||||
return response
|
||||
except Exception as e:
|
||||
|
||||
@@ -2,6 +2,7 @@ import asyncio
|
||||
from itertools import cycle
|
||||
from typing import Dict
|
||||
|
||||
|
||||
from app.config.config import settings
|
||||
from app.log.logger import get_key_manager_logger
|
||||
|
||||
@@ -36,6 +37,16 @@ class KeyManager:
|
||||
async with self.failure_count_lock:
|
||||
for key in self.key_failure_counts:
|
||||
self.key_failure_counts[key] = 0
|
||||
|
||||
async def reset_key_failure_count(self, key: str) -> bool:
|
||||
"""重置指定key的失败计数"""
|
||||
async with self.failure_count_lock:
|
||||
if key in self.key_failure_counts:
|
||||
self.key_failure_counts[key] = 0
|
||||
logger.info(f"Reset failure count for key: {key}")
|
||||
return True
|
||||
logger.warning(f"Attempt to reset failure count for non-existent key: {key}")
|
||||
return False
|
||||
|
||||
async def get_next_working_key(self) -> str:
|
||||
"""获取下一可用的API key"""
|
||||
@@ -51,7 +62,7 @@ class KeyManager:
|
||||
# await self.reset_failure_counts() 取消重置
|
||||
return current_key
|
||||
|
||||
async def handle_api_failure(self, api_key: str) -> str:
|
||||
async def handle_api_failure(self, api_key: str,retries: int) -> str:
|
||||
"""处理API调用失败"""
|
||||
async with self.failure_count_lock:
|
||||
self.key_failure_counts[api_key] += 1
|
||||
@@ -59,8 +70,10 @@ class KeyManager:
|
||||
logger.warning(
|
||||
f"API key {api_key} has failed {self.MAX_FAILURES} times"
|
||||
)
|
||||
|
||||
return await self.get_next_working_key()
|
||||
if retries < settings.MAX_RETRIES:
|
||||
return await self.get_next_working_key()
|
||||
else:
|
||||
return ""
|
||||
|
||||
def get_fail_count(self, key: str) -> int:
|
||||
"""获取指定密钥的失败次数"""
|
||||
@@ -81,6 +94,13 @@ class KeyManager:
|
||||
|
||||
return {"valid_keys": valid_keys, "invalid_keys": invalid_keys}
|
||||
|
||||
async def get_first_valid_key(self) -> str:
|
||||
"""获取第一个有效的API key"""
|
||||
async with self.failure_count_lock:
|
||||
for key in self.key_failure_counts:
|
||||
if self.key_failure_counts[key] < self.MAX_FAILURES:
|
||||
return key
|
||||
return self.api_keys[0]
|
||||
|
||||
_singleton_instance = None
|
||||
_singleton_lock = asyncio.Lock()
|
||||
@@ -100,4 +120,14 @@ async def get_key_manager_instance(api_keys: list = None) -> KeyManager:
|
||||
if api_keys is None:
|
||||
raise ValueError("API keys are required to initialize the KeyManager")
|
||||
_singleton_instance = KeyManager(api_keys)
|
||||
logger.info("KeyManager instance created.")
|
||||
return _singleton_instance
|
||||
|
||||
|
||||
async def reset_key_manager_instance():
|
||||
"""重置 KeyManager 单例实例"""
|
||||
global _singleton_instance
|
||||
async with _singleton_lock:
|
||||
if _singleton_instance:
|
||||
_singleton_instance = None
|
||||
logger.info("KeyManager instance reset.")
|
||||
|
||||
@@ -10,14 +10,8 @@ logger = get_model_logger()
|
||||
|
||||
|
||||
class ModelService:
|
||||
def __init__(self, search_models: list, image_models: list):
|
||||
self.search_models = search_models
|
||||
self.image_models = image_models
|
||||
self.base_url = settings.BASE_URL
|
||||
self.filtered_models = settings.FILTERED_MODELS
|
||||
|
||||
def get_gemini_models(self, api_key: str) -> Optional[Dict[str, Any]]:
|
||||
url = f"{self.base_url}/models?key={api_key}"
|
||||
url = f"{settings.BASE_URL}/models?key={api_key}"
|
||||
|
||||
try:
|
||||
response = requests.get(url)
|
||||
@@ -27,7 +21,7 @@ class ModelService:
|
||||
filtered_models_list = []
|
||||
for model in gemini_models.get("models", []):
|
||||
model_id = model["name"].split("/")[-1]
|
||||
if model_id not in self.filtered_models:
|
||||
if model_id not in settings.FILTERED_MODELS:
|
||||
filtered_models_list.append(model)
|
||||
else:
|
||||
logger.info(f"Filtered out model: {model_id}")
|
||||
@@ -68,11 +62,11 @@ class ModelService:
|
||||
}
|
||||
openai_format["data"].append(openai_model)
|
||||
|
||||
if model_id in self.search_models:
|
||||
if model_id in settings.SEARCH_MODELS:
|
||||
search_model = openai_model.copy()
|
||||
search_model["id"] = f"{model_id}-search"
|
||||
openai_format["data"].append(search_model)
|
||||
if model_id in self.image_models:
|
||||
if model_id in settings.IMAGE_MODELS:
|
||||
image_model = openai_model.copy()
|
||||
image_model["id"] = f"{model_id}-image"
|
||||
openai_format["data"].append(image_model)
|
||||
@@ -90,9 +84,9 @@ class ModelService:
|
||||
model = model.strip()
|
||||
if model.endswith("-search"):
|
||||
model = model[:-7]
|
||||
return model in self.search_models
|
||||
return model in settings.SEARCH_MODELS
|
||||
if model.endswith("-image"):
|
||||
model = model[:-6]
|
||||
return model in self.image_models
|
||||
return model in settings.IMAGE_MODELS
|
||||
|
||||
return model not in self.filtered_models
|
||||
return model not in settings.FILTERED_MODELS
|
||||
|
||||
123
app/service/stats_service.py
Normal file
@@ -0,0 +1,123 @@
|
||||
# app/service/stats_service.py
|
||||
|
||||
import datetime
|
||||
from sqlalchemy import select, func
|
||||
|
||||
from app.database.connection import database
|
||||
from app.database.models import RequestLog
|
||||
from app.log.logger import get_stats_logger
|
||||
|
||||
logger = get_stats_logger()
|
||||
|
||||
async def get_calls_in_last_seconds(seconds: int) -> int:
|
||||
"""获取过去 N 秒内的调用次数 (包括成功和失败)"""
|
||||
try:
|
||||
cutoff_time = datetime.datetime.now() - datetime.timedelta(seconds=seconds)
|
||||
query = select(func.count(RequestLog.id)).where(
|
||||
RequestLog.request_time >= cutoff_time
|
||||
)
|
||||
count_result = await database.fetch_one(query)
|
||||
return count_result[0] if count_result else 0
|
||||
except Exception as e:
|
||||
logger.error(f"Failed to get calls in last {seconds} seconds: {e}")
|
||||
return 0 # Return 0 on error
|
||||
|
||||
async def get_calls_in_last_minutes(minutes: int) -> int:
|
||||
"""获取过去 N 分钟内的调用次数 (包括成功和失败)"""
|
||||
return await get_calls_in_last_seconds(minutes * 60)
|
||||
|
||||
async def get_calls_in_last_hours(hours: int) -> int:
|
||||
"""获取过去 N 小时内的调用次数 (包括成功和失败)"""
|
||||
return await get_calls_in_last_seconds(hours * 3600)
|
||||
|
||||
async def get_calls_in_current_month() -> int:
|
||||
"""获取当前自然月内的调用次数 (包括成功和失败)"""
|
||||
try:
|
||||
now = datetime.datetime.now()
|
||||
start_of_month = now.replace(day=1, hour=0, minute=0, second=0, microsecond=0)
|
||||
query = select(func.count(RequestLog.id)).where(
|
||||
RequestLog.request_time >= start_of_month
|
||||
)
|
||||
count_result = await database.fetch_one(query)
|
||||
return count_result[0] if count_result else 0
|
||||
except Exception as e:
|
||||
logger.error(f"Failed to get calls in current month: {e}")
|
||||
return 0 # Return 0 on error
|
||||
|
||||
async def get_api_usage_stats() -> dict:
|
||||
"""获取所有需要的 API 使用统计数据"""
|
||||
try:
|
||||
calls_1m = await get_calls_in_last_minutes(1)
|
||||
calls_1h = await get_calls_in_last_hours(1)
|
||||
calls_24h = await get_calls_in_last_hours(24)
|
||||
calls_month = await get_calls_in_current_month()
|
||||
|
||||
return {
|
||||
"calls_1m": calls_1m,
|
||||
"calls_1h": calls_1h,
|
||||
"calls_24h": calls_24h,
|
||||
"calls_month": calls_month,
|
||||
}
|
||||
except Exception as e:
|
||||
logger.error(f"Failed to get API usage stats: {e}")
|
||||
# Return default values on error
|
||||
return {
|
||||
"calls_1m": 0,
|
||||
"calls_1h": 0,
|
||||
"calls_24h": 0,
|
||||
"calls_month": 0,
|
||||
}
|
||||
|
||||
|
||||
async def get_api_call_details(period: str) -> list[dict]:
|
||||
"""
|
||||
获取指定时间段内的 API 调用详情
|
||||
|
||||
Args:
|
||||
period: 时间段标识 ('1m', '1h', '24h')
|
||||
|
||||
Returns:
|
||||
包含调用详情的字典列表,每个字典包含 timestamp, key, model, status
|
||||
|
||||
Raises:
|
||||
ValueError: 如果 period 无效
|
||||
"""
|
||||
now = datetime.datetime.now()
|
||||
if period == '1m':
|
||||
start_time = now - datetime.timedelta(minutes=1)
|
||||
elif period == '1h':
|
||||
start_time = now - datetime.timedelta(hours=1)
|
||||
elif period == '24h':
|
||||
start_time = now - datetime.timedelta(hours=24)
|
||||
else:
|
||||
raise ValueError(f"无效的时间段标识: {period}")
|
||||
|
||||
try:
|
||||
query = select(
|
||||
RequestLog.request_time.label("timestamp"),
|
||||
RequestLog.api_key.label("key"),
|
||||
RequestLog.model_name.label("model"),
|
||||
RequestLog.status_code # We might need to map this to 'success'/'failure' later
|
||||
).where(
|
||||
RequestLog.request_time >= start_time
|
||||
).order_by(RequestLog.request_time.desc()) # Order by most recent first
|
||||
|
||||
results = await database.fetch_all(query)
|
||||
|
||||
# Convert results to list of dicts and map status_code
|
||||
details = []
|
||||
for row in results:
|
||||
status = 'success' if 200 <= row['status_code'] < 300 else 'failure'
|
||||
details.append({
|
||||
"timestamp": row['timestamp'].isoformat(), # Use ISO format for JS compatibility
|
||||
"key": row['key'],
|
||||
"model": row['model'],
|
||||
"status": status
|
||||
})
|
||||
logger.info(f"Retrieved {len(details)} API call details for period '{period}'")
|
||||
return details
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Failed to get API call details for period '{period}': {e}")
|
||||
# Re-raise the exception to be handled by the route
|
||||
raise
|
||||
@@ -1,249 +0,0 @@
|
||||
body {
|
||||
font-family: 'Roboto', sans-serif;
|
||||
line-height: 1.6;
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
|
||||
min-height: 100vh;
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.container {
|
||||
max-width: 400px;
|
||||
width: 90%;
|
||||
background: rgba(255, 255, 255, 0.95);
|
||||
padding: 40px;
|
||||
border-radius: 20px;
|
||||
box-shadow: 0 15px 35px rgba(0,0,0,0.2);
|
||||
backdrop-filter: blur(10px);
|
||||
transition: all 0.4s cubic-bezier(0.4, 0, 0.2, 1);
|
||||
}
|
||||
|
||||
.container:hover {
|
||||
transform: translateY(-5px);
|
||||
box-shadow: 0 20px 40px rgba(0,0,0,0.25);
|
||||
}
|
||||
|
||||
.logo {
|
||||
text-align: center;
|
||||
margin-bottom: 30px;
|
||||
animation: fadeIn 1s ease;
|
||||
}
|
||||
|
||||
.logo i {
|
||||
font-size: 48px;
|
||||
color: #764ba2;
|
||||
margin-bottom: 15px;
|
||||
}
|
||||
|
||||
h2 {
|
||||
color: #2c3e50;
|
||||
text-align: center;
|
||||
margin-bottom: 30px;
|
||||
font-weight: 700;
|
||||
font-size: 24px;
|
||||
animation: slideDown 0.5s ease;
|
||||
}
|
||||
|
||||
form {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 20px;
|
||||
}
|
||||
|
||||
.input-group {
|
||||
position: relative;
|
||||
animation: slideUp 0.5s ease;
|
||||
}
|
||||
|
||||
.input-group i {
|
||||
position: absolute;
|
||||
left: 12px;
|
||||
top: 50%;
|
||||
transform: translateY(-50%);
|
||||
color: #764ba2;
|
||||
font-size: 18px;
|
||||
}
|
||||
|
||||
input {
|
||||
width: 100%;
|
||||
padding: 12px 12px 12px 40px;
|
||||
border: 2px solid #e0e0e0;
|
||||
border-radius: 10px;
|
||||
font-size: 16px;
|
||||
box-sizing: border-box;
|
||||
transition: all 0.3s ease;
|
||||
background: rgba(255, 255, 255, 0.9);
|
||||
}
|
||||
|
||||
input:focus {
|
||||
border-color: #764ba2;
|
||||
box-shadow: 0 0 10px rgba(118, 75, 162, 0.2);
|
||||
outline: none;
|
||||
}
|
||||
|
||||
button {
|
||||
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
|
||||
color: white;
|
||||
border: none;
|
||||
padding: 14px;
|
||||
border-radius: 10px;
|
||||
cursor: pointer;
|
||||
font-size: 16px;
|
||||
font-weight: bold;
|
||||
transition: all 0.3s ease;
|
||||
position: relative;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
button:hover {
|
||||
transform: translateY(-2px);
|
||||
box-shadow: 0 5px 15px rgba(118, 75, 162, 0.3);
|
||||
}
|
||||
|
||||
button:active {
|
||||
transform: translateY(0);
|
||||
}
|
||||
|
||||
button::after {
|
||||
content: '';
|
||||
position: absolute;
|
||||
top: 50%;
|
||||
left: 50%;
|
||||
width: 0;
|
||||
height: 0;
|
||||
background: rgba(255, 255, 255, 0.2);
|
||||
border-radius: 50%;
|
||||
transform: translate(-50%, -50%);
|
||||
transition: width 0.6s, height 0.6s;
|
||||
}
|
||||
|
||||
button:active::after {
|
||||
width: 200px;
|
||||
height: 200px;
|
||||
opacity: 0;
|
||||
}
|
||||
|
||||
.error-message {
|
||||
color: #e74c3c;
|
||||
margin-top: 15px;
|
||||
text-align: center;
|
||||
font-weight: bold;
|
||||
padding: 10px;
|
||||
border-radius: 5px;
|
||||
background: rgba(231, 76, 60, 0.1);
|
||||
animation: shake 0.5s ease;
|
||||
}
|
||||
|
||||
.copyright {
|
||||
position: fixed;
|
||||
bottom: 0;
|
||||
left: 0;
|
||||
width: 100%;
|
||||
background: rgba(255, 255, 255, 0.9);
|
||||
padding: 10px 0;
|
||||
text-align: center;
|
||||
font-size: 14px;
|
||||
color: #2c3e50;
|
||||
backdrop-filter: blur(5px);
|
||||
border-top: 1px solid rgba(0,0,0,0.1);
|
||||
}
|
||||
|
||||
.copyright a {
|
||||
color: #764ba2;
|
||||
text-decoration: none;
|
||||
transition: color 0.3s ease;
|
||||
}
|
||||
|
||||
.copyright a:hover {
|
||||
color: #667eea;
|
||||
}
|
||||
|
||||
.copyright img {
|
||||
width: 20px;
|
||||
height: 20px;
|
||||
border-radius: 50%;
|
||||
vertical-align: middle;
|
||||
margin-right: 5px;
|
||||
}
|
||||
|
||||
@keyframes fadeIn {
|
||||
from { opacity: 0; }
|
||||
to { opacity: 1; }
|
||||
}
|
||||
|
||||
@keyframes slideDown {
|
||||
from { transform: translateY(-20px); opacity: 0; }
|
||||
to { transform: translateY(0); opacity: 1; }
|
||||
}
|
||||
|
||||
@keyframes slideUp {
|
||||
from { transform: translateY(20px); opacity: 0; }
|
||||
to { transform: translateY(0); opacity: 1; }
|
||||
}
|
||||
|
||||
@keyframes shake {
|
||||
0%, 100% { transform: translateX(0); }
|
||||
25% { transform: translateX(-5px); }
|
||||
75% { transform: translateX(5px); }
|
||||
}
|
||||
|
||||
@media (max-width: 768px) {
|
||||
.container {
|
||||
width: 85%;
|
||||
padding: 30px;
|
||||
}
|
||||
.logo i {
|
||||
font-size: 40px;
|
||||
}
|
||||
h2 {
|
||||
font-size: 22px;
|
||||
}
|
||||
input {
|
||||
padding: 10px 10px 10px 35px;
|
||||
font-size: 15px;
|
||||
}
|
||||
.input-group i {
|
||||
font-size: 16px;
|
||||
}
|
||||
button {
|
||||
padding: 12px;
|
||||
font-size: 15px;
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 480px) {
|
||||
.container {
|
||||
width: 90%;
|
||||
padding: 25px;
|
||||
}
|
||||
.logo i {
|
||||
font-size: 36px;
|
||||
}
|
||||
h2 {
|
||||
font-size: 20px;
|
||||
margin-bottom: 25px;
|
||||
}
|
||||
form {
|
||||
gap: 15px;
|
||||
}
|
||||
input {
|
||||
padding: 10px 10px 10px 32px;
|
||||
font-size: 14px;
|
||||
}
|
||||
.input-group i {
|
||||
font-size: 15px;
|
||||
left: 10px;
|
||||
}
|
||||
button {
|
||||
padding: 10px;
|
||||
font-size: 14px;
|
||||
}
|
||||
.error-message {
|
||||
font-size: 14px;
|
||||
padding: 8px;
|
||||
margin-top: 12px;
|
||||
}
|
||||
}
|
||||
@@ -1,461 +0,0 @@
|
||||
body {
|
||||
font-family: 'Roboto', sans-serif;
|
||||
line-height: 1.6;
|
||||
margin: 0;
|
||||
padding: 20px;
|
||||
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
|
||||
min-height: 100vh;
|
||||
}
|
||||
|
||||
.container {
|
||||
max-width: 900px;
|
||||
width: 95%;
|
||||
background: rgba(255, 255, 255, 0.95);
|
||||
padding: 40px;
|
||||
border-radius: 20px;
|
||||
box-shadow: 0 15px 35px rgba(0,0,0,0.2);
|
||||
backdrop-filter: blur(10px);
|
||||
position: relative;
|
||||
margin: 20px auto;
|
||||
overflow-y: auto;
|
||||
max-height: calc(100vh - 40px);
|
||||
scrollbar-width: none;
|
||||
-ms-overflow-style: none;
|
||||
}
|
||||
|
||||
.container::-webkit-scrollbar {
|
||||
display: none;
|
||||
}
|
||||
|
||||
h1 {
|
||||
color: #2c3e50;
|
||||
text-align: center;
|
||||
margin-bottom: 30px;
|
||||
font-weight: 700;
|
||||
font-size: 32px;
|
||||
position: relative;
|
||||
padding-bottom: 15px;
|
||||
}
|
||||
|
||||
h1::after {
|
||||
content: '';
|
||||
position: absolute;
|
||||
bottom: 0;
|
||||
left: 50%;
|
||||
transform: translateX(-50%);
|
||||
width: 100px;
|
||||
height: 4px;
|
||||
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
|
||||
border-radius: 2px;
|
||||
}
|
||||
|
||||
.key-list {
|
||||
margin-bottom: 30px;
|
||||
background: rgba(248, 249, 250, 0.9);
|
||||
padding: 25px;
|
||||
border-radius: 15px;
|
||||
transition: all 0.3s ease;
|
||||
border: 1px solid rgba(0,0,0,0.1);
|
||||
animation: fadeIn 0.5s ease forwards;
|
||||
}
|
||||
|
||||
.key-list:hover {
|
||||
transform: translateY(-5px);
|
||||
box-shadow: 0 10px 20px rgba(0,0,0,0.1);
|
||||
}
|
||||
|
||||
.key-list:nth-child(2) {
|
||||
animation-delay: 0.2s;
|
||||
}
|
||||
|
||||
.key-list h2 {
|
||||
color: #2c3e50;
|
||||
margin-bottom: 20px;
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
font-size: 1.5em;
|
||||
padding-bottom: 10px;
|
||||
border-bottom: 2px solid rgba(0,0,0,0.1);
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.key-list h2 .toggle-icon {
|
||||
margin-right: 10px;
|
||||
transition: transform 0.3s ease;
|
||||
}
|
||||
|
||||
.key-list h2 .toggle-icon.collapsed {
|
||||
transform: rotate(-90deg);
|
||||
}
|
||||
|
||||
.key-list .key-content {
|
||||
transition: all 0.3s ease-out;
|
||||
overflow: hidden;
|
||||
height: auto;
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
.key-list .key-content.collapsed {
|
||||
height: 0;
|
||||
opacity: 0;
|
||||
padding-top: 0;
|
||||
padding-bottom: 0;
|
||||
}
|
||||
|
||||
ul {
|
||||
list-style-type: none;
|
||||
padding: 0;
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
li {
|
||||
background: white;
|
||||
border: 1px solid rgba(0,0,0,0.1);
|
||||
margin-bottom: 12px;
|
||||
padding: 15px;
|
||||
border-radius: 10px;
|
||||
transition: all 0.3s ease;
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
box-shadow: 0 2px 5px rgba(0,0,0,0.05);
|
||||
}
|
||||
|
||||
li:hover {
|
||||
transform: translateX(5px);
|
||||
box-shadow: 0 5px 15px rgba(0,0,0,0.1);
|
||||
}
|
||||
|
||||
.key-info {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 15px;
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.key-text {
|
||||
font-family: 'Roboto Mono', monospace;
|
||||
color: #2c3e50;
|
||||
}
|
||||
|
||||
.fail-count {
|
||||
background: rgba(231, 76, 60, 0.1);
|
||||
color: #e74c3c;
|
||||
padding: 4px 10px;
|
||||
border-radius: 15px;
|
||||
font-size: 0.85em;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 5px;
|
||||
}
|
||||
|
||||
.fail-count i {
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.key-actions {
|
||||
display: flex;
|
||||
gap: 10px;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.verify-btn, .copy-btn {
|
||||
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
|
||||
color: white;
|
||||
border: none;
|
||||
padding: 8px 15px;
|
||||
border-radius: 8px;
|
||||
cursor: pointer;
|
||||
font-size: 14px;
|
||||
font-weight: bold;
|
||||
transition: all 0.3s ease;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 5px;
|
||||
}
|
||||
|
||||
.verify-btn {
|
||||
background: linear-gradient(135deg, #2ecc71, #27ae60);
|
||||
}
|
||||
|
||||
.verify-btn:hover {
|
||||
transform: translateY(-2px);
|
||||
box-shadow: 0 5px 15px rgba(46, 204, 113, 0.3);
|
||||
}
|
||||
|
||||
.verify-btn:disabled {
|
||||
opacity: 0.7;
|
||||
cursor: not-allowed;
|
||||
transform: none;
|
||||
box-shadow: none;
|
||||
}
|
||||
|
||||
.verify-btn i {
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
.copy-btn:hover {
|
||||
transform: translateY(-2px);
|
||||
box-shadow: 0 5px 15px rgba(118, 75, 162, 0.3);
|
||||
}
|
||||
|
||||
.copy-btn:active {
|
||||
transform: translateY(0);
|
||||
}
|
||||
|
||||
.copy-btn i {
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
.total {
|
||||
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
|
||||
color: white;
|
||||
padding: 15px 25px;
|
||||
border-radius: 10px;
|
||||
font-weight: bold;
|
||||
text-align: center;
|
||||
font-size: 1.2em;
|
||||
margin-top: 30px;
|
||||
box-shadow: 0 5px 15px rgba(0,0,0,0.1);
|
||||
}
|
||||
|
||||
#copyStatus {
|
||||
position: fixed;
|
||||
top: 50%;
|
||||
left: 50%;
|
||||
transform: translate(-50%, -50%);
|
||||
padding: 15px 30px;
|
||||
border-radius: 25px;
|
||||
font-weight: bold;
|
||||
opacity: 0;
|
||||
transition: all 0.3s ease;
|
||||
backdrop-filter: blur(5px);
|
||||
box-shadow: 0 5px 15px rgba(0,0,0,0.2);
|
||||
z-index: 1000;
|
||||
text-align: center;
|
||||
min-width: 200px;
|
||||
color: white;
|
||||
}
|
||||
|
||||
#copyStatus.success {
|
||||
background: rgba(39, 174, 96, 0.95);
|
||||
}
|
||||
|
||||
#copyStatus.error {
|
||||
background: rgba(231, 76, 60, 0.95);
|
||||
}
|
||||
|
||||
.status-badge {
|
||||
padding: 4px 12px;
|
||||
border-radius: 15px;
|
||||
font-size: 0.9em;
|
||||
font-weight: bold;
|
||||
margin-right: 10px;
|
||||
}
|
||||
|
||||
.status-valid {
|
||||
background: rgba(39, 174, 96, 0.1);
|
||||
color: #27ae60;
|
||||
}
|
||||
|
||||
.status-invalid {
|
||||
background: rgba(231, 76, 60, 0.1);
|
||||
color: #e74c3c;
|
||||
}
|
||||
|
||||
.scroll-buttons {
|
||||
position: fixed;
|
||||
right: 20px;
|
||||
bottom: 20px;
|
||||
display: none;
|
||||
flex-direction: column;
|
||||
gap: 10px;
|
||||
z-index: 1000;
|
||||
}
|
||||
|
||||
.scroll-btn {
|
||||
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
|
||||
color: white;
|
||||
width: 40px;
|
||||
height: 40px;
|
||||
border: none;
|
||||
border-radius: 50%;
|
||||
cursor: pointer;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
font-size: 20px;
|
||||
transition: all 0.3s ease;
|
||||
backdrop-filter: blur(5px);
|
||||
box-shadow: 0 2px 10px rgba(0,0,0,0.2);
|
||||
}
|
||||
|
||||
.scroll-btn:hover {
|
||||
background: linear-gradient(135deg, #764ba2 0%, #667eea 100%);
|
||||
transform: scale(1.1);
|
||||
}
|
||||
|
||||
.scroll-btn:active {
|
||||
transform: scale(0.95);
|
||||
}
|
||||
|
||||
.refresh-btn {
|
||||
position: fixed;
|
||||
top: 20px;
|
||||
right: 20px;
|
||||
z-index: 1000;
|
||||
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
|
||||
color: #fff;
|
||||
border: none;
|
||||
padding: 10px 20px;
|
||||
border-radius: 25px;
|
||||
cursor: pointer;
|
||||
font-size: 14px;
|
||||
font-weight: bold;
|
||||
transition: all 0.3s ease;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 8px;
|
||||
box-shadow: 0 5px 15px rgba(0, 0, 0, 0.1);
|
||||
}
|
||||
|
||||
.refresh-btn:hover {
|
||||
transform: scale(1.05);
|
||||
box-shadow: 0 8px 20px rgba(118, 75, 162, 0.3);
|
||||
background: linear-gradient(135deg, #764ba2 0%, #667eea 100%);
|
||||
}
|
||||
|
||||
.refresh-btn:active {
|
||||
transform: scale(0.95);
|
||||
}
|
||||
|
||||
.refresh-btn i {
|
||||
transition: transform 0.5s ease;
|
||||
}
|
||||
|
||||
.refresh-btn.loading i {
|
||||
animation: spin 1s linear infinite;
|
||||
}
|
||||
|
||||
.copyright {
|
||||
position: fixed;
|
||||
bottom: 0;
|
||||
left: 0;
|
||||
width: 100%;
|
||||
background: rgba(255, 255, 255, 0.9);
|
||||
padding: 10px 0;
|
||||
text-align: center;
|
||||
font-size: 14px;
|
||||
color: #2c3e50;
|
||||
backdrop-filter: blur(5px);
|
||||
border-top: 1px solid rgba(0,0,0,0.1);
|
||||
}
|
||||
|
||||
.copyright a {
|
||||
color: #764ba2;
|
||||
text-decoration: none;
|
||||
transition: color 0.3s ease;
|
||||
}
|
||||
|
||||
.copyright a:hover {
|
||||
color: #667eea;
|
||||
}
|
||||
|
||||
.copyright img {
|
||||
width: 20px;
|
||||
height: 20px;
|
||||
border-radius: 50%;
|
||||
vertical-align: middle;
|
||||
margin-right: 5px;
|
||||
}
|
||||
|
||||
@keyframes fadeIn {
|
||||
from { opacity: 0; transform: translateY(20px); }
|
||||
to { opacity: 1; transform: translateY(0); }
|
||||
}
|
||||
|
||||
@keyframes spin {
|
||||
from { transform: rotate(0deg); }
|
||||
to { transform: rotate(360deg); }
|
||||
}
|
||||
|
||||
@media (max-width: 768px) {
|
||||
.container {
|
||||
width: 100%;
|
||||
padding: 20px;
|
||||
margin: 10px auto;
|
||||
}
|
||||
body {
|
||||
padding: 10px;
|
||||
}
|
||||
h1 {
|
||||
font-size: 24px;
|
||||
}
|
||||
.key-list h2 {
|
||||
font-size: 1.2em;
|
||||
flex-direction: column;
|
||||
gap: 10px;
|
||||
align-items: flex-start;
|
||||
}
|
||||
.key-info {
|
||||
flex-direction: column;
|
||||
align-items: flex-start;
|
||||
gap: 8px;
|
||||
}
|
||||
li {
|
||||
flex-direction: column;
|
||||
gap: 10px;
|
||||
}
|
||||
.key-actions {
|
||||
width: 100%;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.verify-btn, .copy-btn {
|
||||
width: 100%;
|
||||
justify-content: center;
|
||||
}
|
||||
.key-text {
|
||||
word-break: break-all;
|
||||
}
|
||||
.scroll-buttons {
|
||||
right: 10px;
|
||||
bottom: 10px;
|
||||
}
|
||||
.scroll-btn {
|
||||
width: 35px;
|
||||
height: 35px;
|
||||
font-size: 16px;
|
||||
}
|
||||
.refresh-btn {
|
||||
top: 10px;
|
||||
right: 10px;
|
||||
padding: 8px 16px;
|
||||
font-size: 12px;
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 480px) {
|
||||
.container {
|
||||
padding: 15px;
|
||||
}
|
||||
h1 {
|
||||
font-size: 20px;
|
||||
}
|
||||
.key-list {
|
||||
padding: 15px;
|
||||
}
|
||||
.status-badge {
|
||||
padding: 3px 8px;
|
||||
font-size: 0.8em;
|
||||
}
|
||||
.fail-count {
|
||||
font-size: 0.8em;
|
||||
}
|
||||
.total {
|
||||
font-size: 1em;
|
||||
padding: 12px 20px;
|
||||
}
|
||||
}
|
||||
BIN
app/static/icons/logo.png
Normal file
|
After Width: | Height: | Size: 39 KiB |
BIN
app/static/icons/logo1.png
Normal file
|
After Width: | Height: | Size: 18 KiB |
@@ -1,18 +0,0 @@
|
||||
if ('serviceWorker' in navigator) {
|
||||
window.addEventListener('load', () => {
|
||||
navigator.serviceWorker.register('/static/service-worker.js')
|
||||
.then(registration => {
|
||||
console.log('ServiceWorker注册成功:', registration.scope);
|
||||
})
|
||||
.catch(error => {
|
||||
console.log('ServiceWorker注册失败:', error);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
document.addEventListener('DOMContentLoaded', () => {
|
||||
const copyrightYear = document.querySelector('.copyright script');
|
||||
if (copyrightYear) {
|
||||
copyrightYear.textContent = new Date().getFullYear();
|
||||
}
|
||||
});
|
||||
757
app/static/js/config_editor.js
Normal file
@@ -0,0 +1,757 @@
|
||||
document.addEventListener('DOMContentLoaded', function() {
|
||||
// 初始化配置
|
||||
initConfig();
|
||||
|
||||
// 标签切换
|
||||
const tabButtons = document.querySelectorAll('.tab-btn');
|
||||
tabButtons.forEach(button => {
|
||||
button.addEventListener('click', function(e) {
|
||||
// 防止事件冒泡
|
||||
e.stopPropagation();
|
||||
const tabId = this.getAttribute('data-tab');
|
||||
switchTab(tabId);
|
||||
});
|
||||
});
|
||||
|
||||
// 上传提供商切换
|
||||
const uploadProviderSelect = document.getElementById('UPLOAD_PROVIDER');
|
||||
if (uploadProviderSelect) {
|
||||
uploadProviderSelect.addEventListener('change', function() {
|
||||
toggleProviderConfig(this.value);
|
||||
});
|
||||
}
|
||||
|
||||
// 切换按钮事件
|
||||
const toggleSwitches = document.querySelectorAll('.toggle-switch');
|
||||
toggleSwitches.forEach(toggleSwitch => {
|
||||
toggleSwitch.addEventListener('click', function(e) {
|
||||
// 防止事件冒泡
|
||||
e.stopPropagation();
|
||||
const checkbox = this.querySelector('input[type="checkbox"]');
|
||||
if (checkbox) {
|
||||
checkbox.checked = !checkbox.checked;
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
// 保存按钮
|
||||
const saveBtn = document.getElementById('saveBtn');
|
||||
if (saveBtn) {
|
||||
saveBtn.addEventListener('click', saveConfig);
|
||||
}
|
||||
|
||||
// 重置按钮
|
||||
const resetBtn = document.getElementById('resetBtn');
|
||||
if (resetBtn) {
|
||||
resetBtn.addEventListener('click', resetConfig);
|
||||
}
|
||||
|
||||
// 滚动按钮
|
||||
window.addEventListener('scroll', toggleScrollButtons);
|
||||
|
||||
// --- 新增:API Key 模态框和搜索相关 ---
|
||||
const apiKeyModal = document.getElementById('apiKeyModal');
|
||||
const addApiKeyBtn = document.getElementById('addApiKeyBtn');
|
||||
const closeApiKeyModalBtn = document.getElementById('closeApiKeyModalBtn');
|
||||
const cancelAddApiKeyBtn = document.getElementById('cancelAddApiKeyBtn');
|
||||
const confirmAddApiKeyBtn = document.getElementById('confirmAddApiKeyBtn');
|
||||
const apiKeyBulkInput = document.getElementById('apiKeyBulkInput');
|
||||
const apiKeySearchInput = document.getElementById('apiKeySearchInput');
|
||||
const bulkDeleteApiKeyBtn = document.getElementById('bulkDeleteApiKeyBtn'); // 新增
|
||||
const bulkDeleteApiKeyModal = document.getElementById('bulkDeleteApiKeyModal'); // 新增
|
||||
const closeBulkDeleteModalBtn = document.getElementById('closeBulkDeleteModalBtn'); // 新增
|
||||
const cancelBulkDeleteApiKeyBtn = document.getElementById('cancelBulkDeleteApiKeyBtn'); // 新增
|
||||
const confirmBulkDeleteApiKeyBtn = document.getElementById('confirmBulkDeleteApiKeyBtn'); // 新增
|
||||
const bulkDeleteApiKeyInput = document.getElementById('bulkDeleteApiKeyInput'); // 新增
|
||||
|
||||
// --- 新增:重置确认模态框相关 ---
|
||||
const resetConfirmModal = document.getElementById('resetConfirmModal');
|
||||
const closeResetModalBtn = document.getElementById('closeResetModalBtn');
|
||||
const cancelResetBtn = document.getElementById('cancelResetBtn');
|
||||
const confirmResetBtn = document.getElementById('confirmResetBtn');
|
||||
// --- 结束:新增 ---
|
||||
|
||||
|
||||
// 打开模态框
|
||||
if (addApiKeyBtn) {
|
||||
addApiKeyBtn.addEventListener('click', () => {
|
||||
if (apiKeyModal) {
|
||||
apiKeyModal.classList.add('show');
|
||||
}
|
||||
if (apiKeyBulkInput) apiKeyBulkInput.value = ''; // 清空输入框
|
||||
});
|
||||
}
|
||||
|
||||
// 关闭模态框 (X 按钮)
|
||||
if (closeApiKeyModalBtn) {
|
||||
closeApiKeyModalBtn.addEventListener('click', () => {
|
||||
if (apiKeyModal) {
|
||||
apiKeyModal.classList.remove('show');
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
// 关闭模态框 (取消按钮)
|
||||
if (cancelAddApiKeyBtn) {
|
||||
cancelAddApiKeyBtn.addEventListener('click', () => {
|
||||
if (apiKeyModal) {
|
||||
apiKeyModal.classList.remove('show');
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
// 点击模态框外部关闭 (处理两个模态框)
|
||||
window.addEventListener('click', (event) => {
|
||||
if (event.target == apiKeyModal) {
|
||||
apiKeyModal.classList.remove('show');
|
||||
}
|
||||
if (event.target == resetConfirmModal) {
|
||||
resetConfirmModal.classList.remove('show');
|
||||
}
|
||||
if (event.target == bulkDeleteApiKeyModal) { // 新增对批量删除模态框的处理
|
||||
bulkDeleteApiKeyModal.classList.remove('show');
|
||||
}
|
||||
});
|
||||
|
||||
// 确认添加 API Key
|
||||
if (confirmAddApiKeyBtn) {
|
||||
confirmAddApiKeyBtn.addEventListener('click', handleBulkAddApiKeys);
|
||||
}
|
||||
|
||||
// API Key 搜索 (稍后实现具体逻辑)
|
||||
if (apiKeySearchInput) {
|
||||
apiKeySearchInput.addEventListener('input', handleApiKeySearch);
|
||||
}
|
||||
|
||||
// --- 新增:批量删除 API Key 相关事件 ---
|
||||
// 打开批量删除模态框
|
||||
if (bulkDeleteApiKeyBtn) {
|
||||
bulkDeleteApiKeyBtn.addEventListener('click', () => {
|
||||
if (bulkDeleteApiKeyModal) {
|
||||
bulkDeleteApiKeyModal.classList.add('show');
|
||||
}
|
||||
if (bulkDeleteApiKeyInput) bulkDeleteApiKeyInput.value = ''; // 清空输入框
|
||||
});
|
||||
}
|
||||
|
||||
// 关闭批量删除模态框 (X 按钮)
|
||||
if (closeBulkDeleteModalBtn) {
|
||||
closeBulkDeleteModalBtn.addEventListener('click', () => {
|
||||
if (bulkDeleteApiKeyModal) {
|
||||
bulkDeleteApiKeyModal.classList.remove('show');
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
// 关闭批量删除模态框 (取消按钮)
|
||||
if (cancelBulkDeleteApiKeyBtn) {
|
||||
cancelBulkDeleteApiKeyBtn.addEventListener('click', () => {
|
||||
if (bulkDeleteApiKeyModal) {
|
||||
bulkDeleteApiKeyModal.classList.remove('show');
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
// 确认批量删除 API Key
|
||||
if (confirmBulkDeleteApiKeyBtn) {
|
||||
confirmBulkDeleteApiKeyBtn.addEventListener('click', handleBulkDeleteApiKeys);
|
||||
}
|
||||
// --- 结束:批量删除 API Key 相关 ---
|
||||
// --- 结束:API Key 相关 ---
|
||||
|
||||
// --- 新增:重置确认模态框事件监听 (移到 DOMContentLoaded 内部) ---
|
||||
if (closeResetModalBtn) {
|
||||
closeResetModalBtn.addEventListener('click', () => {
|
||||
if (resetConfirmModal) {
|
||||
resetConfirmModal.classList.remove('show');
|
||||
}
|
||||
});
|
||||
}
|
||||
if (cancelResetBtn) {
|
||||
cancelResetBtn.addEventListener('click', () => {
|
||||
if (resetConfirmModal) {
|
||||
resetConfirmModal.classList.remove('show');
|
||||
}
|
||||
});
|
||||
}
|
||||
if (confirmResetBtn) {
|
||||
// 调用之前定义的 executeReset 函数
|
||||
confirmResetBtn.addEventListener('click', () => {
|
||||
if (resetConfirmModal) {
|
||||
resetConfirmModal.classList.remove('show'); // 关闭模态框
|
||||
}
|
||||
executeReset(); // 执行重置逻辑
|
||||
});
|
||||
}
|
||||
// --- 结束:重置相关 ---
|
||||
|
||||
// 移除了静态生成令牌按钮的事件监听器,现在按钮是动态生成的
|
||||
|
||||
// 认证令牌生成按钮事件绑定
|
||||
const generateAuthTokenBtn = document.getElementById('generateAuthTokenBtn');
|
||||
const authTokenInput = document.getElementById('AUTH_TOKEN');
|
||||
if (generateAuthTokenBtn && authTokenInput) {
|
||||
generateAuthTokenBtn.addEventListener('click', function() {
|
||||
const newToken = generateRandomToken();
|
||||
authTokenInput.value = newToken;
|
||||
showNotification('已生成新认证令牌', 'success');
|
||||
});
|
||||
}
|
||||
}); // <-- DOMContentLoaded 结束括号
|
||||
|
||||
// 初始化配置
|
||||
async function initConfig() {
|
||||
try {
|
||||
showNotification('正在加载配置...', 'info');
|
||||
const response = await fetch('/api/config');
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(`HTTP error! status: ${response.status}`);
|
||||
}
|
||||
|
||||
const config = await response.json();
|
||||
|
||||
// 确保数组字段有默认值
|
||||
if (!config.API_KEYS || !Array.isArray(config.API_KEYS) || config.API_KEYS.length === 0) {
|
||||
config.API_KEYS = ['请在此处输入 API 密钥'];
|
||||
}
|
||||
|
||||
if (!config.ALLOWED_TOKENS || !Array.isArray(config.ALLOWED_TOKENS) || config.ALLOWED_TOKENS.length === 0) {
|
||||
config.ALLOWED_TOKENS = [''];
|
||||
}
|
||||
|
||||
if (!config.IMAGE_MODELS || !Array.isArray(config.IMAGE_MODELS) || config.IMAGE_MODELS.length === 0) {
|
||||
config.IMAGE_MODELS = ['gemini-1.5-pro-latest'];
|
||||
}
|
||||
|
||||
if (!config.SEARCH_MODELS || !Array.isArray(config.SEARCH_MODELS) || config.SEARCH_MODELS.length === 0) {
|
||||
config.SEARCH_MODELS = ['gemini-1.5-flash-latest'];
|
||||
}
|
||||
|
||||
if (!config.FILTERED_MODELS || !Array.isArray(config.FILTERED_MODELS) || config.FILTERED_MODELS.length === 0) {
|
||||
config.FILTERED_MODELS = ['gemini-1.0-pro-latest'];
|
||||
}
|
||||
|
||||
populateForm(config);
|
||||
|
||||
// 确保上传提供商有默认值
|
||||
const uploadProvider = document.getElementById('UPLOAD_PROVIDER');
|
||||
if (uploadProvider && !uploadProvider.value) {
|
||||
uploadProvider.value = 'smms'; // 设置默认值为 smms
|
||||
toggleProviderConfig('smms');
|
||||
}
|
||||
|
||||
showNotification('配置加载成功', 'success');
|
||||
} catch (error) {
|
||||
console.error('加载配置失败:', error);
|
||||
showNotification('加载配置失败: ' + error.message, 'error');
|
||||
|
||||
// 加载失败时,使用默认配置
|
||||
const defaultConfig = {
|
||||
API_KEYS: [''],
|
||||
ALLOWED_TOKENS: [''],
|
||||
IMAGE_MODELS: ['gemini-1.5-pro-latest'],
|
||||
SEARCH_MODELS: ['gemini-1.5-flash-latest'],
|
||||
FILTERED_MODELS: ['gemini-1.0-pro-latest'],
|
||||
UPLOAD_PROVIDER: 'smms'
|
||||
};
|
||||
|
||||
populateForm(defaultConfig);
|
||||
toggleProviderConfig('smms');
|
||||
}
|
||||
}
|
||||
|
||||
// 填充表单
|
||||
function populateForm(config) {
|
||||
for (const [key, value] of Object.entries(config)) {
|
||||
// 首先检查是否是数组类型
|
||||
if (Array.isArray(value)) {
|
||||
const container = document.getElementById(`${key}_container`);
|
||||
if (container) {
|
||||
// 清除现有项
|
||||
const existingItems = container.querySelectorAll('.array-item');
|
||||
existingItems.forEach(item => item.remove());
|
||||
// 添加数组项
|
||||
value.forEach(item => {
|
||||
// 确保只添加非空字符串项(如果需要)
|
||||
// if (item && typeof item === 'string' && item.trim() !== '') {
|
||||
addArrayItemWithValue(key, item);
|
||||
// }
|
||||
});
|
||||
}
|
||||
// 处理完数组后,跳过本次循环的剩余部分
|
||||
continue;
|
||||
}
|
||||
|
||||
// 如果不是数组,再尝试查找对应的单个元素
|
||||
const element = document.getElementById(key);
|
||||
if (element) {
|
||||
if (typeof value === 'boolean') {
|
||||
element.checked = value;
|
||||
} else {
|
||||
// 处理其他类型 (确保 value 不是 null 或 undefined)
|
||||
element.value = value ?? ''; // 使用空字符串作为默认值
|
||||
}
|
||||
}
|
||||
// 如果既不是数组,也找不到对应 ID 的元素,则忽略该配置项
|
||||
}
|
||||
|
||||
// 初始化上传提供商配置 (保持不变)
|
||||
const uploadProvider = document.getElementById('UPLOAD_PROVIDER');
|
||||
if (uploadProvider) {
|
||||
toggleProviderConfig(uploadProvider.value);
|
||||
}
|
||||
}
|
||||
|
||||
// --- 新增:处理批量添加 API Key 的逻辑 ---
|
||||
function handleBulkAddApiKeys() {
|
||||
const apiKeyBulkInput = document.getElementById('apiKeyBulkInput');
|
||||
const apiKeyContainer = document.getElementById('API_KEYS_container');
|
||||
const apiKeyModal = document.getElementById('apiKeyModal');
|
||||
|
||||
if (!apiKeyBulkInput || !apiKeyContainer || !apiKeyModal) return;
|
||||
|
||||
const bulkText = apiKeyBulkInput.value;
|
||||
const keyRegex = /AIzaSy\S{33}/g; // 全局匹配
|
||||
const extractedKeys = bulkText.match(keyRegex) || [];
|
||||
|
||||
// 获取当前已有的 keys
|
||||
const currentKeyInputs = apiKeyContainer.querySelectorAll('.array-input');
|
||||
const currentKeys = Array.from(currentKeyInputs).map(input => input.value).filter(key => key.trim() !== '');
|
||||
|
||||
// 合并并去重
|
||||
const combinedKeys = new Set([...currentKeys, ...extractedKeys]);
|
||||
const uniqueKeys = Array.from(combinedKeys);
|
||||
|
||||
// 清空现有列表显示
|
||||
const existingItems = apiKeyContainer.querySelectorAll('.array-item');
|
||||
existingItems.forEach(item => item.remove());
|
||||
|
||||
// 重新填充列表
|
||||
uniqueKeys.forEach(key => {
|
||||
addArrayItemWithValue('API_KEYS', key);
|
||||
});
|
||||
|
||||
// 关闭模态框
|
||||
apiKeyModal.classList.remove('show');
|
||||
showNotification(`添加/更新了 ${uniqueKeys.length} 个唯一密钥`, 'success');
|
||||
}
|
||||
|
||||
// --- 新增:处理 API Key 搜索的逻辑 ---
|
||||
function handleApiKeySearch() {
|
||||
const apiKeySearchInput = document.getElementById('apiKeySearchInput');
|
||||
const apiKeyContainer = document.getElementById('API_KEYS_container');
|
||||
|
||||
if (!apiKeySearchInput || !apiKeyContainer) return;
|
||||
|
||||
const searchTerm = apiKeySearchInput.value.toLowerCase();
|
||||
const keyItems = apiKeyContainer.querySelectorAll('.array-item');
|
||||
|
||||
keyItems.forEach(item => {
|
||||
const input = item.querySelector('.array-input');
|
||||
if (input) {
|
||||
const key = input.value.toLowerCase();
|
||||
if (key.includes(searchTerm)) {
|
||||
item.style.display = 'flex'; // 或者 'block',取决于你的布局
|
||||
} else {
|
||||
item.style.display = 'none';
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
// --- 新增:处理批量删除 API Key 的逻辑 ---
|
||||
function handleBulkDeleteApiKeys() {
|
||||
const bulkDeleteTextarea = document.getElementById('bulkDeleteApiKeyInput'); // Use the textarea ID
|
||||
const apiKeyContainer = document.getElementById('API_KEYS_container');
|
||||
const bulkDeleteModal = document.getElementById('bulkDeleteApiKeyModal');
|
||||
|
||||
if (!bulkDeleteTextarea || !apiKeyContainer || !bulkDeleteModal) return;
|
||||
|
||||
const bulkText = bulkDeleteTextarea.value;
|
||||
if (!bulkText.trim()) {
|
||||
showNotification('请粘贴需要删除的 API 密钥', 'warning');
|
||||
return;
|
||||
}
|
||||
|
||||
// Use the same regex as for adding keys to extract keys to delete
|
||||
const keyRegex = /AIzaSy\S{33}/g;
|
||||
const keysToDelete = new Set(bulkText.match(keyRegex) || []); // Create a Set for efficient lookup
|
||||
|
||||
if (keysToDelete.size === 0) {
|
||||
showNotification('未在输入内容中提取到有效的 API 密钥格式', 'warning');
|
||||
// Optionally clear the textarea or keep it as is
|
||||
// bulkDeleteTextarea.value = '';
|
||||
return;
|
||||
}
|
||||
|
||||
const keyItems = apiKeyContainer.querySelectorAll('.array-item');
|
||||
let deleteCount = 0;
|
||||
|
||||
keyItems.forEach(item => {
|
||||
const input = item.querySelector('.array-input');
|
||||
// Check if the input exists and its value is in the set of keys to delete
|
||||
if (input && keysToDelete.has(input.value)) {
|
||||
item.remove(); // Remove the entire array item element
|
||||
deleteCount++;
|
||||
}
|
||||
});
|
||||
|
||||
// Close the modal
|
||||
bulkDeleteModal.classList.remove('show');
|
||||
|
||||
// Provide feedback
|
||||
if (deleteCount > 0) {
|
||||
showNotification(`成功删除了 ${deleteCount} 个匹配的密钥`, 'success');
|
||||
} else {
|
||||
// This message implies keys were extracted but not found in the current list
|
||||
showNotification('列表中未找到您输入的任何密钥进行删除', 'info');
|
||||
}
|
||||
|
||||
// Clear the textarea after processing
|
||||
bulkDeleteTextarea.value = '';
|
||||
}
|
||||
|
||||
// 切换标签
|
||||
function switchTab(tabId) {
|
||||
// 更新标签按钮状态
|
||||
const tabButtons = document.querySelectorAll('.tab-btn');
|
||||
tabButtons.forEach(button => {
|
||||
if (button.getAttribute('data-tab') === tabId) {
|
||||
// 激活状态:主色背景,白色文字,添加阴影
|
||||
button.classList.remove('bg-white', 'bg-opacity-50', 'text-gray-700', 'hover:bg-opacity-70');
|
||||
button.classList.add('bg-primary-600', 'text-white', 'shadow-md');
|
||||
} else {
|
||||
// 非激活状态:白色背景,灰色文字,无阴影
|
||||
button.classList.remove('bg-primary-600', 'text-white', 'shadow-md');
|
||||
button.classList.add('bg-white', 'bg-opacity-50', 'text-gray-700', 'hover:bg-opacity-70');
|
||||
}
|
||||
});
|
||||
|
||||
// 更新内容区域
|
||||
const sections = document.querySelectorAll('.config-section');
|
||||
sections.forEach(section => {
|
||||
if (section.id === `${tabId}-section`) {
|
||||
section.classList.add('active');
|
||||
} else {
|
||||
section.classList.remove('active');
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
// 切换上传提供商配置
|
||||
function toggleProviderConfig(provider) {
|
||||
const providerConfigs = document.querySelectorAll('.provider-config');
|
||||
providerConfigs.forEach(config => {
|
||||
if (config.getAttribute('data-provider') === provider) {
|
||||
config.classList.add('active');
|
||||
} else {
|
||||
config.classList.remove('active');
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
// 添加数组项
|
||||
function addArrayItem(key) {
|
||||
const container = document.getElementById(`${key}_container`);
|
||||
if (!container) return;
|
||||
|
||||
addArrayItemWithValue(key, '');
|
||||
}
|
||||
|
||||
// 添加带值的数组项
|
||||
function addArrayItemWithValue(key, value) {
|
||||
const container = document.getElementById(`${key}_container`);
|
||||
if (!container) return;
|
||||
|
||||
const arrayItem = document.createElement('div');
|
||||
// 主容器使用 Flexbox
|
||||
arrayItem.className = 'array-item flex items-center mb-2 gap-2'; // 添加 gap-2 来分隔元素
|
||||
|
||||
// 创建一个包装器 div 来包含输入框和生成按钮
|
||||
const inputWrapper = document.createElement('div');
|
||||
// 这个包装器占据主要空间,并使用 Flexbox
|
||||
inputWrapper.className = 'flex items-center flex-grow border border-gray-300 rounded-md focus-within:border-primary-500 focus-within:ring focus-within:ring-primary-200 focus-within:ring-opacity-50';
|
||||
|
||||
const input = document.createElement('input');
|
||||
input.type = 'text';
|
||||
input.name = `${key}[]`;
|
||||
input.value = value;
|
||||
// 输入框占据包装器内的主要空间,移除边框和圆角,因为包装器已有
|
||||
input.className = 'array-input flex-grow px-3 py-2 border-none rounded-l-md focus:outline-none'; // 移除右侧圆角
|
||||
|
||||
inputWrapper.appendChild(input); // 将输入框添加到包装器
|
||||
|
||||
// 只为 ALLOWED_TOKENS 添加生成按钮
|
||||
if (key === 'ALLOWED_TOKENS') {
|
||||
const generateBtn = document.createElement('button');
|
||||
generateBtn.type = 'button';
|
||||
// 按钮样式,放在输入框右侧,有背景和内边距,调整颜色
|
||||
generateBtn.className = 'generate-btn px-2 py-2 text-gray-500 hover:text-primary-600 focus:outline-none rounded-r-md bg-gray-100 hover:bg-gray-200 transition-colors'; // 添加背景和右侧圆角
|
||||
generateBtn.innerHTML = '<i class="fas fa-dice"></i>';
|
||||
generateBtn.title = '生成随机令牌';
|
||||
generateBtn.addEventListener('click', function() {
|
||||
const newToken = generateRandomToken();
|
||||
input.value = newToken;
|
||||
showNotification('已生成新令牌', 'success');
|
||||
});
|
||||
inputWrapper.appendChild(generateBtn); // 将生成按钮添加到包装器
|
||||
} else {
|
||||
// 如果不是 ALLOWED_TOKENS,确保输入框有右侧圆角
|
||||
input.classList.add('rounded-r-md');
|
||||
}
|
||||
|
||||
const removeBtn = document.createElement('button');
|
||||
removeBtn.type = 'button';
|
||||
// 删除按钮样式,保持不变
|
||||
removeBtn.className = 'remove-btn text-gray-400 hover:text-red-500 focus:outline-none transition-colors duration-150';
|
||||
removeBtn.innerHTML = '<i class="fas fa-trash-alt"></i>';
|
||||
removeBtn.title = '删除';
|
||||
removeBtn.addEventListener('click', function() {
|
||||
arrayItem.remove();
|
||||
});
|
||||
|
||||
// 将包装器(包含输入框和可能的生成按钮)和删除按钮添加到主容器
|
||||
arrayItem.appendChild(inputWrapper);
|
||||
arrayItem.appendChild(removeBtn);
|
||||
|
||||
// 插入到容器末尾
|
||||
container.appendChild(arrayItem);
|
||||
}
|
||||
|
||||
// 收集表单数据
|
||||
function collectFormData() {
|
||||
const formData = {};
|
||||
|
||||
// 处理普通输入
|
||||
const inputs = document.querySelectorAll('input[type="text"], input[type="number"], select');
|
||||
inputs.forEach(input => {
|
||||
if (!input.name.includes('[]')) {
|
||||
if (input.type === 'number') {
|
||||
formData[input.name] = parseFloat(input.value);
|
||||
} else {
|
||||
formData[input.name] = input.value;
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
// 处理复选框
|
||||
const checkboxes = document.querySelectorAll('input[type="checkbox"]');
|
||||
checkboxes.forEach(checkbox => {
|
||||
formData[checkbox.name] = checkbox.checked;
|
||||
});
|
||||
|
||||
// 处理数组
|
||||
const arrayContainers = document.querySelectorAll('.array-container');
|
||||
arrayContainers.forEach(container => {
|
||||
const key = container.id.replace('_container', '');
|
||||
const arrayInputs = container.querySelectorAll('.array-input');
|
||||
formData[key] = Array.from(arrayInputs).map(input => input.value).filter(value => value.trim() !== '');
|
||||
});
|
||||
|
||||
return formData;
|
||||
}
|
||||
|
||||
// 辅助函数:停止定时任务
|
||||
async function stopScheduler() {
|
||||
try {
|
||||
const response = await fetch('/api/scheduler/stop', { method: 'POST' });
|
||||
if (!response.ok) {
|
||||
console.warn(`停止定时任务失败: ${response.status}`);
|
||||
} else {
|
||||
console.log('定时任务已停止');
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('调用停止定时任务API时出错:', error);
|
||||
}
|
||||
}
|
||||
|
||||
// 辅助函数:启动定时任务
|
||||
async function startScheduler() {
|
||||
try {
|
||||
const response = await fetch('/api/scheduler/start', { method: 'POST' });
|
||||
if (!response.ok) {
|
||||
console.warn(`启动定时任务失败: ${response.status}`);
|
||||
} else {
|
||||
console.log('定时任务已启动');
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('调用启动定时任务API时出错:', error);
|
||||
}
|
||||
}
|
||||
|
||||
// 保存配置
|
||||
async function saveConfig() {
|
||||
try {
|
||||
const formData = collectFormData();
|
||||
|
||||
showNotification('正在保存配置...', 'info');
|
||||
|
||||
// 1. 停止定时任务
|
||||
await stopScheduler();
|
||||
|
||||
const response = await fetch('/api/config', {
|
||||
method: 'PUT',
|
||||
headers: {
|
||||
'Content-Type': 'application/json'
|
||||
},
|
||||
body: JSON.stringify(formData)
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
const errorData = await response.json();
|
||||
throw new Error(errorData.detail || `HTTP error! status: ${response.status}`);
|
||||
}
|
||||
|
||||
const result = await response.json();
|
||||
|
||||
// 移除居中的 saveStatus 提示
|
||||
|
||||
showNotification('配置保存成功', 'success');
|
||||
|
||||
// 3. 启动新的定时任务
|
||||
await startScheduler();
|
||||
|
||||
} catch (error) {
|
||||
console.error('保存配置失败:', error);
|
||||
// 保存失败时,也尝试重启定时任务,以防万一
|
||||
await startScheduler();
|
||||
// 移除居中的 saveStatus 提示
|
||||
|
||||
showNotification('保存配置失败: ' + error.message, 'error');
|
||||
}
|
||||
}
|
||||
|
||||
// 重置配置 (现在只负责打开模态框)
|
||||
function resetConfig(event) {
|
||||
// 阻止事件冒泡和默认行为
|
||||
if (event) {
|
||||
event.preventDefault();
|
||||
event.stopPropagation();
|
||||
}
|
||||
|
||||
console.log('resetConfig called. Event target:', event ? event.target.id : 'No event');
|
||||
|
||||
// 确保只有当事件来自重置按钮时才显示模态框
|
||||
if (!event || event.target.id === 'resetBtn' || event.currentTarget.id === 'resetBtn') {
|
||||
const resetConfirmModal = document.getElementById('resetConfirmModal');
|
||||
if (resetConfirmModal) {
|
||||
resetConfirmModal.classList.add('show');
|
||||
} else {
|
||||
// Fallback if modal doesn't exist for some reason
|
||||
console.error("Reset confirmation modal not found! Falling back to default confirm.");
|
||||
// Fallback to original confirm behavior
|
||||
if (!confirm('确定要重置所有配置吗?这将恢复到默认值。')) {
|
||||
return;
|
||||
}
|
||||
// If confirmed, proceed with the reset logic directly (less ideal)
|
||||
executeReset();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// --- 新增:将实际重置逻辑提取到一个单独的函数 ---
|
||||
async function executeReset() {
|
||||
try {
|
||||
showNotification('正在重置配置...', 'info');
|
||||
|
||||
// 1. 停止定时任务
|
||||
await stopScheduler();
|
||||
const response = await fetch('/api/config/reset', { method: 'POST' });
|
||||
if (!response.ok) {
|
||||
throw new Error(`HTTP error! status: ${response.status}`);
|
||||
}
|
||||
const config = await response.json();
|
||||
populateForm(config);
|
||||
showNotification('配置已重置为默认值', 'success');
|
||||
|
||||
// 3. 启动新的定时任务
|
||||
await startScheduler();
|
||||
|
||||
} catch (error) {
|
||||
console.error('重置配置失败:', error);
|
||||
showNotification('重置配置失败: ' + error.message, 'error');
|
||||
// 重置失败时,也尝试重启定时任务
|
||||
await startScheduler();
|
||||
}
|
||||
}
|
||||
// 显示通知
|
||||
function showNotification(message, type = 'info') {
|
||||
const notification = document.getElementById('notification');
|
||||
notification.textContent = message;
|
||||
|
||||
// 设置适当的样式
|
||||
if (type === 'error') {
|
||||
notification.classList.add('bg-danger-500');
|
||||
notification.classList.remove('bg-black');
|
||||
} else {
|
||||
notification.classList.remove('bg-danger-500');
|
||||
notification.classList.add('bg-black');
|
||||
|
||||
// 可以为不同类型设置不同的颜色
|
||||
if (type === 'success') {
|
||||
notification.style.backgroundColor = '#22c55e'; // 绿色
|
||||
} else if (type === 'info') {
|
||||
notification.style.backgroundColor = '#3b82f6'; // 蓝色
|
||||
} else if (type === 'warning') {
|
||||
notification.style.backgroundColor = '#f59e0b'; // 橙色
|
||||
}
|
||||
}
|
||||
|
||||
// 应用过渡效果 - 与keys_status.js中一致
|
||||
notification.style.opacity = "1";
|
||||
notification.style.transform = "translate(-50%, 0)";
|
||||
|
||||
// 设置自动消失
|
||||
setTimeout(() => {
|
||||
notification.style.opacity = "0";
|
||||
notification.style.transform = "translate(-50%, 10px)";
|
||||
}, 3000);
|
||||
}
|
||||
|
||||
// 刷新页面
|
||||
function refreshPage(button) {
|
||||
button.classList.add('loading');
|
||||
location.reload();
|
||||
}
|
||||
|
||||
// 滚动到顶部
|
||||
function scrollToTop() {
|
||||
window.scrollTo({
|
||||
top: 0,
|
||||
behavior: 'smooth'
|
||||
});
|
||||
}
|
||||
|
||||
// 滚动到底部
|
||||
function scrollToBottom() {
|
||||
window.scrollTo({
|
||||
top: document.body.scrollHeight,
|
||||
behavior: 'smooth'
|
||||
});
|
||||
}
|
||||
|
||||
// 切换滚动按钮显示
|
||||
function toggleScrollButtons() {
|
||||
const scrollButtons = document.querySelector('.scroll-buttons');
|
||||
|
||||
if (window.scrollY > 200) {
|
||||
scrollButtons.style.display = 'flex';
|
||||
} else {
|
||||
scrollButtons.style.display = 'none';
|
||||
}
|
||||
}
|
||||
|
||||
// --- 新增:生成随机令牌函数 ---
|
||||
function generateRandomToken() {
|
||||
const characters = 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789_';
|
||||
const length = 48;
|
||||
let result = 'sk-';
|
||||
const charactersLength = characters.length;
|
||||
for (let i = 0; i < length; i++) {
|
||||
result += characters.charAt(Math.floor(Math.random() * charactersLength));
|
||||
}
|
||||
return result;
|
||||
}
|
||||
// --- 结束:生成随机令牌函数 ---
|
||||
564
app/static/js/error_logs.js
Normal file
@@ -0,0 +1,564 @@
|
||||
// 错误日志页面JavaScript (Updated for new structure, no Bootstrap)
|
||||
|
||||
// 页面滚动功能
|
||||
function scrollToTop() {
|
||||
window.scrollTo({ top: 0, behavior: 'smooth' });
|
||||
}
|
||||
|
||||
function scrollToBottom() {
|
||||
window.scrollTo({ top: document.body.scrollHeight, behavior: 'smooth' });
|
||||
}
|
||||
|
||||
// Refresh function removed as the buttons are gone.
|
||||
// If refresh functionality is needed elsewhere, it can be triggered directly by calling loadErrorLogs().
|
||||
|
||||
// 全局变量
|
||||
let currentPage = 1;
|
||||
let pageSize = 10;
|
||||
// let totalPages = 1; // totalPages will be calculated dynamically based on API response if available, or based on fetched data length
|
||||
let errorLogs = []; // Store fetched logs for details view
|
||||
let currentSearch = { // Store current search parameters
|
||||
key: '',
|
||||
error: '',
|
||||
startDate: '',
|
||||
endDate: ''
|
||||
};
|
||||
|
||||
// DOM Elements Cache
|
||||
let pageSizeSelector;
|
||||
// let refreshBtn; // Removed, as the button is deleted
|
||||
let tableBody;
|
||||
let paginationElement;
|
||||
let loadingIndicator;
|
||||
let noDataMessage;
|
||||
let errorMessage;
|
||||
let logDetailModal;
|
||||
let modalCloseBtns; // Collection of close buttons for the modal
|
||||
let keySearchInput;
|
||||
let errorSearchInput;
|
||||
let startDateInput;
|
||||
let endDateInput;
|
||||
let searchBtn;
|
||||
let pageInput; // 新增:页码输入框
|
||||
let goToPageBtn; // 新增:跳转按钮
|
||||
|
||||
// 页面加载完成后执行
|
||||
document.addEventListener('DOMContentLoaded', function() {
|
||||
// Cache DOM elements
|
||||
pageSizeSelector = document.getElementById('pageSize');
|
||||
// refreshBtn = document.getElementById('refreshBtn'); // Removed
|
||||
tableBody = document.getElementById('errorLogsTable');
|
||||
paginationElement = document.getElementById('pagination');
|
||||
loadingIndicator = document.getElementById('loadingIndicator');
|
||||
noDataMessage = document.getElementById('noDataMessage');
|
||||
errorMessage = document.getElementById('errorMessage');
|
||||
logDetailModal = document.getElementById('logDetailModal');
|
||||
// Get all elements that should close the modal
|
||||
modalCloseBtns = document.querySelectorAll('#closeLogDetailModalBtn, #closeModalFooterBtn');
|
||||
keySearchInput = document.getElementById('keySearch');
|
||||
errorSearchInput = document.getElementById('errorSearch');
|
||||
startDateInput = document.getElementById('startDate');
|
||||
endDateInput = document.getElementById('endDate');
|
||||
searchBtn = document.getElementById('searchBtn');
|
||||
pageInput = document.getElementById('pageInput'); // 新增
|
||||
goToPageBtn = document.getElementById('goToPageBtn'); // 新增
|
||||
|
||||
// Initialize page size selector
|
||||
if (pageSizeSelector) {
|
||||
pageSizeSelector.value = pageSize;
|
||||
pageSizeSelector.addEventListener('change', function() {
|
||||
pageSize = parseInt(this.value);
|
||||
currentPage = 1; // Reset to first page
|
||||
loadErrorLogs();
|
||||
});
|
||||
}
|
||||
|
||||
// Refresh button event listener removed
|
||||
|
||||
// Initialize search button
|
||||
if (searchBtn) {
|
||||
searchBtn.addEventListener('click', function() {
|
||||
// Update search parameters from input fields
|
||||
currentSearch.key = keySearchInput ? keySearchInput.value.trim() : '';
|
||||
currentSearch.error = errorSearchInput ? errorSearchInput.value.trim() : '';
|
||||
currentSearch.startDate = startDateInput ? startDateInput.value : '';
|
||||
currentSearch.endDate = endDateInput ? endDateInput.value : '';
|
||||
currentPage = 1; // Reset to first page on new search
|
||||
loadErrorLogs();
|
||||
});
|
||||
}
|
||||
|
||||
// Initialize modal close buttons
|
||||
if (logDetailModal && modalCloseBtns) {
|
||||
modalCloseBtns.forEach(btn => {
|
||||
btn.addEventListener('click', closeLogDetailModal);
|
||||
});
|
||||
// Optional: Close modal if clicking outside the content
|
||||
logDetailModal.addEventListener('click', function(event) {
|
||||
if (event.target === logDetailModal) {
|
||||
closeLogDetailModal();
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
// Initial load of error logs
|
||||
loadErrorLogs();
|
||||
|
||||
// Add event listeners for copy buttons inside the modal
|
||||
setupCopyButtons();
|
||||
|
||||
// 新增:为页码跳转按钮添加事件监听器
|
||||
if (goToPageBtn && pageInput) {
|
||||
goToPageBtn.addEventListener('click', function() {
|
||||
const targetPage = parseInt(pageInput.value);
|
||||
// 需要获取总页数来验证输入
|
||||
// 暂时无法直接获取 totalPages,需要在 updatePagination 中存储或重新计算
|
||||
// 简单的验证:必须是正整数
|
||||
if (!isNaN(targetPage) && targetPage >= 1) {
|
||||
// 理想情况下,应检查 targetPage <= totalPages
|
||||
// 但 totalPages 可能未知,所以暂时只跳转
|
||||
currentPage = targetPage;
|
||||
loadErrorLogs();
|
||||
pageInput.value = ''; // 清空输入框
|
||||
} else {
|
||||
showNotification('请输入有效的页码', 'error', 2000);
|
||||
pageInput.value = ''; // 清空无效输入
|
||||
}
|
||||
});
|
||||
// 允许按 Enter 键跳转
|
||||
pageInput.addEventListener('keypress', function(event) {
|
||||
if (event.key === 'Enter') {
|
||||
goToPageBtn.click(); // 触发按钮点击
|
||||
}
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
// Fallback copy function using document.execCommand
|
||||
function fallbackCopyTextToClipboard(text) {
|
||||
const textArea = document.createElement("textarea");
|
||||
textArea.value = text;
|
||||
|
||||
// Avoid scrolling to bottom
|
||||
textArea.style.top = "0";
|
||||
textArea.style.left = "0";
|
||||
textArea.style.position = "fixed";
|
||||
|
||||
document.body.appendChild(textArea);
|
||||
textArea.focus();
|
||||
textArea.select();
|
||||
|
||||
let successful = false;
|
||||
try {
|
||||
successful = document.execCommand('copy');
|
||||
} catch (err) {
|
||||
console.error('Fallback copy failed:', err);
|
||||
successful = false;
|
||||
}
|
||||
|
||||
document.body.removeChild(textArea);
|
||||
return successful;
|
||||
}
|
||||
|
||||
// Helper function to handle feedback after copy attempt (both modern and fallback)
|
||||
function handleCopyResult(buttonElement, success) {
|
||||
const originalIcon = buttonElement.querySelector('i').className; // Store original icon class
|
||||
const iconElement = buttonElement.querySelector('i');
|
||||
if (success) {
|
||||
iconElement.className = 'fas fa-check text-success-500'; // Use checkmark icon class
|
||||
showNotification('已复制到剪贴板', 'success', 2000);
|
||||
} else {
|
||||
iconElement.className = 'fas fa-times text-danger-500'; // Use error icon class
|
||||
showNotification('复制失败', 'error', 3000);
|
||||
}
|
||||
setTimeout(() => { iconElement.className = originalIcon; }, success ? 2000 : 3000); // Restore original icon class
|
||||
}
|
||||
|
||||
// Function to set up copy button listeners (using modern API with fallback)
|
||||
function setupCopyButtons() {
|
||||
const copyButtons = document.querySelectorAll('.copy-btn');
|
||||
copyButtons.forEach(button => {
|
||||
button.addEventListener('click', function() {
|
||||
const targetId = this.getAttribute('data-target');
|
||||
const targetElement = document.getElementById(targetId);
|
||||
|
||||
if (targetElement) {
|
||||
const textToCopy = targetElement.textContent;
|
||||
let copySuccess = false;
|
||||
|
||||
// Try modern clipboard API first (requires HTTPS or localhost)
|
||||
if (navigator.clipboard && window.isSecureContext) {
|
||||
navigator.clipboard.writeText(textToCopy).then(() => {
|
||||
handleCopyResult(this, true); // Use helper for feedback
|
||||
}).catch(err => {
|
||||
console.error('Clipboard API failed, attempting fallback:', err);
|
||||
// Attempt fallback if modern API fails
|
||||
copySuccess = fallbackCopyTextToClipboard(textToCopy);
|
||||
handleCopyResult(this, copySuccess); // Use helper for feedback
|
||||
});
|
||||
} else {
|
||||
// Use fallback if modern API is not available or context is insecure
|
||||
console.warn("Clipboard API not available or context insecure. Using fallback copy method.");
|
||||
copySuccess = fallbackCopyTextToClipboard(textToCopy);
|
||||
handleCopyResult(this, copySuccess); // Use helper for feedback
|
||||
}
|
||||
} else {
|
||||
console.error('Target element not found:', targetId);
|
||||
showNotification('复制出错:找不到目标元素', 'error');
|
||||
}
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
// 加载错误日志数据
|
||||
async function loadErrorLogs() {
|
||||
showLoading(true);
|
||||
showError(false);
|
||||
showNoData(false);
|
||||
|
||||
const offset = (currentPage - 1) * pageSize;
|
||||
|
||||
try {
|
||||
// Construct the API URL with search parameters
|
||||
let apiUrl = `/api/logs/errors?limit=${pageSize}&offset=${offset}`;
|
||||
if (currentSearch.key) {
|
||||
apiUrl += `&key_search=${encodeURIComponent(currentSearch.key)}`;
|
||||
}
|
||||
if (currentSearch.error) {
|
||||
apiUrl += `&error_search=${encodeURIComponent(currentSearch.error)}`;
|
||||
}
|
||||
if (currentSearch.startDate) {
|
||||
apiUrl += `&start_date=${encodeURIComponent(currentSearch.startDate)}`;
|
||||
}
|
||||
if (currentSearch.endDate) {
|
||||
apiUrl += `&end_date=${encodeURIComponent(currentSearch.endDate)}`;
|
||||
}
|
||||
|
||||
const response = await fetch(apiUrl);
|
||||
if (!response.ok) {
|
||||
// Try to get error message from response body
|
||||
let errorData;
|
||||
try {
|
||||
errorData = await response.json();
|
||||
} catch (e) {
|
||||
// Ignore if response is not JSON
|
||||
}
|
||||
throw new Error(errorData?.detail || `网络响应异常: ${response.statusText}`);
|
||||
}
|
||||
const data = await response.json();
|
||||
// API 现在返回 { logs: [], total: count }
|
||||
if (data && Array.isArray(data.logs)) {
|
||||
errorLogs = data.logs; // Store the list data (contains error_code)
|
||||
renderErrorLogs(errorLogs);
|
||||
updatePagination(errorLogs.length, data.total || -1);
|
||||
} else {
|
||||
throw new Error('无法识别的API响应格式');
|
||||
}
|
||||
|
||||
|
||||
showLoading(false);
|
||||
|
||||
if (errorLogs.length === 0) {
|
||||
showNoData(true);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('获取错误日志失败:', error);
|
||||
showLoading(false);
|
||||
showError(true, error.message); // Show specific error message
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
// 渲染错误日志表格
|
||||
function renderErrorLogs(logs) {
|
||||
if (!tableBody) return;
|
||||
tableBody.innerHTML = ''; // Clear previous entries
|
||||
|
||||
if (!logs || logs.length === 0) {
|
||||
// Handled by showNoData
|
||||
return;
|
||||
}
|
||||
|
||||
const startIndex = (currentPage - 1) * pageSize; // Calculate starting index for the current page
|
||||
|
||||
logs.forEach((log, index) => { // Add index parameter to forEach
|
||||
const row = document.createElement('tr');
|
||||
const sequentialId = startIndex + index + 1; // Calculate sequential ID for the current page
|
||||
// Format date
|
||||
let formattedTime = 'N/A';
|
||||
try {
|
||||
const requestTime = new Date(log.request_time);
|
||||
if (!isNaN(requestTime)) {
|
||||
formattedTime = requestTime.toLocaleString('zh-CN', {
|
||||
year: 'numeric', month: '2-digit', day: '2-digit',
|
||||
hour: '2-digit', minute: '2-digit', second: '2-digit', hour12: false
|
||||
});
|
||||
}
|
||||
} catch (e) { console.error("Error formatting date:", e); }
|
||||
|
||||
|
||||
// Display error code instead of truncated log
|
||||
const errorCodeContent = log.error_code || '无';
|
||||
|
||||
// Mask the Gemini key for display in the table
|
||||
const maskKey = (key) => {
|
||||
if (!key || key.length < 8) return key || '无'; // Don't mask short keys or null
|
||||
return `${key.substring(0, 4)}...${key.substring(key.length - 4)}`;
|
||||
};
|
||||
const maskedKey = maskKey(log.gemini_key);
|
||||
|
||||
row.innerHTML = `
|
||||
<td>${sequentialId}</td> <!-- Use sequential ID -->
|
||||
<td title="${log.gemini_key || ''}">${maskedKey}</td>
|
||||
<td>${log.error_type || '未知'}</td>
|
||||
<td class="error-code-content" title="${log.error_code || ''}">${errorCodeContent}</td>
|
||||
<td>${log.model_name || '未知'}</td>
|
||||
<td>${formattedTime}</td>
|
||||
<td>
|
||||
<button class="btn-view-details" data-log-id="${log.id}">
|
||||
查看详情
|
||||
</button>
|
||||
</td>
|
||||
`;
|
||||
|
||||
tableBody.appendChild(row);
|
||||
});
|
||||
|
||||
// Add event listeners to new 'View Details' buttons
|
||||
document.querySelectorAll('.btn-view-details').forEach(button => {
|
||||
button.addEventListener('click', function() {
|
||||
const logId = parseInt(this.getAttribute('data-log-id'));
|
||||
showLogDetails(logId);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
// 显示错误日志详情 (从 API 获取)
|
||||
async function showLogDetails(logId) {
|
||||
if (!logDetailModal) return;
|
||||
|
||||
// Show loading state in modal (optional)
|
||||
// Clear previous content and show a spinner or message
|
||||
document.getElementById('modalGeminiKey').textContent = '加载中...';
|
||||
document.getElementById('modalErrorType').textContent = '加载中...';
|
||||
document.getElementById('modalErrorLog').textContent = '加载中...';
|
||||
document.getElementById('modalRequestMsg').textContent = '加载中...';
|
||||
document.getElementById('modalModelName').textContent = '加载中...';
|
||||
document.getElementById('modalRequestTime').textContent = '加载中...';
|
||||
|
||||
logDetailModal.classList.add('show');
|
||||
document.body.style.overflow = 'hidden'; // Prevent body scrolling
|
||||
|
||||
try {
|
||||
const response = await fetch(`/api/logs/errors/${logId}/details`);
|
||||
if (!response.ok) {
|
||||
let errorData;
|
||||
try {
|
||||
errorData = await response.json();
|
||||
} catch (e) { /* ignore */ }
|
||||
throw new Error(errorData?.detail || `获取日志详情失败: ${response.statusText}`);
|
||||
}
|
||||
const logDetails = await response.json();
|
||||
|
||||
// Format date
|
||||
let formattedTime = 'N/A';
|
||||
try {
|
||||
const requestTime = new Date(logDetails.request_time);
|
||||
if (!isNaN(requestTime)) {
|
||||
formattedTime = requestTime.toLocaleString('zh-CN', {
|
||||
year: 'numeric', month: '2-digit', day: '2-digit',
|
||||
hour: '2-digit', minute: '2-digit', second: '2-digit', hour12: false
|
||||
});
|
||||
}
|
||||
} catch (e) { console.error("Error formatting date:", e); }
|
||||
|
||||
// Format request message (handle potential JSON)
|
||||
let formattedRequestMsg = '无';
|
||||
if (logDetails.request_msg) {
|
||||
try {
|
||||
if (typeof logDetails.request_msg === 'object' && logDetails.request_msg !== null) {
|
||||
formattedRequestMsg = JSON.stringify(logDetails.request_msg, null, 2);
|
||||
} else if (typeof logDetails.request_msg === 'string') {
|
||||
// Try parsing if it looks like JSON, otherwise display as string
|
||||
const trimmedMsg = logDetails.request_msg.trim();
|
||||
if (trimmedMsg.startsWith('{') || trimmedMsg.startsWith('[')) {
|
||||
formattedRequestMsg = JSON.stringify(JSON.parse(logDetails.request_msg), null, 2);
|
||||
} else {
|
||||
formattedRequestMsg = logDetails.request_msg;
|
||||
}
|
||||
} else {
|
||||
formattedRequestMsg = String(logDetails.request_msg);
|
||||
}
|
||||
} catch (e) {
|
||||
formattedRequestMsg = String(logDetails.request_msg); // Fallback
|
||||
console.warn("Could not parse request_msg as JSON:", e);
|
||||
}
|
||||
}
|
||||
|
||||
// Populate modal content with fetched details
|
||||
document.getElementById('modalGeminiKey').textContent = logDetails.gemini_key || '无';
|
||||
document.getElementById('modalErrorType').textContent = logDetails.error_type || '未知';
|
||||
document.getElementById('modalErrorLog').textContent = logDetails.error_log || '无'; // Full error log
|
||||
document.getElementById('modalRequestMsg').textContent = formattedRequestMsg; // Full request message
|
||||
document.getElementById('modalModelName').textContent = logDetails.model_name || '未知';
|
||||
document.getElementById('modalRequestTime').textContent = formattedTime;
|
||||
|
||||
} catch (error) {
|
||||
console.error('获取日志详情失败:', error);
|
||||
// Show error in modal
|
||||
document.getElementById('modalGeminiKey').textContent = '错误';
|
||||
document.getElementById('modalErrorType').textContent = '错误';
|
||||
document.getElementById('modalErrorLog').textContent = `加载失败: ${error.message}`;
|
||||
document.getElementById('modalRequestMsg').textContent = '错误';
|
||||
document.getElementById('modalModelName').textContent = '错误';
|
||||
document.getElementById('modalRequestTime').textContent = '错误';
|
||||
// Optionally show a notification
|
||||
showNotification(`加载日志详情失败: ${error.message}`, 'error', 5000);
|
||||
}
|
||||
}
|
||||
|
||||
// Close Log Detail Modal
|
||||
function closeLogDetailModal() {
|
||||
if (logDetailModal) {
|
||||
logDetailModal.classList.remove('show');
|
||||
// Optional: Restore body scrolling
|
||||
document.body.style.overflow = '';
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
// 更新分页控件
|
||||
function updatePagination(currentItemCount, totalItems) {
|
||||
if (!paginationElement) return;
|
||||
paginationElement.innerHTML = ''; // Clear existing pagination
|
||||
|
||||
// Calculate total pages only if totalItems is known and valid
|
||||
let totalPages = 1;
|
||||
if (totalItems >= 0) {
|
||||
totalPages = Math.max(1, Math.ceil(totalItems / pageSize));
|
||||
} else if (currentItemCount < pageSize && currentPage === 1) {
|
||||
// If less items than page size fetched on page 1, assume it's the only page
|
||||
totalPages = 1;
|
||||
} else {
|
||||
// If total is unknown and more items might exist, we can't build full pagination
|
||||
// We can show Prev/Next based on current page and if items were returned
|
||||
console.warn("Total item count unknown, pagination will be limited.");
|
||||
// Basic Prev/Next for unknown total
|
||||
addPaginationLink(paginationElement, '«', currentPage > 1, () => { currentPage--; loadErrorLogs(); });
|
||||
addPaginationLink(paginationElement, currentPage.toString(), true, null, true); // Current page number (non-clickable)
|
||||
addPaginationLink(paginationElement, '»', currentItemCount === pageSize, () => { currentPage++; loadErrorLogs(); }); // Next enabled if full page was returned
|
||||
return; // Exit here for limited pagination
|
||||
}
|
||||
|
||||
|
||||
const maxPagesToShow = 5; // Max number of page links to show
|
||||
let startPage = Math.max(1, currentPage - Math.floor(maxPagesToShow / 2));
|
||||
let endPage = Math.min(totalPages, startPage + maxPagesToShow - 1);
|
||||
|
||||
// Adjust startPage if endPage reaches the limit first
|
||||
if (endPage === totalPages) {
|
||||
startPage = Math.max(1, endPage - maxPagesToShow + 1);
|
||||
}
|
||||
|
||||
|
||||
// Previous Button
|
||||
addPaginationLink(paginationElement, '«', currentPage > 1, () => { currentPage--; loadErrorLogs(); });
|
||||
|
||||
// First Page Button
|
||||
if (startPage > 1) {
|
||||
addPaginationLink(paginationElement, '1', true, () => { currentPage = 1; loadErrorLogs(); });
|
||||
if (startPage > 2) {
|
||||
addPaginationLink(paginationElement, '...', false); // Ellipsis
|
||||
}
|
||||
}
|
||||
|
||||
// Page Number Buttons
|
||||
for (let i = startPage; i <= endPage; i++) {
|
||||
addPaginationLink(paginationElement, i.toString(), true, () => { currentPage = i; loadErrorLogs(); }, i === currentPage);
|
||||
}
|
||||
|
||||
// Last Page Button
|
||||
if (endPage < totalPages) {
|
||||
if (endPage < totalPages - 1) {
|
||||
addPaginationLink(paginationElement, '...', false); // Ellipsis
|
||||
}
|
||||
addPaginationLink(paginationElement, totalPages.toString(), true, () => { currentPage = totalPages; loadErrorLogs(); });
|
||||
}
|
||||
|
||||
|
||||
// Next Button
|
||||
addPaginationLink(paginationElement, '»', currentPage < totalPages, () => { currentPage++; loadErrorLogs(); });
|
||||
}
|
||||
|
||||
// Helper function to add pagination links
|
||||
function addPaginationLink(parentElement, text, enabled, clickHandler, isActive = false) {
|
||||
const pageItem = document.createElement('li');
|
||||
// 移除 'page-item' 和 'active' 类,使用 Tailwind 类进行样式化
|
||||
// pageItem.className = `page-item ${!enabled ? 'disabled' : ''} ${isActive ? 'active' : ''}`;
|
||||
|
||||
const pageLink = document.createElement('a');
|
||||
// 使用 Tailwind 类进行样式化
|
||||
pageLink.className = `px-3 py-1 rounded-md text-sm transition duration-150 ease-in-out ${
|
||||
isActive
|
||||
? 'bg-primary-600 text-white font-semibold shadow-md cursor-default' // 突出当前页样式
|
||||
: enabled
|
||||
? 'bg-white text-gray-700 hover:bg-primary-50 hover:text-primary-600 border border-gray-300' // 可点击页码样式
|
||||
: 'bg-gray-100 text-gray-400 cursor-not-allowed border border-gray-200' // 禁用状态样式 (如 '...')
|
||||
}`;
|
||||
pageLink.href = '#'; // Prevent page jump
|
||||
pageLink.innerHTML = text;
|
||||
|
||||
if (enabled && clickHandler) {
|
||||
pageLink.addEventListener('click', function(e) {
|
||||
e.preventDefault();
|
||||
clickHandler();
|
||||
});
|
||||
} else if (!enabled) {
|
||||
pageLink.addEventListener('click', e => e.preventDefault()); // Prevent click on disabled or active
|
||||
} else if (isActive) {
|
||||
pageLink.addEventListener('click', e => e.preventDefault()); // Prevent click on active page
|
||||
}
|
||||
|
||||
// 不再需要 li 元素,直接将 a 元素添加到父元素
|
||||
// pageItem.appendChild(pageLink);
|
||||
parentElement.appendChild(pageLink);
|
||||
}
|
||||
|
||||
|
||||
// 显示/隐藏状态指示器 (using 'active' class)
|
||||
function showLoading(show) {
|
||||
if (loadingIndicator) loadingIndicator.style.display = show ? 'block' : 'none';
|
||||
}
|
||||
|
||||
function showNoData(show) {
|
||||
if (noDataMessage) noDataMessage.style.display = show ? 'block' : 'none';
|
||||
}
|
||||
|
||||
function showError(show, message = '加载错误日志失败,请稍后重试。') {
|
||||
if (errorMessage) {
|
||||
errorMessage.style.display = show ? 'block' : 'none';
|
||||
if (show) {
|
||||
// Update the error message content
|
||||
const p = errorMessage.querySelector('p');
|
||||
if (p) p.textContent = message;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Function to show temporary status notifications (like copy success)
|
||||
function showNotification(message, type = 'success', duration = 3000) {
|
||||
const notificationElement = document.getElementById('copyStatus'); // Or a more generic ID if needed
|
||||
if (!notificationElement) return;
|
||||
|
||||
notificationElement.textContent = message;
|
||||
notificationElement.className = `notification ${type} show`; // Add 'show' class
|
||||
|
||||
// Hide after duration
|
||||
setTimeout(() => {
|
||||
notificationElement.classList.remove('show');
|
||||
}, duration);
|
||||
}
|
||||
|
||||
// Example Usage (if copy functionality is added later):
|
||||
// showNotification('密钥已复制!', 'success');
|
||||
// showNotification('复制失败!', 'error');
|
||||
@@ -1,3 +1,5 @@
|
||||
// 统计数据可视化交互效果
|
||||
|
||||
function copyToClipboard(text) {
|
||||
if (navigator.clipboard && navigator.clipboard.writeText) {
|
||||
return navigator.clipboard.writeText(text);
|
||||
@@ -25,43 +27,64 @@ function copyToClipboard(text) {
|
||||
}
|
||||
}
|
||||
|
||||
// 添加统计项动画效果
|
||||
function initStatItemAnimations() {
|
||||
const statItems = document.querySelectorAll('.stat-item');
|
||||
statItems.forEach(item => {
|
||||
item.addEventListener('mouseenter', () => {
|
||||
item.style.transform = 'scale(1.05)';
|
||||
const icon = item.querySelector('.stat-icon');
|
||||
if (icon) {
|
||||
icon.style.opacity = '0.2';
|
||||
icon.style.transform = 'scale(1.1) rotate(0deg)';
|
||||
}
|
||||
});
|
||||
|
||||
item.addEventListener('mouseleave', () => {
|
||||
item.style.transform = '';
|
||||
const icon = item.querySelector('.stat-icon');
|
||||
if (icon) {
|
||||
icon.style.opacity = '';
|
||||
icon.style.transform = '';
|
||||
}
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
function copyKeys(type) {
|
||||
const keys = Array.from(document.querySelectorAll(`#${type}Keys .key-text`)).map(span => span.textContent.trim());
|
||||
const jsonKeys = JSON.stringify(keys);
|
||||
|
||||
copyToClipboard(jsonKeys)
|
||||
// 选择对应区域内所有可见的 li 元素下的 key-text span
|
||||
const visibleKeyItems = document.querySelectorAll(`#${type}Keys li:not([style*="display: none"]) .key-text`);
|
||||
const keys = Array.from(visibleKeyItems).map(span => span.dataset.fullKey);
|
||||
|
||||
if (keys.length === 0) {
|
||||
showNotification('没有可复制的筛选后密钥', 'warning'); // 修改提示信息
|
||||
return;
|
||||
}
|
||||
|
||||
const keysText = keys.join('\n');
|
||||
|
||||
copyToClipboard(keysText)
|
||||
.then(() => {
|
||||
showCopyStatus(`已成功复制${type === 'valid' ? '有效' : '无效'}密钥到剪贴板`);
|
||||
showNotification(`已成功复制 ${keys.length} 个筛选后的${type === 'valid' ? '有效' : '无效'}密钥`); // 修改提示信息
|
||||
})
|
||||
.catch((err) => {
|
||||
console.error('无法复制文本: ', err);
|
||||
showCopyStatus('复制失败,请重试');
|
||||
showNotification('复制失败,请重试', 'error');
|
||||
});
|
||||
}
|
||||
|
||||
function copyKey(key) {
|
||||
copyToClipboard(key)
|
||||
.then(() => {
|
||||
showCopyStatus(`已成功复制密钥到剪贴板`);
|
||||
showNotification(`已成功复制密钥`);
|
||||
})
|
||||
.catch((err) => {
|
||||
console.error('无法复制文本: ', err);
|
||||
showCopyStatus('复制失败,请重试');
|
||||
showNotification('复制失败,请重试', 'error');
|
||||
});
|
||||
}
|
||||
|
||||
function showCopyStatus(message, type = 'success') {
|
||||
const statusElement = document.getElementById('copyStatus');
|
||||
statusElement.textContent = message;
|
||||
statusElement.className = type; // 设置样式类
|
||||
statusElement.style.opacity = 1;
|
||||
setTimeout(() => {
|
||||
statusElement.style.opacity = 0;
|
||||
setTimeout(() => {
|
||||
statusElement.className = ''; // 清除样式类
|
||||
}, 300);
|
||||
}, 2000);
|
||||
}
|
||||
// 移除 showCopyStatus 函数,因为它已被 showNotification 替代
|
||||
|
||||
async function verifyKey(key, button) {
|
||||
try {
|
||||
@@ -70,59 +93,286 @@ async function verifyKey(key, button) {
|
||||
const originalHtml = button.innerHTML;
|
||||
button.innerHTML = '<i class="fas fa-spinner fa-spin"></i> 验证中';
|
||||
|
||||
const response = await fetch(`/gemini/v1beta/verify-key/${key}`, {
|
||||
try {
|
||||
const response = await fetch(`/gemini/v1beta/verify-key/${key}`, {
|
||||
method: 'POST'
|
||||
});
|
||||
const data = await response.json();
|
||||
|
||||
// 根据验证结果更新UI并显示模态提示框
|
||||
if (data.success || data.status === 'valid') {
|
||||
// 验证成功,显示成功结果
|
||||
button.style.backgroundColor = '#27ae60';
|
||||
// 使用结果模态框显示成功消息
|
||||
showResultModal(true, '密钥验证成功');
|
||||
// 模态框关闭时会自动刷新页面
|
||||
} else {
|
||||
// 验证失败,显示失败结果
|
||||
const errorMsg = data.error || '密钥无效';
|
||||
button.style.backgroundColor = '#e74c3c';
|
||||
// 使用结果模态框显示失败消息,但不自动刷新页面
|
||||
showResultModal(false, '密钥验证失败: ' + errorMsg, true); // 改为true以在关闭时刷新
|
||||
}
|
||||
} catch (fetchError) {
|
||||
console.error('API请求失败:', fetchError);
|
||||
showResultModal(false, '验证请求失败: ' + fetchError.message, true); // 改为true以在关闭时刷新
|
||||
} finally {
|
||||
// 1秒后恢复按钮原始状态
|
||||
setTimeout(() => {
|
||||
button.innerHTML = originalHtml;
|
||||
button.disabled = false;
|
||||
button.style.backgroundColor = '';
|
||||
}, 1000);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('验证失败:', error);
|
||||
button.disabled = false;
|
||||
button.innerHTML = '<i class="fas fa-check-circle"></i> 验证';
|
||||
showResultModal(false, '验证处理失败: ' + error.message, true); // 改为true以在关闭时刷新
|
||||
}
|
||||
}
|
||||
|
||||
async function resetKeyFailCount(key, button) {
|
||||
try {
|
||||
// 禁用按钮并显示加载状态
|
||||
button.disabled = true;
|
||||
const originalHtml = button.innerHTML;
|
||||
button.innerHTML = '<i class="fas fa-spinner fa-spin"></i> 重置中';
|
||||
|
||||
const response = await fetch(`/gemini/v1beta/reset-fail-count/${key}`, {
|
||||
method: 'POST'
|
||||
});
|
||||
const data = await response.json();
|
||||
|
||||
// 根据验证结果更新UI
|
||||
if (data.status === 'valid') {
|
||||
showCopyStatus('密钥验证成功', 'success');
|
||||
// 根据重置结果更新UI
|
||||
if (data.success) {
|
||||
showNotification('失败计数重置成功');
|
||||
// 成功时保留绿色背景一会儿
|
||||
button.style.backgroundColor = '#27ae60';
|
||||
// 稍后刷新页面
|
||||
setTimeout(() => location.reload(), 1000);
|
||||
} else {
|
||||
showCopyStatus('密钥验证失败', 'error');
|
||||
const errorMsg = data.message || '重置失败';
|
||||
showNotification('重置失败: ' + errorMsg, 'error');
|
||||
// 失败时保留红色背景一会儿
|
||||
button.style.backgroundColor = '#e74c3c';
|
||||
}
|
||||
|
||||
// 3秒后恢复按钮原始状态
|
||||
setTimeout(() => {
|
||||
button.innerHTML = originalHtml;
|
||||
button.disabled = false;
|
||||
button.style.backgroundColor = '';
|
||||
}, 3000);
|
||||
// 立即恢复按钮状态,除非成功或失败时需要短暂显示颜色
|
||||
if (!data.success) {
|
||||
// 如果失败,1秒后恢复按钮
|
||||
setTimeout(() => {
|
||||
button.innerHTML = originalHtml;
|
||||
button.disabled = false;
|
||||
button.style.backgroundColor = '';
|
||||
}, 1000);
|
||||
} else {
|
||||
// 如果成功,在刷新前恢复按钮(虽然用户可能看不到)
|
||||
button.innerHTML = originalHtml;
|
||||
button.disabled = false;
|
||||
// 背景色会在刷新时重置
|
||||
}
|
||||
|
||||
} catch (error) {
|
||||
console.error('验证失败:', error);
|
||||
showCopyStatus('验证请求失败', 'error');
|
||||
console.error('重置失败:', error);
|
||||
showNotification('重置请求失败: ' + error.message, 'error');
|
||||
// 确保在捕获到错误时恢复按钮状态
|
||||
button.innerHTML = originalHtml; // 需要确保 originalHtml 在此作用域可用
|
||||
button.disabled = false;
|
||||
button.innerHTML = '<i class="fas fa-check-circle"></i> 验证';
|
||||
button.innerHTML = '<i class="fas fa-redo-alt"></i> 重置';
|
||||
}
|
||||
}
|
||||
|
||||
function showResetModal(type) {
|
||||
const modalElement = document.getElementById('resetModal');
|
||||
const titleElement = document.getElementById('resetModalTitle');
|
||||
const messageElement = document.getElementById('resetModalMessage');
|
||||
const confirmButton = document.getElementById('confirmResetBtn');
|
||||
|
||||
// 获取当前筛选后可见的、且包含 data-fail-count 属性的密钥数量
|
||||
// 根据密钥类型选择合适的选择器
|
||||
let keySelector;
|
||||
if (type === 'valid') {
|
||||
// 对于有效密钥,可能需要基于失败次数筛选,保留 data-fail-count (虽然批量重置通常不需要筛选)
|
||||
// 如果批量重置有效密钥也应重置所有可见的,可以将此行改为下面 else 中的选择器
|
||||
keySelector = `#${type}Keys li[data-fail-count]:not([style*="display: none"])`;
|
||||
} else {
|
||||
// 对于无效密钥,我们想要重置所有可见的无效密钥,不依赖 data-fail-count
|
||||
keySelector = `#${type}Keys li:not([style*="display: none"])`;
|
||||
}
|
||||
const visibleKeyItems = document.querySelectorAll(keySelector);
|
||||
const count = visibleKeyItems.length;
|
||||
|
||||
// 设置标题和消息
|
||||
titleElement.textContent = '批量重置失败次数';
|
||||
if (count > 0) {
|
||||
messageElement.textContent = `确定要批量重置筛选出的 ${count} 个${type === 'valid' ? '有效' : '无效'}密钥的失败次数吗?`;
|
||||
confirmButton.disabled = false; // 确保按钮可用
|
||||
} else {
|
||||
messageElement.textContent = `当前没有筛选出可重置的${type === 'valid' ? '有效' : '无效'}密钥。`;
|
||||
confirmButton.disabled = true; // 没有可重置的密钥时禁用确认按钮
|
||||
}
|
||||
|
||||
|
||||
// 设置确认按钮事件
|
||||
confirmButton.onclick = () => executeResetAll(type);
|
||||
|
||||
// 显示模态框
|
||||
modalElement.classList.remove('hidden');
|
||||
}
|
||||
|
||||
function closeResetModal() {
|
||||
document.getElementById('resetModal').classList.add('hidden');
|
||||
}
|
||||
|
||||
// 触发显示模态框
|
||||
function resetAllKeysFailCount(type, event) {
|
||||
// 阻止事件冒泡
|
||||
if (event) {
|
||||
event.stopPropagation();
|
||||
}
|
||||
|
||||
// 显示模态确认框
|
||||
showResetModal(type);
|
||||
}
|
||||
|
||||
// 执行批量重置
|
||||
// 关闭模态框并根据参数决定是否刷新页面
|
||||
function closeResultModal(reload = true) {
|
||||
document.getElementById('resultModal').classList.add('hidden');
|
||||
if (reload) {
|
||||
location.reload(); // 操作完成后刷新页面
|
||||
}
|
||||
}
|
||||
|
||||
// 显示操作结果模态框
|
||||
function showResultModal(success, message, autoReload = true) {
|
||||
const modalElement = document.getElementById('resultModal');
|
||||
const titleElement = document.getElementById('resultModalTitle');
|
||||
const messageElement = document.getElementById('resultModalMessage');
|
||||
const iconElement = document.getElementById('resultIcon');
|
||||
const confirmButton = document.getElementById('resultModalConfirmBtn');
|
||||
|
||||
// 设置标题
|
||||
titleElement.textContent = success ? '操作成功' : '操作失败';
|
||||
|
||||
// 设置图标
|
||||
if (success) {
|
||||
iconElement.innerHTML = '<i class="fas fa-check-circle text-success-500"></i>';
|
||||
iconElement.className = 'text-5xl mb-3 text-success-500';
|
||||
} else {
|
||||
iconElement.innerHTML = '<i class="fas fa-times-circle"></i>';
|
||||
iconElement.className = 'text-5xl mb-3 text-danger-500';
|
||||
}
|
||||
|
||||
// 设置消息
|
||||
// 支持长文本和换行,内容插入到div而不是p
|
||||
if (typeof message === 'string') {
|
||||
// 如果内容包含换行或长文本,自动转为可滚动
|
||||
messageElement.textContent = '';
|
||||
messageElement.innerText = message;
|
||||
} else if (message instanceof Node) {
|
||||
messageElement.innerHTML = '';
|
||||
messageElement.appendChild(message);
|
||||
} else {
|
||||
messageElement.textContent = String(message);
|
||||
}
|
||||
|
||||
// 设置确认按钮点击事件
|
||||
confirmButton.onclick = () => closeResultModal(autoReload);
|
||||
|
||||
// 显示模态框
|
||||
modalElement.classList.remove('hidden');
|
||||
}
|
||||
|
||||
async function executeResetAll(type) {
|
||||
try {
|
||||
// 关闭确认模态框
|
||||
closeResetModal();
|
||||
|
||||
// 找到对应的重置按钮以显示加载状态
|
||||
const resetButton = document.querySelector(`button[data-reset-type="${type}"]`);
|
||||
if (!resetButton) {
|
||||
showResultModal(false, `找不到${type === 'valid' ? '有效' : '无效'}密钥区域的批量重置按钮`);
|
||||
return;
|
||||
}
|
||||
|
||||
// 获取筛选后可见的密钥
|
||||
const visibleKeyItems = document.querySelectorAll(`#${type}Keys li:not([style*="display: none"]) .key-text`);
|
||||
const keysToReset = Array.from(visibleKeyItems).map(span => span.dataset.fullKey);
|
||||
|
||||
if (keysToReset.length === 0) {
|
||||
showNotification(`没有需要重置的筛选后${type === 'valid' ? '有效' : '无效'}密钥`, 'warning');
|
||||
return;
|
||||
}
|
||||
|
||||
// 禁用按钮并显示加载状态
|
||||
resetButton.disabled = true;
|
||||
const originalHtml = resetButton.innerHTML;
|
||||
resetButton.innerHTML = '<i class="fas fa-spinner fa-spin"></i> 重置中';
|
||||
|
||||
try {
|
||||
// 调用新的后端 API 来重置选定的密钥
|
||||
const response = await fetch(`/gemini/v1beta/reset-selected-fail-counts`, { // 假设的新 API 端点
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
body: JSON.stringify({ keys: keysToReset, key_type: type }) // 发送密钥列表和类型
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
// 尝试解析错误信息
|
||||
let errorMsg = `服务器返回错误: ${response.status}`;
|
||||
try {
|
||||
const errorData = await response.json();
|
||||
errorMsg = errorData.message || errorMsg;
|
||||
} catch (e) {
|
||||
// 如果解析失败,使用原始错误信息
|
||||
}
|
||||
|
||||
throw new Error(errorMsg);
|
||||
}
|
||||
|
||||
const data = await response.json();
|
||||
|
||||
// 根据重置结果显示模态框
|
||||
if (data.success) {
|
||||
const message = data.reset_count !== undefined ? // 检查 reset_count 是否存在
|
||||
`成功重置 ${data.reset_count} 个筛选后的${type === 'valid' ? '有效' : '无效'}密钥的失败次数` :
|
||||
`成功重置 ${keysToReset.length} 个筛选后的密钥`; // 如果后端没返回数量,使用前端计算的数量
|
||||
showResultModal(true, message); // 成功后刷新页面
|
||||
} else {
|
||||
const errorMsg = data.message || '批量重置失败';
|
||||
// 失败后不自动刷新页面,让用户看到错误信息
|
||||
showResultModal(false, '批量重置失败: ' + errorMsg, false);
|
||||
}
|
||||
} catch (fetchError) {
|
||||
console.error('API请求失败:', fetchError);
|
||||
showResultModal(false, '批量重置请求失败: ' + fetchError.message, false); // 失败后不自动刷新
|
||||
} finally {
|
||||
// 恢复按钮状态
|
||||
resetButton.innerHTML = originalHtml;
|
||||
resetButton.disabled = false;
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('批量重置处理失败:', error);
|
||||
showResultModal(false, '批量重置处理失败: ' + error.message, false); // 失败后不自动刷新
|
||||
}
|
||||
}
|
||||
|
||||
function scrollToTop() {
|
||||
const container = document.querySelector('.container');
|
||||
container.scrollTo({
|
||||
top: 0,
|
||||
behavior: 'smooth'
|
||||
});
|
||||
window.scrollTo({ top: 0, behavior: 'smooth' });
|
||||
}
|
||||
|
||||
function scrollToBottom() {
|
||||
const container = document.querySelector('.container');
|
||||
container.scrollTo({
|
||||
top: container.scrollHeight,
|
||||
behavior: 'smooth'
|
||||
});
|
||||
window.scrollTo({ top: document.body.scrollHeight, behavior: 'smooth' });
|
||||
}
|
||||
|
||||
// 移除这个函数,因为它可能正在干扰按钮的显示
|
||||
// HTML中已经设置了滚动按钮为flex显示,不需要JavaScript额外控制
|
||||
function updateScrollButtons() {
|
||||
const container = document.querySelector('.container');
|
||||
const scrollButtons = document.querySelector('.scroll-buttons');
|
||||
if (container.scrollHeight > container.clientHeight) {
|
||||
scrollButtons.style.display = 'flex';
|
||||
} else {
|
||||
scrollButtons.style.display = 'none';
|
||||
}
|
||||
// 不执行任何操作
|
||||
}
|
||||
|
||||
function refreshPage(button) {
|
||||
@@ -134,30 +384,388 @@ function refreshPage(button) {
|
||||
}, 300);
|
||||
}
|
||||
|
||||
// 重写切换区域显示/隐藏函数,以更好地支持新样式
|
||||
function toggleSection(header, sectionId) {
|
||||
const toggleIcon = header.querySelector('.toggle-icon');
|
||||
const content = header.nextElementSibling;
|
||||
|
||||
toggleIcon.classList.toggle('collapsed');
|
||||
content.classList.toggle('collapsed');
|
||||
if (toggleIcon && content) {
|
||||
// 添加旋转动画
|
||||
toggleIcon.classList.toggle('collapsed');
|
||||
|
||||
// 控制内容区域的可见性
|
||||
if (!content.classList.contains('collapsed')) {
|
||||
// 收起内容
|
||||
content.style.maxHeight = '0px';
|
||||
content.style.opacity = '0';
|
||||
content.style.overflow = 'hidden';
|
||||
content.classList.add('collapsed');
|
||||
|
||||
// 为动画添加延迟
|
||||
setTimeout(() => {
|
||||
content.style.padding = '0';
|
||||
}, 100);
|
||||
} else {
|
||||
// 展开内容
|
||||
content.classList.remove('collapsed');
|
||||
content.style.padding = '1rem';
|
||||
content.style.maxHeight = '2000px'; // 使用足够大的高度
|
||||
content.style.opacity = '1';
|
||||
|
||||
// 为动画添加延迟
|
||||
setTimeout(() => {
|
||||
content.style.overflow = 'visible';
|
||||
}, 300);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 筛选有效密钥(根据失败次数阈值)
|
||||
function filterValidKeys() {
|
||||
const thresholdInput = document.getElementById('failCountThreshold');
|
||||
const validKeyItems = document.querySelectorAll('#validKeys li');
|
||||
// 读取阈值,如果输入无效或为空,则默认为0(不过滤)
|
||||
const threshold = parseInt(thresholdInput.value, 10);
|
||||
const filterThreshold = isNaN(threshold) || threshold < 0 ? 0 : threshold;
|
||||
|
||||
validKeyItems.forEach(item => {
|
||||
const failCount = parseInt(item.dataset.failCount, 10);
|
||||
// 如果失败次数大于等于阈值,则显示,否则隐藏
|
||||
if (failCount >= filterThreshold) {
|
||||
item.style.display = ''; // 显示
|
||||
} else {
|
||||
item.style.display = 'none'; // 隐藏
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
// 初始化
|
||||
document.addEventListener('DOMContentLoaded', () => {
|
||||
// 检查滚动按钮
|
||||
updateScrollButtons();
|
||||
|
||||
// 监听展开/折叠事件
|
||||
document.querySelectorAll('.key-list h2').forEach(header => {
|
||||
header.addEventListener('click', () => {
|
||||
setTimeout(updateScrollButtons, 300);
|
||||
// 初始化统计区块动画
|
||||
initStatItemAnimations();
|
||||
|
||||
// 添加数字滚动动画效果
|
||||
const animateCounters = () => {
|
||||
const statValues = document.querySelectorAll('.stat-value');
|
||||
statValues.forEach(valueElement => {
|
||||
const finalValue = parseInt(valueElement.textContent, 10);
|
||||
if (!isNaN(finalValue)) {
|
||||
// 保存原始值以便稍后恢复
|
||||
if (!valueElement.dataset.originalValue) {
|
||||
valueElement.dataset.originalValue = valueElement.textContent;
|
||||
}
|
||||
|
||||
// 数字滚动动画
|
||||
let startValue = 0;
|
||||
const duration = 1500;
|
||||
const startTime = performance.now();
|
||||
|
||||
const updateCounter = (currentTime) => {
|
||||
const elapsedTime = currentTime - startTime;
|
||||
if (elapsedTime < duration) {
|
||||
const progress = elapsedTime / duration;
|
||||
// 使用缓动函数使动画更自然
|
||||
const easeOutValue = 1 - Math.pow(1 - progress, 3);
|
||||
const currentValue = Math.floor(easeOutValue * finalValue);
|
||||
valueElement.textContent = currentValue;
|
||||
requestAnimationFrame(updateCounter);
|
||||
} else {
|
||||
// 恢复为原始值,以确保准确性
|
||||
valueElement.textContent = valueElement.dataset.originalValue;
|
||||
}
|
||||
|
||||
|
||||
window.showVerifyModal = function(type, event) {
|
||||
// 阻止事件冒泡(如果从按钮点击触发)
|
||||
if (event) {
|
||||
event.stopPropagation();
|
||||
}
|
||||
|
||||
const modalElement = document.getElementById('verifyModal');
|
||||
const titleElement = document.getElementById('verifyModalTitle');
|
||||
const messageElement = document.getElementById('verifyModalMessage');
|
||||
const confirmButton = document.getElementById('confirmVerifyBtn');
|
||||
|
||||
// 获取当前筛选后可见的、且包含 data-fail-count 属性的密钥数量
|
||||
// 注意:对于验证,我们可能想验证所有筛选出的密钥,无论其 data-fail-count 如何,
|
||||
// 但为了与重置保持一致,并且通常只验证有效/无效列表中的项,我们保留 data-fail-count 检查。
|
||||
// 如果要验证所有可见项(包括没有 data-fail-count 的),可以移除 [data-fail-count] 选择器。
|
||||
const visibleKeyItems = document.querySelectorAll(`#${type}Keys li[data-fail-count]:not([style*="display: none"])`);
|
||||
const count = visibleKeyItems.length;
|
||||
|
||||
// 设置标题和消息
|
||||
titleElement.textContent = '批量验证密钥';
|
||||
if (count > 0) {
|
||||
messageElement.textContent = `确定要批量验证筛选出的 ${count} 个${type === 'valid' ? '有效' : '无效'}密钥吗?此操作可能需要一些时间。`;
|
||||
confirmButton.disabled = false; // 确保按钮可用
|
||||
} else {
|
||||
messageElement.textContent = `当前没有筛选出可验证的${type === 'valid' ? '有效' : '无效'}密钥。`;
|
||||
confirmButton.disabled = true; // 没有可验证的密钥时禁用确认按钮
|
||||
}
|
||||
|
||||
// 设置确认按钮事件
|
||||
confirmButton.onclick = () => executeVerifyAll(type);
|
||||
|
||||
// 显示模态框
|
||||
modalElement.classList.remove('hidden');
|
||||
}
|
||||
|
||||
window.closeVerifyModal = function() {
|
||||
document.getElementById('verifyModal').classList.add('hidden');
|
||||
}
|
||||
|
||||
window.executeVerifyAll = async function(type) {
|
||||
try {
|
||||
// 关闭确认模态框
|
||||
closeVerifyModal();
|
||||
|
||||
// 找到对应的验证按钮以显示加载状态 (需要给按钮添加 data-verify-type 属性)
|
||||
// 或者,我们可以暂时禁用所有按钮或显示一个全局加载指示器
|
||||
// 这里我们暂时只记录日志,实际UI反馈可以后续增强
|
||||
console.log(`Starting bulk verification for ${type} keys...`);
|
||||
|
||||
// 获取筛选后可见的密钥
|
||||
const visibleKeyItems = document.querySelectorAll(`#${type}Keys li[data-fail-count]:not([style*="display: none"]) .key-text`);
|
||||
const keysToVerify = Array.from(visibleKeyItems).map(span => span.dataset.fullKey);
|
||||
|
||||
if (keysToVerify.length === 0) {
|
||||
showNotification(`没有需要验证的筛选后${type === 'valid' ? '有效' : '无效'}密钥`, 'warning');
|
||||
return;
|
||||
}
|
||||
|
||||
// 显示一个通用的加载提示
|
||||
showNotification('开始批量验证,请稍候...', 'info');
|
||||
|
||||
// 调用新的后端 API 来验证选定的密钥
|
||||
const response = await fetch(`/gemini/v1beta/verify-selected-keys`, { // 假设的新 API 端点
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
body: JSON.stringify({ keys: keysToVerify }) // 只发送密钥列表
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
let errorMsg = `服务器返回错误: ${response.status}`;
|
||||
try {
|
||||
const errorData = await response.json();
|
||||
errorMsg = errorData.message || errorMsg;
|
||||
} catch (e) { /*忽略解析错误*/ }
|
||||
throw new Error(errorMsg);
|
||||
}
|
||||
|
||||
const data = await response.json();
|
||||
|
||||
// 根据验证结果显示模态框
|
||||
if (data.success) {
|
||||
// 可以在这里构建更详细的消息,例如显示多少有效多少无效
|
||||
const message = `批量验证完成。有效: ${data.valid_count}, 无效: ${data.invalid_count}。页面即将刷新。`;
|
||||
// 验证成功后通常需要刷新页面以更新状态
|
||||
showResultModal(true, message, true); // autoReload = true
|
||||
} else {
|
||||
const errorMsg = data.message || '批量验证失败';
|
||||
// 失败后不自动刷新
|
||||
showResultModal(false, '批量验证失败: ' + errorMsg, false);
|
||||
}
|
||||
|
||||
} catch (error) {
|
||||
console.error('批量验证处理失败:', error);
|
||||
// 失败后不自动刷新
|
||||
showResultModal(false, '批量验证处理失败: ' + error.message, false);
|
||||
} finally {
|
||||
// 可以在这里移除加载指示器
|
||||
console.log("Bulk verification process finished.");
|
||||
}
|
||||
}
|
||||
|
||||
};
|
||||
|
||||
requestAnimationFrame(updateCounter);
|
||||
}
|
||||
});
|
||||
};
|
||||
|
||||
// 在页面加载后启动数字动画
|
||||
setTimeout(animateCounters, 300);
|
||||
|
||||
// 添加卡片悬停效果
|
||||
document.querySelectorAll('.stats-card').forEach(card => {
|
||||
card.addEventListener('mouseenter', () => {
|
||||
card.classList.add('shadow-lg');
|
||||
card.style.transform = 'translateY(-2px)';
|
||||
});
|
||||
|
||||
card.addEventListener('mouseleave', () => {
|
||||
card.classList.remove('shadow-lg');
|
||||
card.style.transform = '';
|
||||
});
|
||||
});
|
||||
|
||||
// 监听展开/折叠事件
|
||||
document.querySelectorAll('.stats-card-title').forEach(header => {
|
||||
header.addEventListener('click', () => {
|
||||
const card = header.closest('.stats-card');
|
||||
if (card) {
|
||||
card.classList.toggle('active');
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
// 添加筛选输入框事件监听
|
||||
const thresholdInput = document.getElementById('failCountThreshold');
|
||||
if (thresholdInput) {
|
||||
// 使用 'input' 事件实时响应输入变化
|
||||
thresholdInput.addEventListener('input', filterValidKeys);
|
||||
// 初始加载时应用一次筛选
|
||||
filterValidKeys();
|
||||
}
|
||||
|
||||
// --- 批量验证相关函数 (明确挂载到 window) ---
|
||||
|
||||
window.showVerifyModal = function(type, event) {
|
||||
// 阻止事件冒泡(如果从按钮点击触发)
|
||||
if (event) {
|
||||
event.stopPropagation();
|
||||
}
|
||||
|
||||
const modalElement = document.getElementById('verifyModal');
|
||||
const titleElement = document.getElementById('verifyModalTitle');
|
||||
const messageElement = document.getElementById('verifyModalMessage');
|
||||
const confirmButton = document.getElementById('confirmVerifyBtn');
|
||||
|
||||
// 获取当前筛选后可见的、且包含 data-fail-count 属性的密钥数量
|
||||
// 注意:对于验证,我们可能想验证所有筛选出的密钥,无论其 data-fail-count 如何,
|
||||
// 但为了与重置保持一致,并且通常只验证有效/无效列表中的项,我们保留 data-fail-count 检查。
|
||||
// 如果要验证所有可见项(包括没有 data-fail-count 的),可以移除 [data-fail-count] 选择器。
|
||||
const visibleKeyItems = document.querySelectorAll(`#${type}Keys li[data-fail-count]:not([style*="display: none"])`);
|
||||
const count = visibleKeyItems.length;
|
||||
|
||||
// 设置标题和消息
|
||||
titleElement.textContent = '批量验证密钥';
|
||||
if (count > 0) {
|
||||
messageElement.textContent = `确定要批量验证筛选出的 ${count} 个${type === 'valid' ? '有效' : '无效'}密钥吗?此操作可能需要一些时间。`;
|
||||
confirmButton.disabled = false; // 确保按钮可用
|
||||
} else {
|
||||
messageElement.textContent = `当前没有筛选出可验证的${type === 'valid' ? '有效' : '无效'}密钥。`;
|
||||
confirmButton.disabled = true; // 没有可验证的密钥时禁用确认按钮
|
||||
}
|
||||
|
||||
// 设置确认按钮事件
|
||||
confirmButton.onclick = () => executeVerifyAll(type);
|
||||
|
||||
// 显示模态框
|
||||
modalElement.classList.remove('hidden');
|
||||
}
|
||||
|
||||
window.closeVerifyModal = function() {
|
||||
document.getElementById('verifyModal').classList.add('hidden');
|
||||
}
|
||||
|
||||
window.executeVerifyAll = async function(type) {
|
||||
try {
|
||||
// 关闭确认模态框
|
||||
closeVerifyModal();
|
||||
|
||||
// 找到对应的验证按钮以显示加载状态 (需要给按钮添加 data-verify-type 属性)
|
||||
// 或者,我们可以暂时禁用所有按钮或显示一个全局加载指示器
|
||||
// 这里我们暂时只记录日志,实际UI反馈可以后续增强
|
||||
console.log(`Starting bulk verification for ${type} keys...`);
|
||||
|
||||
// 获取筛选后可见的密钥
|
||||
const visibleKeyItems = document.querySelectorAll(`#${type}Keys li[data-fail-count]:not([style*="display: none"]) .key-text`);
|
||||
const keysToVerify = Array.from(visibleKeyItems).map(span => span.dataset.fullKey);
|
||||
|
||||
if (keysToVerify.length === 0) {
|
||||
showNotification(`没有需要验证的筛选后${type === 'valid' ? '有效' : '无效'}密钥`, 'warning');
|
||||
return;
|
||||
}
|
||||
|
||||
// 显示一个通用的加载提示
|
||||
showNotification('开始批量验证,请稍候...', 'info');
|
||||
|
||||
// 调用新的后端 API 来验证选定的密钥
|
||||
const response = await fetch(`/gemini/v1beta/verify-selected-keys`, { // 假设的新 API 端点
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
body: JSON.stringify({ keys: keysToVerify }) // 只发送密钥列表
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
let errorMsg = `服务器返回错误: ${response.status}`;
|
||||
try {
|
||||
const errorData = await response.json();
|
||||
errorMsg = errorData.message || errorMsg;
|
||||
} catch (e) { /*忽略解析错误*/ }
|
||||
throw new Error(errorMsg);
|
||||
}
|
||||
|
||||
const data = await response.json();
|
||||
|
||||
// 根据验证结果显示模态框
|
||||
if (data.success) {
|
||||
// 可以在这里构建更详细的消息,例如显示多少有效多少无效
|
||||
const message = `批量验证完成。有效: ${data.valid_count}, 无效: ${data.invalid_count}。页面即将刷新。`;
|
||||
// 验证成功后通常需要刷新页面以更新状态
|
||||
showResultModal(true, message, true); // autoReload = true
|
||||
} else {
|
||||
const errorMsg = data.message || '批量验证失败';
|
||||
// 失败后不自动刷新
|
||||
showResultModal(false, '批量验证失败: ' + errorMsg, false);
|
||||
}
|
||||
|
||||
} catch (error) {
|
||||
console.error('批量验证处理失败:', error);
|
||||
// 失败后不自动刷新
|
||||
showResultModal(false, '批量验证处理失败: ' + error.message, false);
|
||||
} finally {
|
||||
// 可以在这里移除加载指示器
|
||||
console.log("Bulk verification process finished.");
|
||||
}
|
||||
}
|
||||
|
||||
// 更新版权年份
|
||||
const copyrightYear = document.querySelector('.copyright script');
|
||||
if (copyrightYear) {
|
||||
copyrightYear.textContent = new Date().getFullYear();
|
||||
// --- 滚动和页面控制 ---
|
||||
// --- 自动刷新控制 ---
|
||||
const autoRefreshToggle = document.getElementById('autoRefreshToggle');
|
||||
const autoRefreshIntervalTime = 60000; // 60秒
|
||||
let autoRefreshTimer = null;
|
||||
|
||||
function startAutoRefresh() {
|
||||
if (autoRefreshTimer) return; // 防止重复启动
|
||||
console.log('启动自动刷新...');
|
||||
autoRefreshTimer = setInterval(() => {
|
||||
console.log('自动刷新 keys_status 页面...');
|
||||
location.reload();
|
||||
}, autoRefreshIntervalTime);
|
||||
}
|
||||
|
||||
function stopAutoRefresh() {
|
||||
if (autoRefreshTimer) {
|
||||
console.log('停止自动刷新...');
|
||||
clearInterval(autoRefreshTimer);
|
||||
autoRefreshTimer = null;
|
||||
}
|
||||
}
|
||||
|
||||
if (autoRefreshToggle) {
|
||||
// 从 localStorage 读取状态并初始化
|
||||
const isAutoRefreshEnabled = localStorage.getItem('autoRefreshEnabled') === 'true';
|
||||
autoRefreshToggle.checked = isAutoRefreshEnabled;
|
||||
if (isAutoRefreshEnabled) {
|
||||
startAutoRefresh();
|
||||
}
|
||||
|
||||
// 添加事件监听器
|
||||
autoRefreshToggle.addEventListener('change', () => {
|
||||
if (autoRefreshToggle.checked) {
|
||||
localStorage.setItem('autoRefreshEnabled', 'true');
|
||||
startAutoRefresh();
|
||||
} else {
|
||||
localStorage.setItem('autoRefreshEnabled', 'false');
|
||||
stopAutoRefresh();
|
||||
}
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
@@ -173,3 +781,136 @@ if ('serviceWorker' in navigator) {
|
||||
});
|
||||
});
|
||||
}
|
||||
function toggleKeyVisibility(button) {
|
||||
const keyContainer = button.closest('.flex.items-center.gap-1');
|
||||
const keyTextSpan = keyContainer.querySelector('.key-text');
|
||||
const eyeIcon = button.querySelector('i');
|
||||
const fullKey = keyTextSpan.dataset.fullKey;
|
||||
const maskedKey = fullKey.substring(0, 4) + '...' + fullKey.substring(fullKey.length - 4);
|
||||
|
||||
if (keyTextSpan.textContent === maskedKey) {
|
||||
keyTextSpan.textContent = fullKey;
|
||||
eyeIcon.classList.remove('fa-eye');
|
||||
eyeIcon.classList.add('fa-eye-slash');
|
||||
button.title = '隐藏密钥';
|
||||
} else {
|
||||
keyTextSpan.textContent = maskedKey;
|
||||
eyeIcon.classList.remove('fa-eye-slash');
|
||||
eyeIcon.classList.add('fa-eye');
|
||||
button.title = '显示密钥';
|
||||
}
|
||||
}
|
||||
|
||||
// --- API 调用详情模态框逻辑 ---
|
||||
|
||||
// 显示 API 调用详情模态框
|
||||
async function showApiCallDetails(period) {
|
||||
const modal = document.getElementById('apiCallDetailsModal');
|
||||
const contentArea = document.getElementById('apiCallDetailsContent');
|
||||
const titleElement = document.getElementById('apiCallDetailsModalTitle');
|
||||
|
||||
if (!modal || !contentArea || !titleElement) {
|
||||
console.error('无法找到 API 调用详情模态框元素');
|
||||
showNotification('无法显示详情,页面元素缺失', 'error');
|
||||
return;
|
||||
}
|
||||
|
||||
// 设置标题
|
||||
let periodText = '';
|
||||
switch (period) {
|
||||
case '1m': periodText = '最近 1 分钟'; break;
|
||||
case '1h': periodText = '最近 1 小时'; break;
|
||||
case '24h': periodText = '最近 24 小时'; break;
|
||||
default: periodText = '指定时间段';
|
||||
}
|
||||
titleElement.textContent = `${periodText} API 调用详情`;
|
||||
|
||||
// 显示模态框并设置加载状态
|
||||
modal.classList.remove('hidden');
|
||||
contentArea.innerHTML = `
|
||||
<div class="text-center py-10">
|
||||
<i class="fas fa-spinner fa-spin text-primary-600 text-3xl"></i>
|
||||
<p class="text-gray-500 mt-2">加载中...</p>
|
||||
</div>`;
|
||||
|
||||
try {
|
||||
// 调用后端 API 获取数据
|
||||
const response = await fetch(`/api/stats/details?period=${period}`);
|
||||
if (!response.ok) {
|
||||
throw new Error(`服务器错误: ${response.status}`);
|
||||
}
|
||||
const data = await response.json();
|
||||
|
||||
// 渲染数据
|
||||
renderApiCallDetails(data, contentArea);
|
||||
|
||||
} catch (error) {
|
||||
console.error('获取 API 调用详情失败:', error);
|
||||
contentArea.innerHTML = `
|
||||
<div class="text-center py-10 text-danger-500">
|
||||
<i class="fas fa-exclamation-triangle text-3xl"></i>
|
||||
<p class="mt-2">加载失败: ${error.message}</p>
|
||||
</div>`;
|
||||
}
|
||||
}
|
||||
|
||||
// 关闭 API 调用详情模态框
|
||||
function closeApiCallDetailsModal() {
|
||||
const modal = document.getElementById('apiCallDetailsModal');
|
||||
if (modal) {
|
||||
modal.classList.add('hidden');
|
||||
}
|
||||
}
|
||||
|
||||
// 渲染 API 调用详情到模态框
|
||||
function renderApiCallDetails(data, container) {
|
||||
if (!data || data.length === 0) {
|
||||
container.innerHTML = `
|
||||
<div class="text-center py-10 text-gray-500">
|
||||
<i class="fas fa-info-circle text-3xl"></i>
|
||||
<p class="mt-2">该时间段内没有 API 调用记录。</p>
|
||||
</div>`;
|
||||
return;
|
||||
}
|
||||
|
||||
// 创建表格
|
||||
let tableHtml = `
|
||||
<table class="min-w-full divide-y divide-gray-200">
|
||||
<thead class="bg-gray-50">
|
||||
<tr>
|
||||
<th scope="col" class="px-4 py-2 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">时间</th>
|
||||
<th scope="col" class="px-4 py-2 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">密钥 (部分)</th>
|
||||
<th scope="col" class="px-4 py-2 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">模型</th>
|
||||
<th scope="col" class="px-4 py-2 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">状态</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody class="bg-white divide-y divide-gray-200">
|
||||
`;
|
||||
|
||||
// 填充表格行
|
||||
data.forEach(call => {
|
||||
const timestamp = new Date(call.timestamp).toLocaleString();
|
||||
const keyDisplay = call.key ? `${call.key.substring(0, 4)}...${call.key.substring(call.key.length - 4)}` : 'N/A';
|
||||
const statusClass = call.status === 'success' ? 'text-success-600' : 'text-danger-600';
|
||||
const statusIcon = call.status === 'success' ? 'fa-check-circle' : 'fa-times-circle';
|
||||
|
||||
tableHtml += `
|
||||
<tr>
|
||||
<td class="px-4 py-2 whitespace-nowrap text-sm text-gray-700">${timestamp}</td>
|
||||
<td class="px-4 py-2 whitespace-nowrap text-sm text-gray-500 font-mono">${keyDisplay}</td>
|
||||
<td class="px-4 py-2 whitespace-nowrap text-sm text-gray-500">${call.model || 'N/A'}</td>
|
||||
<td class="px-4 py-2 whitespace-nowrap text-sm ${statusClass}">
|
||||
<i class="fas ${statusIcon} mr-1"></i>
|
||||
${call.status}
|
||||
</td>
|
||||
</tr>
|
||||
`;
|
||||
});
|
||||
|
||||
tableHtml += `
|
||||
</tbody>
|
||||
</table>
|
||||
`;
|
||||
|
||||
container.innerHTML = tableHtml;
|
||||
}
|
||||
|
||||
@@ -1,42 +1,124 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="zh-CN">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>验证页面</title>
|
||||
<link rel="manifest" href="/static/manifest.json">
|
||||
<meta name="theme-color" content="#764ba2">
|
||||
<meta name="apple-mobile-web-app-capable" content="yes">
|
||||
<meta name="apple-mobile-web-app-status-bar-style" content="black">
|
||||
<meta name="apple-mobile-web-app-title" content="GBalance">
|
||||
<link rel="icon" href="/static/icons/icon-192x192.png">
|
||||
<link href="https://fonts.googleapis.com/css2?family=Roboto:wght@300;400;700&display=swap" rel="stylesheet">
|
||||
<link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.0.0/css/all.min.css">
|
||||
<link rel="stylesheet" href="/static/css/auth.css">
|
||||
</head>
|
||||
<body>
|
||||
<div class="container">
|
||||
<div class="logo">
|
||||
<i class="fas fa-shield-alt"></i>
|
||||
</div>
|
||||
<h2>安全验证</h2>
|
||||
<form id="auth-form" action="/auth" method="post">
|
||||
<div class="input-group">
|
||||
<i class="fas fa-key"></i>
|
||||
<input type="password" id="auth-token" name="auth_token" required placeholder="请输入验证令牌">
|
||||
{% extends "base.html" %}
|
||||
|
||||
{% block title %}验证页面 - Gemini Balance{% endblock %}
|
||||
|
||||
{% block head_extra_styles %}
|
||||
<style>
|
||||
/* auth.html specific styles */
|
||||
.auth-glass-card { /* Renamed to avoid conflict if base.html has .glass-card */
|
||||
background: rgba(255, 255, 255, 0.85); /* Increased opacity */
|
||||
backdrop-filter: blur(20px);
|
||||
-webkit-backdrop-filter: blur(20px);
|
||||
border: 1px solid rgba(255, 255, 255, 0.2);
|
||||
}
|
||||
.auth-bg-gradient { /* Renamed to avoid conflict if base.html has .bg-gradient */
|
||||
background: linear-gradient(135deg, #4F46E5 0%, #7C3AED 50%, #EC4899 100%);
|
||||
}
|
||||
/* .input-icon class removed, using direct Tailwind classes now */
|
||||
/* Keep button ripple effect if needed, or remove if base provides similar */
|
||||
.auth-button { /* Renamed to avoid conflict */
|
||||
position: relative;
|
||||
overflow: hidden;
|
||||
}
|
||||
.auth-button:after {
|
||||
content: '';
|
||||
position: absolute;
|
||||
top: 50%;
|
||||
left: 50%;
|
||||
width: 0;
|
||||
height: 0;
|
||||
background: rgba(255, 255, 255, 0.2);
|
||||
border-radius: 50%;
|
||||
transform: translate(-50%, -50%);
|
||||
transition: width 0.6s, height 0.6s;
|
||||
}
|
||||
.auth-button:active:after {
|
||||
width: 300px;
|
||||
height: 300px;
|
||||
opacity: 0;
|
||||
}
|
||||
</style>
|
||||
{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
<div class="auth-bg-gradient min-h-screen flex flex-col justify-center items-center p-4">
|
||||
<div class="glass-card rounded-2xl shadow-2xl p-10 max-w-md w-full mx-auto transform transition duration-500 hover:-translate-y-1 hover:shadow-3xl animate-fade-in">
|
||||
<div class="flex justify-center mb-8 animate-slide-down">
|
||||
<div class="rounded-full bg-primary-100 p-4 text-primary-600">
|
||||
<i class="fas fa-shield-alt text-4xl"></i>
|
||||
</div>
|
||||
<button type="submit">
|
||||
验证访问
|
||||
</div>
|
||||
|
||||
<h2 class="text-3xl font-extrabold text-center text-transparent bg-clip-text bg-gradient-to-r from-primary-600 to-primary-700 mb-8 animate-slide-down">
|
||||
<img src="/static/icons/logo.png" alt="Gemini Balance Logo" class="h-9 inline-block align-middle mr-2">
|
||||
Gemini Balance
|
||||
</h2>
|
||||
|
||||
<form id="auth-form" action="/auth" method="post" class="space-y-6 animate-slide-up">
|
||||
<div class="relative">
|
||||
<i class="fas fa-key absolute left-3 top-1/2 transform -translate-y-1/2 text-gray-500"></i>
|
||||
<input
|
||||
type="password"
|
||||
id="auth-token"
|
||||
name="auth_token"
|
||||
required
|
||||
placeholder="请输入验证令牌"
|
||||
class="w-full pl-10 pr-4 py-4 rounded-xl border border-gray-300 focus:border-primary-500 focus:ring focus:ring-primary-200 focus:ring-opacity-50 transition duration-300 bg-white bg-opacity-90 text-gray-700"
|
||||
>
|
||||
</div>
|
||||
|
||||
<button
|
||||
type="submit"
|
||||
class="w-full py-4 rounded-xl bg-gradient-to-r from-primary-600 to-primary-700 text-white font-semibold transition duration-300 transform hover:-translate-y-1 hover:shadow-lg"
|
||||
>
|
||||
登录
|
||||
</button>
|
||||
</form>
|
||||
|
||||
{% if error %}
|
||||
<p class="error-message">{{ error }}</p>
|
||||
<p class="mt-4 text-red-500 text-center font-medium p-3 bg-red-50 rounded-lg border border-red-200 animate-shake">
|
||||
{{ error }}
|
||||
</p>
|
||||
{% endif %}
|
||||
</div>
|
||||
<div class="copyright">
|
||||
© <script>document.write(new Date().getFullYear())</script> by <a href="https://linux.do/u/snaily" target="_blank"><img src="https://linux.do/user_avatar/linux.do/snaily/288/306510_2.gif" alt="snaily">snaily</a> |
|
||||
<a href="https://github.com/snailyp/gemini-balance" target="_blank"><i class="fab fa-github"></i> GitHub</a>
|
||||
</div>
|
||||
<script src="/static/js/auth.js"></script>
|
||||
</body>
|
||||
</html>
|
||||
|
||||
</div> <!-- Close auth-bg-gradient div -->
|
||||
<!-- Notification placeholder for base.html's showNotification -->
|
||||
<div id="notification" class="notification"></div>
|
||||
|
||||
{% endblock %}
|
||||
|
||||
{% block body_scripts %}
|
||||
<script>
|
||||
// auth.html specific JavaScript
|
||||
document.addEventListener('DOMContentLoaded', function() {
|
||||
const form = document.getElementById('auth-form');
|
||||
if (form) {
|
||||
form.addEventListener('submit', function(e) {
|
||||
const token = document.getElementById('auth-token').value.trim();
|
||||
if (!token) {
|
||||
e.preventDefault();
|
||||
// Use the base notification system
|
||||
showNotification('请输入验证令牌', 'error');
|
||||
}
|
||||
});
|
||||
}
|
||||
// Apply renamed classes
|
||||
document.querySelectorAll('button[type="submit"]').forEach(button => {
|
||||
button.classList.add('auth-button');
|
||||
});
|
||||
const card = document.querySelector('.auth-glass-card'); // Find the renamed card
|
||||
if (card) {
|
||||
// If the base template also defines .glass-card, remove it first
|
||||
// card.classList.remove('glass-card');
|
||||
} else {
|
||||
// If the card wasn't found by the new name, try the old name and rename
|
||||
const oldCard = document.querySelector('.glass-card');
|
||||
if (oldCard) {
|
||||
oldCard.classList.remove('glass-card');
|
||||
oldCard.classList.add('auth-glass-card');
|
||||
}
|
||||
}
|
||||
});
|
||||
</script>
|
||||
{% endblock %}
|
||||
|
||||
265
app/templates/base.html
Normal file
@@ -0,0 +1,265 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="zh-CN">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>{% block title %}Gemini Balance{% endblock %}</title>
|
||||
<link rel="manifest" href="/static/manifest.json">
|
||||
<meta name="theme-color" content="#4F46E5">
|
||||
<meta name="apple-mobile-web-app-capable" content="yes">
|
||||
<meta name="apple-mobile-web-app-status-bar-style" content="black">
|
||||
<meta name="apple-mobile-web-app-title" content="GBalance">
|
||||
<link rel="icon" href="/static/icons/icon-192x192.png">
|
||||
<link href="https://fonts.googleapis.com/css2?family=Inter:wght@300;400;500;600;700&display=swap" rel="stylesheet">
|
||||
<link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.4.0/css/all.min.css">
|
||||
<script src="https://cdn.tailwindcss.com"></script>
|
||||
<script>
|
||||
tailwind.config = {
|
||||
theme: {
|
||||
extend: {
|
||||
colors: {
|
||||
primary: {
|
||||
50: '#eef2ff',
|
||||
100: '#e0e7ff',
|
||||
200: '#c7d2fe',
|
||||
300: '#a5b4fc',
|
||||
400: '#818cf8',
|
||||
500: '#6366f1',
|
||||
600: '#4f46e5',
|
||||
700: '#4338ca',
|
||||
800: '#3730a3',
|
||||
900: '#312e81',
|
||||
},
|
||||
success: {
|
||||
50: '#ecfdf5',
|
||||
500: '#10b981',
|
||||
600: '#059669'
|
||||
},
|
||||
danger: {
|
||||
50: '#fef2f2',
|
||||
500: '#ef4444',
|
||||
600: '#dc2626'
|
||||
}
|
||||
},
|
||||
fontFamily: {
|
||||
sans: ['Inter', 'sans-serif'],
|
||||
mono: ['JetBrains Mono', 'SFMono-Regular', 'Menlo', 'Monaco', 'Consolas', 'monospace'],
|
||||
},
|
||||
animation: {
|
||||
'fade-in': 'fadeIn 0.5s ease-out',
|
||||
'slide-up': 'slideUp 0.5s ease-out',
|
||||
'slide-down': 'slideDown 0.5s ease-out',
|
||||
'shake': 'shake 0.5s ease-in-out',
|
||||
'spin': 'spin 1s linear infinite',
|
||||
},
|
||||
keyframes: {
|
||||
fadeIn: {
|
||||
'0%': { opacity: '0' },
|
||||
'100%': { opacity: '1' },
|
||||
},
|
||||
slideUp: {
|
||||
'0%': { transform: 'translateY(20px)', opacity: '0' },
|
||||
'100%': { transform: 'translateY(0)', opacity: '1' },
|
||||
},
|
||||
slideDown: {
|
||||
'0%': { transform: 'translateY(-20px)', opacity: '0' },
|
||||
'100%': { transform: 'translateY(0)', opacity: '1' },
|
||||
},
|
||||
shake: {
|
||||
'0%, 100%': { transform: 'translateX(0)' },
|
||||
'25%': { transform: 'translateX(-5px)' },
|
||||
'75%': { transform: 'translateX(5px)' },
|
||||
},
|
||||
spin: {
|
||||
'0%': { transform: 'rotate(0deg)' },
|
||||
'100%': { transform: 'rotate(360deg)' },
|
||||
},
|
||||
},
|
||||
}
|
||||
}
|
||||
}
|
||||
</script>
|
||||
<style>
|
||||
.glass-card {
|
||||
background: rgba(255, 255, 255, 0.85); /* Slightly increased opacity for better readability */
|
||||
backdrop-filter: blur(16px);
|
||||
-webkit-backdrop-filter: blur(16px);
|
||||
border: 1px solid rgba(255, 255, 255, 0.18); /* Subtle border */
|
||||
}
|
||||
.bg-gradient {
|
||||
background: linear-gradient(135deg, #4F46E5 0%, #7C3AED 50%, #EC4899 100%);
|
||||
}
|
||||
/* Scrollbar styling */
|
||||
::-webkit-scrollbar {
|
||||
width: 8px;
|
||||
height: 8px;
|
||||
}
|
||||
::-webkit-scrollbar-track {
|
||||
background: rgba(243, 244, 246, 0.8); /* bg-gray-100 with opacity */
|
||||
border-radius: 10px;
|
||||
}
|
||||
::-webkit-scrollbar-thumb {
|
||||
background: rgba(79, 70, 229, 0.4); /* primary-600 with opacity */
|
||||
border-radius: 10px;
|
||||
}
|
||||
::-webkit-scrollbar-thumb:hover {
|
||||
background: rgba(79, 70, 229, 0.6); /* primary-600 with more opacity */
|
||||
}
|
||||
/* Basic modal styles */
|
||||
.modal {
|
||||
display: none;
|
||||
position: fixed;
|
||||
z-index: 50;
|
||||
left: 0;
|
||||
top: 0;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
background-color: rgba(0,0,0,0.5);
|
||||
backdrop-filter: blur(4px);
|
||||
}
|
||||
.modal.show {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
}
|
||||
/* Loading spinner */
|
||||
.loading-spin {
|
||||
animation: spin 1s linear infinite;
|
||||
}
|
||||
@keyframes spin {
|
||||
from { transform: rotate(0deg); }
|
||||
to { transform: rotate(360deg); }
|
||||
}
|
||||
/* Notification */
|
||||
.notification {
|
||||
position: fixed;
|
||||
bottom: 5rem; /* Adjusted from bottom-20 */
|
||||
left: 50%;
|
||||
transform: translateX(-50%);
|
||||
padding: 0.75rem 1.25rem; /* px-5 py-3 */
|
||||
border-radius: 0.5rem; /* rounded-lg */
|
||||
background-color: rgba(0, 0, 0, 0.8);
|
||||
color: white;
|
||||
font-weight: 500; /* font-medium */
|
||||
z-index: 50;
|
||||
opacity: 0;
|
||||
transition: opacity 0.3s ease-in-out, transform 0.3s ease-in-out;
|
||||
}
|
||||
.notification.show {
|
||||
opacity: 1;
|
||||
transform: translate(-50%, 0);
|
||||
}
|
||||
.notification.error {
|
||||
background-color: rgba(220, 38, 38, 0.8); /* danger-600 with opacity */
|
||||
}
|
||||
/* Scroll buttons */
|
||||
.scroll-buttons {
|
||||
position: fixed;
|
||||
right: 1.25rem; /* right-5 */
|
||||
bottom: 5rem; /* bottom-20 */
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.5rem; /* gap-2 */
|
||||
z-index: 10;
|
||||
}
|
||||
.scroll-button {
|
||||
width: 2.5rem; /* w-10 */
|
||||
height: 2.5rem; /* h-10 */
|
||||
background-color: #4f46e5; /* bg-primary-600 */
|
||||
color: white;
|
||||
border-radius: 9999px; /* rounded-full */
|
||||
box-shadow: 0 4px 6px -1px rgba(0, 0, 0, 0.1), 0 2px 4px -1px rgba(0, 0, 0, 0.06); /* shadow-md */
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
transition: all 0.3s ease-in-out;
|
||||
}
|
||||
.scroll-button:hover {
|
||||
background-color: #4338ca; /* hover:bg-primary-700 */
|
||||
box-shadow: 0 10px 15px -3px rgba(0, 0, 0, 0.1), 0 4px 6px -2px rgba(0, 0, 0, 0.05); /* hover:shadow-lg */
|
||||
}
|
||||
{% block head_extra_styles %}
|
||||
{% endblock %}
|
||||
</style>
|
||||
{% block head_extra_scripts %}{% endblock %}
|
||||
</head>
|
||||
<body class="bg-gradient min-h-screen text-gray-800 pt-6 pb-16">
|
||||
|
||||
{% block content %}{% endblock %}
|
||||
|
||||
<!-- 底部版权 -->
|
||||
<div class="fixed bottom-0 left-0 w-full py-3 bg-white bg-opacity-80 backdrop-blur-md text-center text-sm text-gray-600 border-t border-gray-200">
|
||||
© <span id="copyright-year"></span> by
|
||||
<a href="https://linux.do/u/snaily" target="_blank" class="text-primary-600 hover:text-primary-800 transition duration-300">
|
||||
<img src="https://linux.do/user_avatar/linux.do/snaily/288/306510_2.gif" alt="snaily" class="inline-block w-5 h-5 rounded-full align-middle mr-1">snaily
|
||||
</a> |
|
||||
<a href="https://github.com/snailyp/gemini-balance" target="_blank" class="text-primary-600 hover:text-primary-800 transition duration-300">
|
||||
<i class="fab fa-github"></i> GitHub
|
||||
</a>
|
||||
</div>
|
||||
|
||||
<!-- 通用JS -->
|
||||
<script>
|
||||
// 设置版权年份
|
||||
document.getElementById('copyright-year').textContent = new Date().getFullYear();
|
||||
|
||||
// 滚动到顶部/底部函数 (如果页面需要)
|
||||
function scrollToTop() {
|
||||
window.scrollTo({ top: 0, behavior: 'smooth' });
|
||||
}
|
||||
function scrollToBottom() {
|
||||
window.scrollTo({ top: document.body.scrollHeight, behavior: 'smooth' });
|
||||
}
|
||||
|
||||
// 显示通知
|
||||
function showNotification(message, type = 'success', duration = 3000) {
|
||||
const notification = document.getElementById('notification') || createNotificationElement();
|
||||
if (!notification) return;
|
||||
|
||||
notification.textContent = message;
|
||||
notification.className = 'notification show'; // Reset classes
|
||||
if (type === 'error') {
|
||||
notification.classList.add('error');
|
||||
}
|
||||
|
||||
// Clear previous timeout if exists
|
||||
if (notification.timeoutId) {
|
||||
clearTimeout(notification.timeoutId);
|
||||
}
|
||||
|
||||
notification.timeoutId = setTimeout(() => {
|
||||
notification.classList.remove('show');
|
||||
// Optional: remove the element after fade out if dynamically created
|
||||
// setTimeout(() => notification.remove(), 300);
|
||||
}, duration);
|
||||
}
|
||||
|
||||
// Helper to create notification element if it doesn't exist
|
||||
function createNotificationElement() {
|
||||
let notification = document.getElementById('notification');
|
||||
if (!notification) {
|
||||
notification = document.createElement('div');
|
||||
notification.id = 'notification';
|
||||
notification.className = 'notification';
|
||||
document.body.appendChild(notification);
|
||||
}
|
||||
return notification;
|
||||
}
|
||||
|
||||
// 页面刷新带加载状态
|
||||
function refreshPage(button) {
|
||||
if (button) {
|
||||
const icon = button.querySelector('i');
|
||||
if (icon) {
|
||||
icon.classList.add('loading-spin');
|
||||
}
|
||||
}
|
||||
setTimeout(() => {
|
||||
window.location.reload();
|
||||
}, 300); // Short delay to show spinner
|
||||
}
|
||||
|
||||
</script>
|
||||
{% block body_scripts %}{% endblock %}
|
||||
</body>
|
||||
</html>
|
||||
478
app/templates/config_editor.html
Normal file
@@ -0,0 +1,478 @@
|
||||
{% extends "base.html" %}
|
||||
|
||||
{% block title %}配置编辑器 - Gemini Balance{% endblock %}
|
||||
|
||||
{% block head_extra_styles %}
|
||||
<style>
|
||||
/* config_editor.html specific styles */
|
||||
/* Animations (already in base.html, but keep fade-in class usage) */
|
||||
.fade-in {
|
||||
animation: fadeIn 0.3s ease forwards;
|
||||
}
|
||||
/* Modal specific styles (already in base.html) */
|
||||
.array-container {
|
||||
max-height: 300px;
|
||||
overflow-y: auto;
|
||||
padding-right: 5px; /* Keep specific padding if needed */
|
||||
}
|
||||
#API_KEYS_container { /* Keep specific ID styling if needed */
|
||||
max-height: 300px;
|
||||
overflow-y: auto;
|
||||
}
|
||||
.config-section {
|
||||
display: none;
|
||||
}
|
||||
.config-section.active {
|
||||
display: block;
|
||||
animation: fadeIn 0.3s ease forwards; /* Use base animation */
|
||||
}
|
||||
.provider-config {
|
||||
display: none;
|
||||
}
|
||||
.provider-config.active {
|
||||
display: block;
|
||||
}
|
||||
/* Tailwind Toggle Switch Helper CSS */
|
||||
.toggle-checkbox:checked {
|
||||
@apply: right-0 border-primary-600;
|
||||
right: 0;
|
||||
border-color: #4F46E5;
|
||||
}
|
||||
.toggle-checkbox:checked + .toggle-label {
|
||||
@apply: bg-primary-600;
|
||||
background-color: #4F46E5;
|
||||
}
|
||||
</style>
|
||||
{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
<div class="container max-w-4xl mx-auto px-4">
|
||||
<div class="glass-card rounded-2xl shadow-xl p-6 md:p-8">
|
||||
<button class="absolute top-6 right-6 bg-white bg-opacity-20 hover:bg-opacity-30 rounded-full w-8 h-8 flex items-center justify-center text-primary-600 transition-all duration-300" onclick="refreshPage(this)">
|
||||
<i class="fas fa-sync-alt"></i>
|
||||
</button>
|
||||
|
||||
<h1 class="text-3xl font-extrabold text-center text-transparent bg-clip-text bg-gradient-to-r from-primary-600 to-primary-700 mb-4">
|
||||
<img src="/static/icons/logo.png" alt="Gemini Balance Logo" class="h-9 inline-block align-middle mr-2">
|
||||
Gemini Balance - 配置编辑
|
||||
</h1>
|
||||
|
||||
<!-- Navigation Tabs -->
|
||||
<div class="flex justify-center mb-8 overflow-x-auto pb-2 gap-2">
|
||||
<a href="/config" class="whitespace-nowrap flex items-center justify-center gap-2 px-6 py-3 font-medium rounded-lg bg-primary-600 text-white shadow-md">
|
||||
<i class="fas fa-cog"></i> 配置编辑
|
||||
</a>
|
||||
<a href="/keys" class="whitespace-nowrap flex items-center justify-center gap-2 px-6 py-3 font-medium rounded-lg bg-white bg-opacity-50 hover:bg-opacity-70 text-gray-700 transition-all duration-200">
|
||||
<i class="fas fa-tachometer-alt"></i> 监控面板
|
||||
</a>
|
||||
<a href="/logs" class="whitespace-nowrap flex items-center justify-center gap-2 px-6 py-3 font-medium rounded-lg bg-white bg-opacity-50 hover:bg-opacity-70 text-gray-700 transition-all duration-200">
|
||||
<i class="fas fa-exclamation-triangle"></i> 错误日志
|
||||
</a>
|
||||
</div>
|
||||
|
||||
<!-- Config Tabs -->
|
||||
<div class="flex justify-center mb-6 flex-wrap gap-2">
|
||||
<button class="tab-btn bg-primary-600 text-white px-5 py-2 rounded-full shadow-md font-medium text-sm" data-tab="api">
|
||||
API配置
|
||||
</button>
|
||||
<button class="tab-btn bg-white bg-opacity-50 text-gray-700 px-5 py-2 rounded-full font-medium text-sm hover:bg-opacity-70 transition-all duration-200" data-tab="model">
|
||||
模型配置
|
||||
</button>
|
||||
<button class="tab-btn bg-white bg-opacity-50 text-gray-700 px-5 py-2 rounded-full font-medium text-sm hover:bg-opacity-70 transition-all duration-200" data-tab="image">
|
||||
图像生成
|
||||
</button>
|
||||
<button class="tab-btn bg-white bg-opacity-50 text-gray-700 px-5 py-2 rounded-full font-medium text-sm hover:bg-opacity-70 transition-all duration-200" data-tab="stream">
|
||||
流式输出
|
||||
</button>
|
||||
<button class="tab-btn bg-white bg-opacity-50 text-gray-700 px-5 py-2 rounded-full font-medium text-sm hover:bg-opacity-70 transition-all duration-200" data-tab="scheduler">
|
||||
定时任务
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!-- Save Status Banner (Removed - using notification component now) -->
|
||||
|
||||
<!-- Configuration Form -->
|
||||
<form id="configForm" class="mt-6">
|
||||
<!-- API 相关配置 -->
|
||||
<div class="config-section active bg-white bg-opacity-70 rounded-xl p-6 mb-6 shadow-lg" id="api-section">
|
||||
<h2 class="text-xl font-bold mb-6 pb-3 border-b border-gray-200 flex items-center gap-2">
|
||||
<i class="fas fa-key text-primary-600"></i> API相关配置
|
||||
</h2>
|
||||
|
||||
<!-- API密钥列表 -->
|
||||
<div class="mb-6">
|
||||
<label for="API_KEYS" class="block font-semibold mb-2 text-gray-700">API密钥列表</label>
|
||||
<div class="mb-2">
|
||||
<input type="search" id="apiKeySearchInput" placeholder="搜索密钥..." class="w-full px-4 py-2 rounded-lg border border-gray-300 focus:border-primary-500 focus:ring focus:ring-primary-200 focus:ring-opacity-50">
|
||||
</div>
|
||||
<div class="array-container bg-white rounded-lg border border-gray-200 p-4 mb-2" id="API_KEYS_container">
|
||||
<!-- 数组项将在这里动态添加 -->
|
||||
</div>
|
||||
<div class="flex justify-end gap-2">
|
||||
<button type="button" class="bg-danger-600 hover:bg-danger-700 text-white px-4 py-2 rounded-lg font-medium transition-all duration-200 flex items-center gap-2" id="bulkDeleteApiKeyBtn">
|
||||
<i class="fas fa-trash-alt"></i> 删除密钥
|
||||
</button>
|
||||
<button type="button" class="bg-primary-600 hover:bg-primary-700 text-white px-4 py-2 rounded-lg font-medium transition-all duration-200 flex items-center gap-2" id="addApiKeyBtn">
|
||||
<i class="fas fa-plus"></i> 添加密钥
|
||||
</button>
|
||||
</div>
|
||||
<small class="text-gray-500 mt-1 block">Gemini API密钥列表,每行一个</small>
|
||||
</div>
|
||||
|
||||
<!-- 允许的令牌列表 -->
|
||||
<div class="mb-6">
|
||||
<label for="ALLOWED_TOKENS" class="block font-semibold mb-2 text-gray-700">允许的令牌列表</label>
|
||||
<div class="array-container bg-white rounded-lg border border-gray-200 p-4 mb-2" id="ALLOWED_TOKENS_container">
|
||||
<!-- 数组项将在这里动态添加 -->
|
||||
</div>
|
||||
<div class="flex justify-end">
|
||||
<button type="button" class="bg-primary-600 hover:bg-primary-700 text-white px-4 py-2 rounded-lg font-medium transition-all duration-200 flex items-center gap-2" onclick="addArrayItem('ALLOWED_TOKENS')">
|
||||
<i class="fas fa-plus"></i> 添加令牌
|
||||
</button>
|
||||
</div>
|
||||
<small class="text-gray-500 mt-1 block">允许访问API的令牌列表</small>
|
||||
</div>
|
||||
|
||||
<!-- 认证令牌 -->
|
||||
<div class="mb-6">
|
||||
<label for="AUTH_TOKEN" class="block font-semibold mb-2 text-gray-700">认证令牌</label>
|
||||
<div class="flex items-center">
|
||||
<div class="flex items-center flex-grow border border-gray-300 rounded-md focus-within:border-primary-500 focus-within:ring focus-within:ring-primary-200 focus-within:ring-opacity-50">
|
||||
<input type="text" id="AUTH_TOKEN" name="AUTH_TOKEN" placeholder="默认使用ALLOWED_TOKENS中的第一个" class="array-input flex-grow px-3 py-2 border-none rounded-l-md focus:outline-none">
|
||||
<button type="button" id="generateAuthTokenBtn" class="generate-btn px-2 py-2 text-gray-500 hover:text-primary-600 focus:outline-none rounded-r-md bg-gray-100 hover:bg-gray-200 transition-colors" title="生成随机令牌">
|
||||
<i class="fas fa-dice"></i>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<small class="text-gray-500 mt-1 block">用于API认证的令牌</small>
|
||||
</div>
|
||||
|
||||
<!-- API基础URL -->
|
||||
<div class="mb-6">
|
||||
<label for="BASE_URL" class="block font-semibold mb-2 text-gray-700">API基础URL</label>
|
||||
<input type="text" id="BASE_URL" name="BASE_URL" placeholder="https://generativelanguage.googleapis.com/v1beta" class="w-full px-4 py-3 rounded-lg border border-gray-300 focus:border-primary-500 focus:ring focus:ring-primary-200 focus:ring-opacity-50">
|
||||
<small class="text-gray-500 mt-1 block">Gemini API的基础URL</small>
|
||||
</div>
|
||||
|
||||
<!-- 最大失败次数 -->
|
||||
<div class="mb-6">
|
||||
<label for="MAX_FAILURES" class="block font-semibold mb-2 text-gray-700">最大失败次数</label>
|
||||
<input type="number" id="MAX_FAILURES" name="MAX_FAILURES" min="1" max="100" class="w-full px-4 py-3 rounded-lg border border-gray-300 focus:border-primary-500 focus:ring focus:ring-primary-200 focus:ring-opacity-50">
|
||||
<small class="text-gray-500 mt-1 block">API密钥失败后标记为无效的次数</small>
|
||||
</div>
|
||||
|
||||
<!-- 请求超时时间 -->
|
||||
<div class="mb-6">
|
||||
<label for="TIME_OUT" class="block font-semibold mb-2 text-gray-700">请求超时时间(秒)</label>
|
||||
<input type="number" id="TIME_OUT" name="TIME_OUT" min="1" max="600" class="w-full px-4 py-3 rounded-lg border border-gray-300 focus:border-primary-500 focus:ring focus:ring-primary-200 focus:ring-opacity-50">
|
||||
<small class="text-gray-500 mt-1 block">API请求的超时时间</small>
|
||||
</div>
|
||||
|
||||
<!-- 最大重试次数 -->
|
||||
<div class="mb-6">
|
||||
<label for="MAX_RETRIES" class="block font-semibold mb-2 text-gray-700">最大重试次数</label>
|
||||
<input type="number" id="MAX_RETRIES" name="MAX_RETRIES" min="0" max="10" class="w-full px-4 py-3 rounded-lg border border-gray-300 focus:border-primary-500 focus:ring focus:ring-primary-200 focus:ring-opacity-50">
|
||||
<small class="text-gray-500 mt-1 block">API请求失败后的最大重试次数</small>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 模型相关配置 -->
|
||||
<div class="config-section bg-white bg-opacity-70 rounded-xl p-6 mb-6 shadow-lg" id="model-section">
|
||||
<h2 class="text-xl font-bold mb-6 pb-3 border-b border-gray-200 flex items-center gap-2">
|
||||
<i class="fas fa-robot text-primary-600"></i> 模型相关配置
|
||||
</h2>
|
||||
|
||||
<!-- 测试模型 -->
|
||||
<div class="mb-6">
|
||||
<label for="TEST_MODEL" class="block font-semibold mb-2 text-gray-700">测试模型</label>
|
||||
<input type="text" id="TEST_MODEL" name="TEST_MODEL" placeholder="gemini-1.5-flash" class="w-full px-4 py-3 rounded-lg border border-gray-300 focus:border-primary-500 focus:ring focus:ring-primary-200 focus:ring-opacity-50">
|
||||
<small class="text-gray-500 mt-1 block">用于测试API密钥的模型</small>
|
||||
</div>
|
||||
|
||||
<!-- 图像模型列表 -->
|
||||
<div class="mb-6">
|
||||
<label for="IMAGE_MODELS" class="block font-semibold mb-2 text-gray-700">图像模型列表</label>
|
||||
<div class="array-container bg-white rounded-lg border border-gray-200 p-4 mb-2" id="IMAGE_MODELS_container">
|
||||
<!-- 数组项将在这里动态添加 -->
|
||||
</div>
|
||||
<div class="flex justify-end">
|
||||
<button type="button" class="bg-primary-600 hover:bg-primary-700 text-white px-4 py-2 rounded-lg font-medium transition-all duration-200 flex items-center gap-2" onclick="addArrayItem('IMAGE_MODELS')">
|
||||
<i class="fas fa-plus"></i> 添加模型
|
||||
</button>
|
||||
</div>
|
||||
<small class="text-gray-500 mt-1 block">支持图像处理的模型列表</small>
|
||||
</div>
|
||||
|
||||
<!-- 搜索模型列表 -->
|
||||
<div class="mb-6">
|
||||
<label for="SEARCH_MODELS" class="block font-semibold mb-2 text-gray-700">搜索模型列表</label>
|
||||
<div class="array-container bg-white rounded-lg border border-gray-200 p-4 mb-2" id="SEARCH_MODELS_container">
|
||||
<!-- 数组项将在这里动态添加 -->
|
||||
</div>
|
||||
<div class="flex justify-end">
|
||||
<button type="button" class="bg-primary-600 hover:bg-primary-700 text-white px-4 py-2 rounded-lg font-medium transition-all duration-200 flex items-center gap-2" onclick="addArrayItem('SEARCH_MODELS')">
|
||||
<i class="fas fa-plus"></i> 添加模型
|
||||
</button>
|
||||
</div>
|
||||
<small class="text-gray-500 mt-1 block">支持搜索功能的模型列表</small>
|
||||
</div>
|
||||
|
||||
<!-- 过滤模型列表 -->
|
||||
<div class="mb-6">
|
||||
<label for="FILTERED_MODELS" class="block font-semibold mb-2 text-gray-700">过滤模型列表</label>
|
||||
<div class="array-container bg-white rounded-lg border border-gray-200 p-4 mb-2" id="FILTERED_MODELS_container">
|
||||
<!-- 数组项将在这里动态添加 -->
|
||||
</div>
|
||||
<div class="flex justify-end">
|
||||
<button type="button" class="bg-primary-600 hover:bg-primary-700 text-white px-4 py-2 rounded-lg font-medium transition-all duration-200 flex items-center gap-2" onclick="addArrayItem('FILTERED_MODELS')">
|
||||
<i class="fas fa-plus"></i> 添加模型
|
||||
</button>
|
||||
</div>
|
||||
<small class="text-gray-500 mt-1 block">需要过滤的模型列表</small>
|
||||
</div>
|
||||
|
||||
<!-- 启用代码执行工具 -->
|
||||
<div class="mb-6 flex items-center justify-between">
|
||||
<label for="TOOLS_CODE_EXECUTION_ENABLED" class="font-semibold text-gray-700">启用代码执行工具</label>
|
||||
<div class="relative inline-block w-10 mr-2 align-middle select-none transition duration-200 ease-in">
|
||||
<input type="checkbox" name="TOOLS_CODE_EXECUTION_ENABLED" id="TOOLS_CODE_EXECUTION_ENABLED" class="toggle-checkbox absolute block w-6 h-6 rounded-full bg-white border-4 appearance-none cursor-pointer"/>
|
||||
<label for="TOOLS_CODE_EXECUTION_ENABLED" class="toggle-label block overflow-hidden h-6 rounded-full bg-gray-300 cursor-pointer"></label>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 显示搜索链接 -->
|
||||
<div class="mb-6 flex items-center justify-between">
|
||||
<label for="SHOW_SEARCH_LINK" class="font-semibold text-gray-700">显示搜索链接</label>
|
||||
<div class="relative inline-block w-10 mr-2 align-middle select-none transition duration-200 ease-in">
|
||||
<input type="checkbox" name="SHOW_SEARCH_LINK" id="SHOW_SEARCH_LINK" class="toggle-checkbox absolute block w-6 h-6 rounded-full bg-white border-4 appearance-none cursor-pointer"/>
|
||||
<label for="SHOW_SEARCH_LINK" class="toggle-label block overflow-hidden h-6 rounded-full bg-gray-300 cursor-pointer"></label>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 显示思考过程 -->
|
||||
<div class="mb-6 flex items-center justify-between">
|
||||
<label for="SHOW_THINKING_PROCESS" class="font-semibold text-gray-700">显示思考过程</label>
|
||||
<div class="relative inline-block w-10 mr-2 align-middle select-none transition duration-200 ease-in">
|
||||
<input type="checkbox" name="SHOW_THINKING_PROCESS" id="SHOW_THINKING_PROCESS" class="toggle-checkbox absolute block w-6 h-6 rounded-full bg-white border-4 appearance-none cursor-pointer"/>
|
||||
<label for="SHOW_THINKING_PROCESS" class="toggle-label block overflow-hidden h-6 rounded-full bg-gray-300 cursor-pointer"></label>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 图像生成相关配置 -->
|
||||
<div class="config-section bg-white bg-opacity-70 rounded-xl p-6 mb-6 shadow-lg" id="image-section">
|
||||
<h2 class="text-xl font-bold mb-6 pb-3 border-b border-gray-200 flex items-center gap-2">
|
||||
<i class="fas fa-image text-primary-600"></i> 图像生成配置
|
||||
</h2>
|
||||
|
||||
<!-- 付费API密钥 -->
|
||||
<div class="mb-6">
|
||||
<label for="PAID_KEY" class="block font-semibold mb-2 text-gray-700">付费API密钥</label>
|
||||
<input type="text" id="PAID_KEY" name="PAID_KEY" placeholder="AIzaSyxxxxxxxxxxxxxxxxxxx" class="w-full px-4 py-3 rounded-lg border border-gray-300 focus:border-primary-500 focus:ring focus:ring-primary-200 focus:ring-opacity-50">
|
||||
<small class="text-gray-500 mt-1 block">用于图像生成的付费API密钥</small>
|
||||
</div>
|
||||
|
||||
<!-- 图像生成模型 -->
|
||||
<div class="mb-6">
|
||||
<label for="CREATE_IMAGE_MODEL" class="block font-semibold mb-2 text-gray-700">图像生成模型</label>
|
||||
<input type="text" id="CREATE_IMAGE_MODEL" name="CREATE_IMAGE_MODEL" placeholder="imagen-3.0-generate-002" class="w-full px-4 py-3 rounded-lg border border-gray-300 focus:border-primary-500 focus:ring focus:ring-primary-200 focus:ring-opacity-50">
|
||||
<small class="text-gray-500 mt-1 block">用于图像生成的模型</small>
|
||||
</div>
|
||||
|
||||
<!-- 上传提供商 -->
|
||||
<div class="mb-6">
|
||||
<label for="UPLOAD_PROVIDER" class="block font-semibold mb-2 text-gray-700">上传提供商</label>
|
||||
<select id="UPLOAD_PROVIDER" name="UPLOAD_PROVIDER" class="w-full px-4 py-3 rounded-lg border border-gray-300 focus:border-primary-500 focus:ring focus:ring-primary-200 focus:ring-opacity-50 bg-white">
|
||||
<option value="smms" selected>SM.MS</option>
|
||||
<option value="picgo">PicGo</option>
|
||||
<option value="cloudflare">Cloudflare</option>
|
||||
</select>
|
||||
<small class="text-gray-500 mt-1 block">图片上传服务提供商</small>
|
||||
</div>
|
||||
|
||||
<!-- SM.MS密钥 -->
|
||||
<div class="mb-6 provider-config active" data-provider="smms">
|
||||
<label for="SMMS_SECRET_TOKEN" class="block font-semibold mb-2 text-gray-700">SM.MS密钥</label>
|
||||
<input type="text" id="SMMS_SECRET_TOKEN" name="SMMS_SECRET_TOKEN" placeholder="XXXXXXXXXXXXXXXXXXXXXXXXXXXXXX" class="w-full px-4 py-3 rounded-lg border border-gray-300 focus:border-primary-500 focus:ring focus:ring-primary-200 focus:ring-opacity-50">
|
||||
<small class="text-gray-500 mt-1 block">SM.MS图床的密钥</small>
|
||||
</div>
|
||||
|
||||
<!-- PicGo API密钥 -->
|
||||
<div class="mb-6 provider-config" data-provider="picgo">
|
||||
<label for="PICGO_API_KEY" class="block font-semibold mb-2 text-gray-700">PicGo API密钥</label>
|
||||
<input type="text" id="PICGO_API_KEY" name="PICGO_API_KEY" placeholder="xxxx" class="w-full px-4 py-3 rounded-lg border border-gray-300 focus:border-primary-500 focus:ring focus:ring-primary-200 focus:ring-opacity-50">
|
||||
<small class="text-gray-500 mt-1 block">PicGo的API密钥</small>
|
||||
</div>
|
||||
|
||||
<!-- Cloudflare图床URL -->
|
||||
<div class="mb-6 provider-config" data-provider="cloudflare">
|
||||
<label for="CLOUDFLARE_IMGBED_URL" class="block font-semibold mb-2 text-gray-700">Cloudflare图床URL</label>
|
||||
<input type="text" id="CLOUDFLARE_IMGBED_URL" name="CLOUDFLARE_IMGBED_URL" placeholder="https://xxxxxxx.pages.dev/upload" class="w-full px-4 py-3 rounded-lg border border-gray-300 focus:border-primary-500 focus:ring focus:ring-primary-200 focus:ring-opacity-50">
|
||||
<small class="text-gray-500 mt-1 block">Cloudflare图床的URL</small>
|
||||
</div>
|
||||
|
||||
<!-- Cloudflare认证码 -->
|
||||
<div class="mb-6 provider-config" data-provider="cloudflare">
|
||||
<label for="CLOUDFLARE_IMGBED_AUTH_CODE" class="block font-semibold mb-2 text-gray-700">Cloudflare认证码</label>
|
||||
<input type="text" id="CLOUDFLARE_IMGBED_AUTH_CODE" name="CLOUDFLARE_IMGBED_AUTH_CODE" placeholder="xxxxxxxxx" class="w-full px-4 py-3 rounded-lg border border-gray-300 focus:border-primary-500 focus:ring focus:ring-primary-200 focus:ring-opacity-50">
|
||||
<small class="text-gray-500 mt-1 block">Cloudflare图床的认证码</small>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 流式输出优化器配置 -->
|
||||
<div class="config-section bg-white bg-opacity-70 rounded-xl p-6 mb-6 shadow-lg" id="stream-section">
|
||||
<h2 class="text-xl font-bold mb-6 pb-3 border-b border-gray-200 flex items-center gap-2">
|
||||
<i class="fas fa-stream text-primary-600"></i> 流式输出优化器
|
||||
</h2>
|
||||
|
||||
<!-- 启用流式输出优化 -->
|
||||
<div class="mb-6 flex items-center justify-between">
|
||||
<label for="STREAM_OPTIMIZER_ENABLED" class="font-semibold text-gray-700">启用流式输出优化</label>
|
||||
<div class="relative inline-block w-10 mr-2 align-middle select-none transition duration-200 ease-in">
|
||||
<input type="checkbox" name="STREAM_OPTIMIZER_ENABLED" id="STREAM_OPTIMIZER_ENABLED" class="toggle-checkbox absolute block w-6 h-6 rounded-full bg-white border-4 appearance-none cursor-pointer"/>
|
||||
<label for="STREAM_OPTIMIZER_ENABLED" class="toggle-label block overflow-hidden h-6 rounded-full bg-gray-300 cursor-pointer"></label>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 最小延迟 -->
|
||||
<div class="mb-6">
|
||||
<label for="STREAM_MIN_DELAY" class="block font-semibold mb-2 text-gray-700">最小延迟(秒)</label>
|
||||
<input type="number" id="STREAM_MIN_DELAY" name="STREAM_MIN_DELAY" min="0" max="1" step="0.001" class="w-full px-4 py-3 rounded-lg border border-gray-300 focus:border-primary-500 focus:ring focus:ring-primary-200 focus:ring-opacity-50">
|
||||
<small class="text-gray-500 mt-1 block">流式输出的最小延迟时间</small>
|
||||
</div>
|
||||
|
||||
<!-- 最大延迟 -->
|
||||
<div class="mb-6">
|
||||
<label for="STREAM_MAX_DELAY" class="block font-semibold mb-2 text-gray-700">最大延迟(秒)</label>
|
||||
<input type="number" id="STREAM_MAX_DELAY" name="STREAM_MAX_DELAY" min="0" max="1" step="0.001" class="w-full px-4 py-3 rounded-lg border border-gray-300 focus:border-primary-500 focus:ring focus:ring-primary-200 focus:ring-opacity-50">
|
||||
<small class="text-gray-500 mt-1 block">流式输出的最大延迟时间</small>
|
||||
</div>
|
||||
|
||||
<!-- 短文本阈值 -->
|
||||
<div class="mb-6">
|
||||
<label for="STREAM_SHORT_TEXT_THRESHOLD" class="block font-semibold mb-2 text-gray-700">短文本阈值</label>
|
||||
<input type="number" id="STREAM_SHORT_TEXT_THRESHOLD" name="STREAM_SHORT_TEXT_THRESHOLD" min="1" max="100" class="w-full px-4 py-3 rounded-lg border border-gray-300 focus:border-primary-500 focus:ring focus:ring-primary-200 focus:ring-opacity-50">
|
||||
<small class="text-gray-500 mt-1 block">短文本的字符阈值</small>
|
||||
</div>
|
||||
|
||||
<!-- 长文本阈值 -->
|
||||
<div class="mb-6">
|
||||
<label for="STREAM_LONG_TEXT_THRESHOLD" class="block font-semibold mb-2 text-gray-700">长文本阈值</label>
|
||||
<input type="number" id="STREAM_LONG_TEXT_THRESHOLD" name="STREAM_LONG_TEXT_THRESHOLD" min="1" max="1000" class="w-full px-4 py-3 rounded-lg border border-gray-300 focus:border-primary-500 focus:ring focus:ring-primary-200 focus:ring-opacity-50">
|
||||
<small class="text-gray-500 mt-1 block">长文本的字符阈值</small>
|
||||
</div>
|
||||
|
||||
<!-- 分块大小 -->
|
||||
<div class="mb-6">
|
||||
<label for="STREAM_CHUNK_SIZE" class="block font-semibold mb-2 text-gray-700">分块大小</label>
|
||||
<input type="number" id="STREAM_CHUNK_SIZE" name="STREAM_CHUNK_SIZE" min="1" max="100" class="w-full px-4 py-3 rounded-lg border border-gray-300 focus:border-primary-500 focus:ring focus:ring-primary-200 focus:ring-opacity-50">
|
||||
<small class="text-gray-500 mt-1 block">流式输出的分块大小</small>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 定时任务配置 -->
|
||||
<div class="config-section bg-white bg-opacity-70 rounded-xl p-6 mb-6 shadow-lg" id="scheduler-section">
|
||||
<h2 class="text-xl font-bold mb-6 pb-3 border-b border-gray-200 flex items-center gap-2">
|
||||
<i class="fas fa-clock text-primary-600"></i> 定时任务配置
|
||||
</h2>
|
||||
|
||||
<!-- 检查间隔 -->
|
||||
<div class="mb-6">
|
||||
<label for="CHECK_INTERVAL_HOURS" class="block font-semibold mb-2 text-gray-700">检查间隔(小时)</label>
|
||||
<input type="number" id="CHECK_INTERVAL_HOURS" name="CHECK_INTERVAL_HOURS" min="1" class="w-full px-4 py-3 rounded-lg border border-gray-300 focus:border-primary-500 focus:ring focus:ring-primary-200 focus:ring-opacity-50">
|
||||
<small class="text-gray-500 mt-1 block">定时检查密钥状态的间隔时间(单位:小时)</small>
|
||||
</div>
|
||||
|
||||
<!-- 时区 -->
|
||||
<div class="mb-6">
|
||||
<label for="TIMEZONE" class="block font-semibold mb-2 text-gray-700">时区</label>
|
||||
<input type="text" id="TIMEZONE" name="TIMEZONE" placeholder="例如: Asia/Shanghai" class="w-full px-4 py-3 rounded-lg border border-gray-300 focus:border-primary-500 focus:ring focus:ring-primary-200 focus:ring-opacity-50">
|
||||
<small class="text-gray-500 mt-1 block">定时任务使用的时区,格式如 "Asia/Shanghai" 或 "UTC"</small>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Action Buttons -->
|
||||
<div class="flex flex-col md:flex-row justify-center gap-4 mt-8">
|
||||
<button type="button" id="saveBtn" class="bg-gradient-to-r from-primary-600 to-primary-700 text-white px-8 py-3 rounded-xl font-semibold transition-all duration-300 transform hover:-translate-y-1 hover:shadow-lg flex items-center justify-center gap-2">
|
||||
<i class="fas fa-save"></i> 保存配置
|
||||
</button>
|
||||
<button type="button" id="resetBtn" class="bg-gradient-to-r from-gray-400 to-gray-500 text-white px-8 py-3 rounded-xl font-semibold transition-all duration-300 transform hover:-translate-y-1 hover:shadow-lg flex items-center justify-center gap-2">
|
||||
<i class="fas fa-undo"></i> 重置配置
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Scroll buttons are now in base.html -->
|
||||
<div class="scroll-buttons">
|
||||
<button class="scroll-button" onclick="scrollToTop()" title="回到顶部">
|
||||
<i class="fas fa-chevron-up"></i>
|
||||
</button>
|
||||
<button class="scroll-button" onclick="scrollToBottom()" title="滚动到底部">
|
||||
<i class="fas fa-chevron-down"></i>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!-- Notification component is now in base.html -->
|
||||
<div id="notification" class="notification"></div>
|
||||
<!-- Footer is now in base.html -->
|
||||
|
||||
<!-- API Key Add Modal -->
|
||||
<div id="apiKeyModal" class="modal">
|
||||
<div class="w-full max-w-lg mx-auto bg-white rounded-2xl shadow-2xl overflow-hidden animate-fade-in">
|
||||
<div class="p-6">
|
||||
<div class="flex justify-between items-center mb-4">
|
||||
<h2 class="text-xl font-bold text-gray-800">批量添加 API 密钥</h2>
|
||||
<button id="closeApiKeyModalBtn" class="text-gray-400 hover:text-gray-600 text-xl">×</button>
|
||||
</div>
|
||||
<p class="text-gray-600 mb-4">每行粘贴一个或多个密钥,将自动提取有效密钥并去重。</p>
|
||||
<textarea id="apiKeyBulkInput" rows="10" placeholder="在此处粘贴 API 密钥..." class="w-full px-4 py-3 rounded-lg border border-gray-300 focus:border-primary-500 focus:ring focus:ring-primary-200 focus:ring-opacity-50 font-mono text-sm"></textarea>
|
||||
<div class="flex justify-end gap-3 mt-6">
|
||||
<button type="button" id="confirmAddApiKeyBtn" class="bg-primary-600 hover:bg-primary-700 text-white px-6 py-2 rounded-lg font-medium transition">确认添加</button>
|
||||
<button type="button" id="cancelAddApiKeyBtn" class="bg-gray-200 hover:bg-gray-300 text-gray-700 px-6 py-2 rounded-lg font-medium transition">取消</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Bulk Delete API Key Modal -->
|
||||
<div id="bulkDeleteApiKeyModal" class="modal">
|
||||
<div class="w-full max-w-lg mx-auto bg-white rounded-2xl shadow-2xl overflow-hidden animate-fade-in">
|
||||
<div class="p-6">
|
||||
<div class="flex justify-between items-center mb-4">
|
||||
<h2 class="text-xl font-bold text-gray-800">批量删除 API 密钥</h2>
|
||||
<button id="closeBulkDeleteModalBtn" class="text-gray-400 hover:text-gray-600 text-xl">×</button>
|
||||
</div>
|
||||
<p class="text-gray-600 mb-4">每行粘贴一个或多个密钥,将自动提取有效密钥并从列表中删除。</p>
|
||||
<textarea id="bulkDeleteApiKeyInput" rows="10" placeholder="在此处粘贴要删除的 API 密钥..." class="w-full px-4 py-3 rounded-lg border border-gray-300 focus:border-danger-500 focus:ring focus:ring-danger-200 focus:ring-opacity-50 font-mono text-sm"></textarea>
|
||||
<div class="flex justify-end gap-3 mt-6">
|
||||
<button type="button" id="confirmBulkDeleteApiKeyBtn" class="bg-danger-600 hover:bg-danger-700 text-white px-6 py-2 rounded-lg font-medium transition">确认删除</button>
|
||||
<button type="button" id="cancelBulkDeleteApiKeyBtn" class="bg-gray-200 hover:bg-gray-300 text-gray-700 px-6 py-2 rounded-lg font-medium transition">取消</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<!-- Reset Confirmation Modal -->
|
||||
<div id="resetConfirmModal" class="modal">
|
||||
<div class="w-full max-w-md mx-auto bg-white rounded-2xl shadow-2xl overflow-hidden animate-fade-in">
|
||||
<div class="p-6">
|
||||
<div class="flex justify-between items-center mb-4">
|
||||
<h2 class="text-xl font-bold text-gray-800">确认重置配置</h2>
|
||||
<button id="closeResetModalBtn" class="text-gray-400 hover:text-gray-600 text-xl">×</button>
|
||||
</div>
|
||||
<p class="text-gray-600 mb-6">确定要重置所有配置吗?<br>这将恢复到默认值,此操作不可撤销。</p>
|
||||
<div class="flex justify-end gap-3">
|
||||
<button type="button" id="confirmResetBtn" class="bg-red-500 hover:bg-red-600 text-white px-6 py-2 rounded-lg font-medium transition">确认重置</button>
|
||||
<button type="button" id="cancelResetBtn" class="bg-primary-600 hover:bg-primary-700 text-white px-6 py-2 rounded-lg font-medium transition">取消</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{% endblock %}
|
||||
|
||||
{% block body_scripts %}
|
||||
<script src="/static/js/config_editor.js"></script>
|
||||
<!-- Add any other page-specific JS initialization here if needed -->
|
||||
{% endblock %}
|
||||
242
app/templates/error_logs.html
Normal file
@@ -0,0 +1,242 @@
|
||||
{% extends "base.html" %}
|
||||
|
||||
{% block title %}错误日志管理 - Gemini Balance{% endblock %}
|
||||
|
||||
{% block head_extra_styles %}
|
||||
<style>
|
||||
/* error_logs.html specific styles */
|
||||
/* Table styles */
|
||||
.styled-table th {
|
||||
position: sticky;
|
||||
top: 0;
|
||||
background: #f3f4f6; /* bg-gray-100 */
|
||||
z-index: 10;
|
||||
}
|
||||
.styled-table tbody tr:hover {
|
||||
background-color: #f9fafb; /* bg-gray-50 */
|
||||
}
|
||||
.styled-table td {
|
||||
padding: 12px 20px;
|
||||
vertical-align: middle;
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
max-width: 250px;
|
||||
}
|
||||
/* Ensure error log column does not wrap and remove max-width */
|
||||
.styled-table td:nth-child(4) { /* Assuming error log is the 4th column */
|
||||
/* max-width: none; */
|
||||
white-space: nowrap;
|
||||
}
|
||||
.btn-view-details {
|
||||
background-color: #eef2ff; /* primary-50 */
|
||||
color: #4f46e5; /* primary-600 */
|
||||
padding: 6px 12px;
|
||||
border-radius: 6px;
|
||||
font-weight: 500;
|
||||
transition: all 0.2s ease-in-out;
|
||||
border: 1px solid #c7d2fe; /* primary-200 */
|
||||
}
|
||||
.btn-view-details:hover {
|
||||
background-color: #c7d2fe; /* primary-200 */
|
||||
color: #4338ca; /* primary-700 */
|
||||
box-shadow: 0 2px 4px rgba(0,0,0,0.05);
|
||||
}
|
||||
@media (max-width: 768px) {
|
||||
.search-container {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
}
|
||||
/* Modal styles are in base.html */
|
||||
</style>
|
||||
{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
<div class="container mx-auto px-4"> <!-- Removed max-width-7xl for wider content -->
|
||||
<div class="glass-card rounded-2xl shadow-xl p-6 md:p-8">
|
||||
<!-- Removed refresh button from top right -->
|
||||
|
||||
<h1 class="text-3xl font-extrabold text-center text-transparent bg-clip-text bg-gradient-to-r from-primary-600 to-primary-700 mb-4">
|
||||
<img src="/static/icons/logo.png" alt="Gemini Balance Logo" class="h-9 inline-block align-middle mr-2">
|
||||
Gemini Balance - 错误日志
|
||||
</h1>
|
||||
|
||||
<!-- Navigation Tabs -->
|
||||
<div class="flex justify-center mb-8 overflow-x-auto pb-2 gap-2">
|
||||
<a href="/config" class="whitespace-nowrap flex items-center justify-center gap-2 px-6 py-3 font-medium rounded-lg bg-white bg-opacity-50 hover:bg-opacity-70 text-gray-700 transition-all duration-200">
|
||||
<i class="fas fa-cog"></i> 配置编辑
|
||||
</a>
|
||||
<a href="/keys" class="whitespace-nowrap flex items-center justify-center gap-2 px-6 py-3 font-medium rounded-lg bg-white bg-opacity-50 hover:bg-opacity-70 text-gray-700 transition-all duration-200">
|
||||
<i class="fas fa-tachometer-alt"></i> 监控面板
|
||||
</a>
|
||||
<a href="/logs" class="whitespace-nowrap flex items-center justify-center gap-2 px-6 py-3 font-medium rounded-lg bg-primary-600 text-white shadow-md">
|
||||
<i class="fas fa-exclamation-triangle"></i> 错误日志
|
||||
</a>
|
||||
</div>
|
||||
|
||||
<!-- 主内容区域 -->
|
||||
<div class="bg-white bg-opacity-70 rounded-xl p-6 shadow-lg animate-fade-in">
|
||||
<h2 class="text-xl font-bold mb-6 pb-3 border-b border-gray-200 flex items-center gap-2">
|
||||
<i class="fas fa-bug text-primary-600"></i> 错误日志列表
|
||||
</h2>
|
||||
|
||||
<!-- 控制区域 (Refresh button removed, page size moved below) -->
|
||||
<!-- Removed the original controls div -->
|
||||
|
||||
<!-- 搜索控件 -->
|
||||
<div class="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-5 gap-3 mb-6">
|
||||
<input type="text" id="keySearch" placeholder="搜索密钥 (部分)" class="px-4 py-3 rounded-lg border border-gray-300 focus:border-primary-500 focus:ring focus:ring-primary-200 focus:ring-opacity-50 col-span-1 lg:col-span-1">
|
||||
<input type="text" id="errorSearch" placeholder="搜索错误类型/日志" class="px-4 py-3 rounded-lg border border-gray-300 focus:border-primary-500 focus:ring focus:ring-primary-200 focus:ring-opacity-50 col-span-1 lg:col-span-1">
|
||||
<div class="flex items-center gap-2 col-span-1 lg:col-span-2">
|
||||
<input type="datetime-local" id="startDate" class="px-4 py-3 rounded-lg border border-gray-300 focus:border-primary-500 focus:ring focus:ring-primary-200 focus:ring-opacity-50 flex-1 text-sm">
|
||||
<span class="text-gray-700">至</span>
|
||||
<input type="datetime-local" id="endDate" class="px-4 py-3 rounded-lg border border-gray-300 focus:border-primary-500 focus:ring focus:ring-primary-200 focus:ring-opacity-50 flex-1 text-sm">
|
||||
</div>
|
||||
<button id="searchBtn" class="flex items-center justify-center gap-2 bg-primary-600 hover:bg-primary-700 text-white px-4 py-3 rounded-lg font-medium transition-all duration-200 col-span-1">
|
||||
<i class="fas fa-search"></i> 搜索
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!-- 表格容器 - Enhanced Styling -->
|
||||
<div class="overflow-x-auto rounded-lg border border-gray-200 mb-6 bg-white"> <!-- Removed shadow, added border -->
|
||||
<table class="styled-table w-full min-w-full text-sm"> <!-- Added text-sm -->
|
||||
<thead>
|
||||
<tr class="bg-primary-50 text-left text-primary-800"> <!-- Changed header background and text color -->
|
||||
<th class="px-5 py-3 font-semibold rounded-tl-lg">ID</th> <!-- Increased padding, adjusted rounding -->
|
||||
<th class="px-5 py-3 font-semibold">Gemini密钥</th>
|
||||
<th class="px-5 py-3 font-semibold">错误类型</th>
|
||||
<th class="px-5 py-3 font-semibold">错误码</th>
|
||||
<th class="px-5 py-3 font-semibold">模型名称</th>
|
||||
<th class="px-5 py-3 font-semibold">请求时间</th>
|
||||
<th class="px-5 py-3 font-semibold rounded-tr-lg">操作</th> <!-- Adjusted rounding -->
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody id="errorLogsTable" class="divide-y divide-gray-200">
|
||||
<!-- 错误日志数据将通过JavaScript动态加载 -->
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
<!-- 状态指示器 -->
|
||||
<div id="loadingIndicator" class="flex items-center justify-center p-8 hidden">
|
||||
<div class="animate-spin rounded-full h-12 w-12 border-b-2 border-primary-600"></div>
|
||||
<p class="ml-4 text-lg text-gray-700 font-medium">加载中,请稍候...</p>
|
||||
</div>
|
||||
|
||||
<div id="noDataMessage" class="text-center py-12 text-gray-500 hidden">
|
||||
<i class="fas fa-inbox text-5xl mb-3"></i>
|
||||
<p class="text-lg">暂无错误日志数据</p>
|
||||
</div>
|
||||
|
||||
<div id="errorMessage" class="bg-danger-50 text-danger-600 p-4 rounded-lg font-medium text-center hidden">
|
||||
<i class="fas fa-exclamation-circle mr-2"></i>
|
||||
加载错误日志失败,请稍后重试。
|
||||
</div>
|
||||
|
||||
<!-- 分页与每页显示控件 -->
|
||||
<div class="flex flex-col sm:flex-row justify-between items-center mt-6 gap-4">
|
||||
<!-- 每页显示控件 (Moved here) -->
|
||||
<div class="flex items-center gap-2 text-sm text-gray-700">
|
||||
<label for="pageSize" class="font-medium">每页显示:</label>
|
||||
<select id="pageSize" class="rounded-md border border-gray-300 focus:ring focus:ring-primary-200 focus:border-primary-500 px-2 py-1 bg-white text-sm">
|
||||
<option value="10">10</option>
|
||||
<option value="20" selected>20</option>
|
||||
<option value="50">50</option>
|
||||
<option value="100">100</option>
|
||||
</select>
|
||||
<span>条</span>
|
||||
</div>
|
||||
<!-- 分页控件 -->
|
||||
<div class="flex items-center gap-4"> <!-- Wrapper for pagination and input -->
|
||||
<ul class="pagination flex items-center gap-1" id="pagination">
|
||||
<!-- 分页控件将通过JavaScript动态加载 -->
|
||||
</ul>
|
||||
<!-- 页码输入跳转 -->
|
||||
<div class="flex items-center gap-1">
|
||||
<input type="number" id="pageInput" min="1" class="w-16 px-2 py-1 rounded-md border border-gray-300 text-sm focus:ring focus:ring-primary-200 focus:border-primary-500" placeholder="页码">
|
||||
<button id="goToPageBtn" class="px-3 py-1 bg-primary-600 hover:bg-primary-700 text-white text-sm rounded-md transition">跳转</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Scroll buttons are now in base.html -->
|
||||
<div class="scroll-buttons">
|
||||
<button class="scroll-button" onclick="scrollToTop()" title="回到顶部">
|
||||
<i class="fas fa-chevron-up"></i>
|
||||
</button>
|
||||
<button class="scroll-button" onclick="scrollToBottom()" title="滚动到底部">
|
||||
<i class="fas fa-chevron-down"></i>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!-- Notification component is now in base.html (use id="notification") -->
|
||||
<div id="notification" class="notification"></div>
|
||||
<!-- Footer is now in base.html -->
|
||||
|
||||
<!-- 日志详情模态框 -->
|
||||
<div id="logDetailModal" class="modal">
|
||||
<div class="w-full max-w-6xl mx-auto bg-white rounded-2xl shadow-2xl overflow-hidden animate-fade-in"> <!-- Increased max-width to 6xl -->
|
||||
<div class="p-6">
|
||||
<div class="flex justify-between items-center border-b border-gray-200 pb-4 mb-4">
|
||||
<h2 class="text-xl font-bold text-gray-800">错误日志详情</h2>
|
||||
<button id="closeLogDetailModalBtn" class="text-gray-400 hover:text-gray-600 text-xl">×</button>
|
||||
</div>
|
||||
|
||||
<div class="space-y-4 max-h-[60vh] overflow-y-auto p-1">
|
||||
<div class="bg-gray-50 p-4 rounded-lg">
|
||||
<h6 class="text-sm font-semibold text-gray-600 mb-1">Gemini密钥:</h6>
|
||||
<pre id="modalGeminiKey" class="font-mono text-sm bg-gray-100 p-3 rounded overflow-x-auto"></pre>
|
||||
</div>
|
||||
|
||||
<div class="bg-gray-50 p-4 rounded-lg">
|
||||
<h6 class="text-sm font-semibold text-gray-600 mb-1">错误类型:</h6>
|
||||
<p id="modalErrorType" class="text-danger-600 font-medium"></p>
|
||||
</div>
|
||||
|
||||
<div class="bg-gray-50 p-4 rounded-lg relative group"> <!-- Added relative and group -->
|
||||
<h6 class="text-sm font-semibold text-gray-600 mb-1">错误日志:</h6>
|
||||
<pre id="modalErrorLog" class="font-mono text-sm bg-gray-100 p-3 rounded overflow-x-auto whitespace-pre-wrap"></pre>
|
||||
<button class="copy-btn absolute top-2 right-2 bg-gray-200 hover:bg-gray-300 text-gray-600 p-1.5 rounded opacity-0 group-hover:opacity-100 transition-opacity" data-target="modalErrorLog" title="复制错误日志">
|
||||
<i class="far fa-copy"></i>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div class="bg-gray-50 p-4 rounded-lg relative group"> <!-- Added relative and group -->
|
||||
<h6 class="text-sm font-semibold text-gray-600 mb-1">请求消息:</h6>
|
||||
<pre id="modalRequestMsg" class="font-mono text-sm bg-gray-100 p-3 rounded overflow-x-auto whitespace-pre-wrap"></pre>
|
||||
<button class="copy-btn absolute top-2 right-2 bg-gray-200 hover:bg-gray-300 text-gray-600 p-1.5 rounded opacity-0 group-hover:opacity-100 transition-opacity" data-target="modalRequestMsg" title="复制请求消息">
|
||||
<i class="far fa-copy"></i>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div class="bg-gray-50 p-4 rounded-lg">
|
||||
<h6 class="text-sm font-semibold text-gray-600 mb-1">模型名称:</h6>
|
||||
<p id="modalModelName" class="font-medium"></p>
|
||||
</div>
|
||||
|
||||
<div class="bg-gray-50 p-4 rounded-lg">
|
||||
<h6 class="text-sm font-semibold text-gray-600 mb-1">请求时间:</h6>
|
||||
<p id="modalRequestTime" class="font-medium"></p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="flex justify-end mt-6">
|
||||
<button type="button" id="closeModalFooterBtn" class="bg-gray-200 hover:bg-gray-300 text-gray-800 px-6 py-2 rounded-lg font-medium transition">关闭</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{% endblock %}
|
||||
|
||||
{% block body_scripts %}
|
||||
<script src="/static/js/error_logs.js"></script>
|
||||
<script>
|
||||
// error_logs.html specific JS initialization (if any)
|
||||
// e.g., initialize date pickers or other elements if needed
|
||||
// The main logic is in error_logs.js
|
||||
</script>
|
||||
{% endblock %}
|
||||
@@ -1,128 +1,634 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="zh-CN">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>API密钥状态</title>
|
||||
<link rel="manifest" href="/static/manifest.json">
|
||||
<meta name="theme-color" content="#764ba2">
|
||||
<meta name="apple-mobile-web-app-capable" content="yes">
|
||||
<meta name="apple-mobile-web-app-status-bar-style" content="black">
|
||||
<meta name="apple-mobile-web-app-title" content="GBalance">
|
||||
<link rel="icon" href="/static/icons/icon-192x192.png">
|
||||
<link href="https://fonts.googleapis.com/css2?family=Roboto:wght@300;400;700&display=swap" rel="stylesheet">
|
||||
<link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.0.0/css/all.min.css">
|
||||
<link rel="stylesheet" href="/static/css/keys_status.css">
|
||||
</head>
|
||||
<body>
|
||||
<div class="container">
|
||||
<button class="refresh-btn" onclick="refreshPage(this)">
|
||||
<i class="fas fa-sync-alt"></i>
|
||||
</button>
|
||||
<h1>API密钥状态</h1>
|
||||
<div class="key-list">
|
||||
<h2 onclick="toggleSection(this, 'validKeys')">
|
||||
<span>
|
||||
<i class="fas fa-chevron-down toggle-icon"></i>
|
||||
<i class="fas fa-check-circle" style="color: #27ae60;"></i>
|
||||
有效密钥
|
||||
</span>
|
||||
<button class="copy-btn" onclick="event.stopPropagation(); copyKeys('valid')">
|
||||
<i class="fas fa-copy"></i>
|
||||
批量复制
|
||||
{% extends "base.html" %}
|
||||
|
||||
{% block title %}API密钥状态 - Gemini Balance{% endblock %}
|
||||
|
||||
{% block head_extra_styles %}
|
||||
<style>
|
||||
/* keys_status.html specific styles */
|
||||
.key-content {
|
||||
transition: max-height 0.3s ease-in-out, opacity 0.3s ease-in-out;
|
||||
}
|
||||
.key-content.collapsed {
|
||||
max-height: 0;
|
||||
overflow: hidden;
|
||||
opacity: 0;
|
||||
}
|
||||
.toggle-icon {
|
||||
transition: transform 0.3s ease;
|
||||
}
|
||||
.toggle-icon.collapsed {
|
||||
transform: rotate(-90deg);
|
||||
}
|
||||
/* Copy status styling is handled by base.html's notification */
|
||||
|
||||
/* 现代数据统计样式 */
|
||||
.stats-dashboard {
|
||||
display: grid;
|
||||
grid-template-columns: 1fr;
|
||||
gap: 1.5rem;
|
||||
margin-bottom: 2rem;
|
||||
position: relative;
|
||||
z-index: 10;
|
||||
}
|
||||
|
||||
@media (min-width: 768px) {
|
||||
.stats-dashboard {
|
||||
grid-template-columns: 1fr 1fr;
|
||||
}
|
||||
}
|
||||
|
||||
/* 统计卡片样式 */
|
||||
.stats-card {
|
||||
background-color: rgba(255, 255, 255, 0.8);
|
||||
backdrop-filter: blur(8px);
|
||||
-webkit-backdrop-filter: blur(8px);
|
||||
border-radius: 0.75rem;
|
||||
box-shadow: 0 4px 6px -1px rgba(0, 0, 0, 0.1), 0 2px 4px -1px rgba(0, 0, 0, 0.06);
|
||||
border: 1px solid rgba(255, 255, 255, 0.4);
|
||||
overflow: hidden;
|
||||
transition: all 0.3s ease-in-out;
|
||||
}
|
||||
|
||||
.stats-card:hover {
|
||||
box-shadow: 0 10px 15px -3px rgba(0, 0, 0, 0.1), 0 4px 6px -2px rgba(0, 0, 0, 0.05);
|
||||
}
|
||||
|
||||
.stats-card-header {
|
||||
background-color: rgba(255, 255, 255, 0.3);
|
||||
padding: 0.75rem 1rem;
|
||||
border-bottom: 1px solid rgba(243, 244, 246, 0.5);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
}
|
||||
|
||||
.stats-card-title {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
font-size: 1rem;
|
||||
font-weight: 500;
|
||||
color: #374151;
|
||||
}
|
||||
|
||||
.stats-card-title i {
|
||||
margin-right: 0.5rem;
|
||||
color: #4F46E5;
|
||||
}
|
||||
|
||||
.stats-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(3, 1fr);
|
||||
gap: 0.5rem;
|
||||
padding: 0.75rem;
|
||||
}
|
||||
|
||||
/* 统计项样式 */
|
||||
.stat-item {
|
||||
padding: 0.75rem;
|
||||
border-radius: 0.5rem;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
text-align: center;
|
||||
transition: all 0.3s ease-in-out;
|
||||
position: relative;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.stat-item::before {
|
||||
content: '';
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
opacity: 0.05;
|
||||
background-color: currentColor;
|
||||
z-index: 0;
|
||||
transition: opacity 0.3s ease-in-out;
|
||||
}
|
||||
|
||||
.stat-item:hover::before {
|
||||
opacity: 0.1;
|
||||
}
|
||||
|
||||
.stat-item:hover {
|
||||
transform: scale(1.05);
|
||||
}
|
||||
|
||||
.stat-value {
|
||||
font-size: 1.5rem;
|
||||
font-weight: 700;
|
||||
z-index: 10;
|
||||
position: relative;
|
||||
color: #1F2937;
|
||||
}
|
||||
|
||||
.stat-label {
|
||||
font-size: 0.75rem;
|
||||
font-weight: 500;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.05em;
|
||||
margin-top: 0.25rem;
|
||||
z-index: 10;
|
||||
position: relative;
|
||||
color: #6B7280;
|
||||
}
|
||||
|
||||
.stat-icon {
|
||||
position: absolute;
|
||||
right: 0.5rem;
|
||||
bottom: 0.25rem;
|
||||
opacity: 0.1;
|
||||
font-size: 1.875rem;
|
||||
transform: rotate(12deg);
|
||||
transition: all 0.3s ease-in-out;
|
||||
}
|
||||
|
||||
.stat-item:hover .stat-icon {
|
||||
opacity: 0.2;
|
||||
transform: scale(1.1) rotate(0deg);
|
||||
}
|
||||
|
||||
/* 统计类型样式 */
|
||||
.stat-primary {
|
||||
color: #4F46E5;
|
||||
background-color: rgba(238, 242, 255, 0.5);
|
||||
}
|
||||
|
||||
.stat-success {
|
||||
color: #10B981;
|
||||
background-color: rgba(236, 253, 245, 0.5);
|
||||
}
|
||||
|
||||
.stat-danger {
|
||||
color: #EF4444;
|
||||
background-color: rgba(254, 242, 242, 0.5);
|
||||
}
|
||||
|
||||
.stat-warning {
|
||||
color: #F59E0B;
|
||||
background-color: rgba(255, 251, 235, 0.5);
|
||||
}
|
||||
|
||||
.stat-info {
|
||||
color: #3B82F6;
|
||||
background-color: rgba(239, 246, 255, 0.5);
|
||||
}
|
||||
|
||||
/* 响应式调整 */
|
||||
@media (max-width: 640px) {
|
||||
.stats-dashboard {
|
||||
gap: 1rem;
|
||||
}
|
||||
|
||||
.stats-grid {
|
||||
grid-template-columns: repeat(2, 1fr);
|
||||
gap: 0.5rem;
|
||||
padding: 0.5rem;
|
||||
}
|
||||
|
||||
.stat-item {
|
||||
padding: 0.5rem;
|
||||
}
|
||||
|
||||
.stat-value {
|
||||
font-size: 1.25rem;
|
||||
}
|
||||
|
||||
.stat-label {
|
||||
font-size: 0.625rem;
|
||||
}
|
||||
}
|
||||
/* Tailwind Toggle Switch Helper CSS from config_editor.html */
|
||||
.toggle-checkbox:checked {
|
||||
@apply: right-0 border-primary-600;
|
||||
right: 0;
|
||||
border-color: #4F46E5;
|
||||
}
|
||||
.toggle-checkbox:checked + .toggle-label {
|
||||
@apply: bg-primary-600;
|
||||
background-color: #4F46E5;
|
||||
}
|
||||
|
||||
</style>
|
||||
{% endblock %}
|
||||
|
||||
{% block head_extra_scripts %}
|
||||
<!-- keys_status.js needs to be loaded in head because it might be used by inline scripts -->
|
||||
<script src="/static/js/keys_status.js"></script>
|
||||
{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
<div class="container max-w-6xl mx-auto px-4"> <!-- Increased max-width -->
|
||||
<div class="glass-card rounded-2xl shadow-xl p-6 md:p-8">
|
||||
<div class="absolute top-6 right-6 flex items-center gap-3">
|
||||
<!-- 自动刷新开关 -->
|
||||
<div class="flex items-center text-sm text-gray-600 select-none">
|
||||
<span class="mr-2">自动刷新</span>
|
||||
<div class="relative inline-block w-10 mr-2 align-middle select-none transition duration-200 ease-in">
|
||||
<input type="checkbox" name="autoRefreshToggle" id="autoRefreshToggle" class="toggle-checkbox absolute block w-6 h-6 rounded-full bg-white border-4 appearance-none cursor-pointer"/>
|
||||
<label for="autoRefreshToggle" class="toggle-label block overflow-hidden h-6 rounded-full bg-gray-300 cursor-pointer"></label>
|
||||
</div>
|
||||
</div>
|
||||
<!-- 手动刷新按钮 -->
|
||||
<button class="bg-white bg-opacity-20 hover:bg-opacity-30 rounded-full w-8 h-8 flex items-center justify-center text-primary-600 transition-all duration-300" onclick="refreshPage(this)" title="手动刷新">
|
||||
<i class="fas fa-sync-alt"></i>
|
||||
</button>
|
||||
</h2>
|
||||
<div class="key-content">
|
||||
<ul id="validKeys">
|
||||
{% for key, fail_count in valid_keys.items() %}
|
||||
<li>
|
||||
<div class="key-info">
|
||||
<span class="status-badge status-valid">
|
||||
<i class="fas fa-check"></i> 有效
|
||||
</span>
|
||||
<span class="key-text">{{ key }}</span>
|
||||
<span class="fail-count">
|
||||
<i class="fas fa-exclamation-triangle"></i>
|
||||
失败: {{ fail_count }}
|
||||
</span>
|
||||
</div>
|
||||
<div class="key-actions">
|
||||
<button class="verify-btn" onclick="verifyKey('{{ key }}', this)">
|
||||
<i class="fas fa-check-circle"></i>
|
||||
验证
|
||||
</button>
|
||||
<button class="copy-btn" onclick="copyKey('{{ key }}')">
|
||||
<i class="fas fa-copy"></i>
|
||||
复制
|
||||
</button>
|
||||
</div>
|
||||
</li>
|
||||
{% endfor %}
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
<div class="key-list">
|
||||
<h2 onclick="toggleSection(this, 'invalidKeys')">
|
||||
<span>
|
||||
<i class="fas fa-chevron-down toggle-icon"></i>
|
||||
<i class="fas fa-times-circle" style="color: #e74c3c;"></i>
|
||||
无效密钥
|
||||
</span>
|
||||
<button class="copy-btn" onclick="event.stopPropagation(); copyKeys('invalid')">
|
||||
<i class="fas fa-copy"></i>
|
||||
批量复制
|
||||
</button>
|
||||
</h2>
|
||||
<div class="key-content">
|
||||
<ul id="invalidKeys">
|
||||
{% for key, fail_count in invalid_keys.items() %}
|
||||
<li>
|
||||
<div class="key-info">
|
||||
<span class="status-badge status-invalid">
|
||||
<i class="fas fa-times"></i> 无效
|
||||
</span>
|
||||
<span class="key-text">{{ key }}</span>
|
||||
<span class="fail-count">
|
||||
<i class="fas fa-exclamation-triangle"></i>
|
||||
失败: {{ fail_count }}
|
||||
</span>
|
||||
</div>
|
||||
<div class="key-actions">
|
||||
<button class="verify-btn" onclick="verifyKey('{{ key }}', this)">
|
||||
<i class="fas fa-check-circle"></i>
|
||||
验证
|
||||
</button>
|
||||
<button class="copy-btn" onclick="copyKey('{{ key }}')">
|
||||
<i class="fas fa-copy"></i>
|
||||
复制
|
||||
</button>
|
||||
</div>
|
||||
</li>
|
||||
{% endfor %}
|
||||
</ul>
|
||||
|
||||
<h1 class="text-3xl font-extrabold text-center text-transparent bg-clip-text bg-gradient-to-r from-primary-600 to-primary-700 mb-4">
|
||||
<img src="/static/icons/logo.png" alt="Gemini Balance Logo" class="h-9 inline-block align-middle mr-2">
|
||||
Gemini Balance - 监控面板
|
||||
</h1>
|
||||
|
||||
<!-- Navigation Tabs -->
|
||||
<div class="flex justify-center mb-8 overflow-x-auto pb-2 gap-2">
|
||||
<a href="/config" class="whitespace-nowrap flex items-center justify-center gap-2 px-6 py-3 font-medium rounded-lg bg-white bg-opacity-50 hover:bg-opacity-70 text-gray-700 transition-all duration-200">
|
||||
<i class="fas fa-cog"></i> 配置编辑
|
||||
</a>
|
||||
<a href="/keys" class="whitespace-nowrap flex items-center justify-center gap-2 px-6 py-3 font-medium rounded-lg bg-primary-600 text-white shadow-md">
|
||||
<i class="fas fa-tachometer-alt"></i> 监控面板
|
||||
</a>
|
||||
<a href="/logs" class="whitespace-nowrap flex items-center justify-center gap-2 px-6 py-3 font-medium rounded-lg bg-white bg-opacity-50 hover:bg-opacity-70 text-gray-700 transition-all duration-200">
|
||||
<i class="fas fa-exclamation-triangle"></i> 错误日志
|
||||
</a>
|
||||
</div>
|
||||
|
||||
<!-- 现代化统计面板 -->
|
||||
<div class="stats-dashboard animate-fade-in" style="animation-delay: 0.1s">
|
||||
<!-- 密钥统计卡片 -->
|
||||
<div class="stats-card">
|
||||
<div class="stats-card-header">
|
||||
<h3 class="stats-card-title">
|
||||
<i class="fas fa-key"></i>
|
||||
<span>密钥统计</span>
|
||||
</h3>
|
||||
<span class="text-xs text-gray-500">总计: {{ total_keys }}</span>
|
||||
</div>
|
||||
<div class="stats-grid">
|
||||
<div class="stat-item stat-primary" title="总密钥数">
|
||||
<div class="stat-value">{{ total_keys }}</div>
|
||||
<div class="stat-label">总密钥数</div>
|
||||
<i class="stat-icon fas fa-key"></i>
|
||||
</div>
|
||||
<div class="stat-item stat-success" title="有效密钥">
|
||||
<div class="stat-value">{{ valid_key_count }}</div>
|
||||
<div class="stat-label">有效密钥</div>
|
||||
<i class="stat-icon fas fa-check-circle"></i>
|
||||
</div>
|
||||
<div class="stat-item stat-danger" title="无效密钥">
|
||||
<div class="stat-value">{{ invalid_key_count }}</div>
|
||||
<div class="stat-label">无效密钥</div>
|
||||
<i class="stat-icon fas fa-times-circle"></i>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- API调用统计卡片 -->
|
||||
<div class="stats-card">
|
||||
<div class="stats-card-header">
|
||||
<h3 class="stats-card-title">
|
||||
<i class="fas fa-chart-line"></i>
|
||||
<span>API调用统计</span>
|
||||
</h3>
|
||||
<span class="text-xs text-gray-500">本月: {{ api_stats.calls_month }}</span>
|
||||
</div>
|
||||
<div class="stats-grid">
|
||||
<div class="stat-item stat-warning cursor-pointer hover:bg-amber-100" title="点击查看1分钟内调用详情" data-period="1m" onclick="showApiCallDetails('1m')">
|
||||
<div class="stat-value">{{ api_stats.calls_1m }}</div>
|
||||
<div class="stat-label">1分钟调用</div>
|
||||
<i class="stat-icon fas fa-stopwatch"></i>
|
||||
</div>
|
||||
<div class="stat-item stat-info cursor-pointer hover:bg-blue-100" title="点击查看1小时内调用详情" data-period="1h" onclick="showApiCallDetails('1h')">
|
||||
<div class="stat-value">{{ api_stats.calls_1h }}</div>
|
||||
<div class="stat-label">1小时调用</div>
|
||||
<i class="stat-icon fas fa-hourglass-half"></i>
|
||||
</div>
|
||||
<div class="stat-item stat-primary cursor-pointer hover:bg-indigo-100" title="点击查看24小时内调用详情" data-period="24h" onclick="showApiCallDetails('24h')">
|
||||
<div class="stat-value">{{ api_stats.calls_24h }}</div>
|
||||
<div class="stat-label">24小时调用</div>
|
||||
<i class="stat-icon fas fa-calendar-day"></i>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 有效密钥区域 -->
|
||||
<div class="stats-card mb-6 animate-fade-in" style="animation-delay: 0.2s">
|
||||
<div class="stats-card-header cursor-pointer" onclick="toggleSection(this, 'validKeys')">
|
||||
<div class="flex items-center gap-3">
|
||||
<i class="fas fa-chevron-down toggle-icon text-primary-600"></i>
|
||||
<i class="fas fa-check-circle text-success-500"></i>
|
||||
<h2 class="text-lg font-semibold">有效密钥列表 ({{ valid_key_count }})</h2>
|
||||
<div class="flex items-center gap-2 ml-4">
|
||||
<label for="failCountThreshold" class="text-sm text-gray-600 select-none">失败次数≥</label>
|
||||
<input type="number" id="failCountThreshold" value="0" min="0" class="form-input h-7 w-16 px-2 py-1 text-sm border border-gray-300 rounded focus:ring-primary-500 focus:border-primary-500" onclick="event.stopPropagation();">
|
||||
</div>
|
||||
</div>
|
||||
<div class="flex gap-2">
|
||||
<button class="flex items-center gap-2 bg-teal-500 hover:bg-teal-600 text-white px-3 py-1.5 rounded-lg text-sm font-medium transition-all duration-200" onclick="event.stopPropagation(); showVerifyModal('valid', event)"> <!-- 新增批量验证按钮 -->
|
||||
<i class="fas fa-check-double"></i>
|
||||
批量验证
|
||||
</button>
|
||||
<button class="flex items-center gap-2 bg-blue-500 hover:bg-blue-600 text-white px-3 py-1.5 rounded-lg text-sm font-medium transition-all duration-200" onclick="event.stopPropagation(); resetAllKeysFailCount('valid', event)" data-reset-type="valid">
|
||||
<i class="fas fa-redo-alt"></i>
|
||||
批量重置
|
||||
</button>
|
||||
<button class="flex items-center gap-2 bg-primary-600 hover:bg-primary-700 text-white px-3 py-1.5 rounded-lg text-sm font-medium transition-all duration-200" onclick="event.stopPropagation(); copyKeys('valid')">
|
||||
<i class="fas fa-copy"></i>
|
||||
批量复制
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="key-content p-4 bg-white bg-opacity-40">
|
||||
<ul id="validKeys" class="grid grid-cols-1 md:grid-cols-2 gap-3">
|
||||
{% if valid_keys %}
|
||||
{% for key, fail_count in valid_keys.items() %}
|
||||
<li class="bg-white rounded-lg p-3 shadow-sm hover:shadow-md transition-all duration-300 border border-gray-100 hover:border-success-300 transform hover:-translate-y-1" data-fail-count="{{ fail_count }}">
|
||||
<div class="flex flex-col justify-between h-full gap-3">
|
||||
<div class="flex flex-wrap items-center gap-2">
|
||||
<span class="inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium bg-success-50 text-success-600">
|
||||
<i class="fas fa-check mr-1"></i> 有效
|
||||
</span>
|
||||
<div class="flex items-center gap-1">
|
||||
<span class="key-text font-mono text-gray-700" data-full-key="{{ key }}">{{ key[:4] + '...' + key[-4:] }}</span>
|
||||
<button class="text-gray-500 hover:text-primary-600 transition-colors" onclick="toggleKeyVisibility(this)">
|
||||
<i class="fas fa-eye"></i>
|
||||
</button>
|
||||
</div>
|
||||
<span class="inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium bg-amber-50 text-amber-600">
|
||||
<i class="fas fa-exclamation-triangle mr-1"></i>
|
||||
失败: {{ fail_count }}
|
||||
</span>
|
||||
</div>
|
||||
<div class="flex flex-wrap items-center gap-2">
|
||||
<button class="flex items-center gap-1 bg-success-500 hover:bg-success-600 text-white px-3 py-1.5 rounded-lg text-sm font-medium transition-all duration-200" onclick="verifyKey('{{ key }}', this)">
|
||||
<i class="fas fa-check-circle"></i>
|
||||
验证
|
||||
</button>
|
||||
<button class="flex items-center gap-1 bg-blue-500 hover:bg-blue-600 text-white px-3 py-1.5 rounded-lg text-sm font-medium transition-all duration-200" onclick="resetKeyFailCount('{{ key }}', this)">
|
||||
<i class="fas fa-redo-alt"></i>
|
||||
重置
|
||||
</button>
|
||||
<button class="flex items-center gap-1 bg-gray-500 hover:bg-gray-600 text-white px-3 py-1.5 rounded-lg text-sm font-medium transition-all duration-200" onclick="copyKey('{{ key }}')">
|
||||
<i class="fas fa-copy"></i>
|
||||
复制
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</li>
|
||||
{% endfor %}
|
||||
{% else %}
|
||||
<li class="text-center text-gray-500 py-4">暂无有效密钥</li>
|
||||
{% endif %}
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 无效密钥区域 -->
|
||||
<div class="stats-card mb-6 animate-fade-in" style="animation-delay: 0.4s">
|
||||
<div class="stats-card-header cursor-pointer" onclick="toggleSection(this, 'invalidKeys')">
|
||||
<div class="flex items-center gap-3">
|
||||
<i class="fas fa-chevron-down toggle-icon text-primary-600"></i>
|
||||
<i class="fas fa-times-circle text-danger-500"></i>
|
||||
<h2 class="text-lg font-semibold">无效密钥列表 ({{ invalid_key_count }})</h2>
|
||||
</div>
|
||||
<div class="flex gap-2">
|
||||
<button class="flex items-center gap-2 bg-teal-500 hover:bg-teal-600 text-white px-3 py-1.5 rounded-lg text-sm font-medium transition-all duration-200" onclick="event.stopPropagation(); showVerifyModal('invalid', event)"> <!-- 新增批量验证按钮 -->
|
||||
<i class="fas fa-check-double"></i>
|
||||
批量验证
|
||||
</button>
|
||||
<button class="flex items-center gap-2 bg-blue-500 hover:bg-blue-600 text-white px-3 py-1.5 rounded-lg text-sm font-medium transition-all duration-200" onclick="event.stopPropagation(); resetAllKeysFailCount('invalid', event)" data-reset-type="invalid">
|
||||
<i class="fas fa-redo-alt"></i>
|
||||
批量重置
|
||||
</button>
|
||||
<button class="flex items-center gap-2 bg-primary-600 hover:bg-primary-700 text-white px-3 py-1.5 rounded-lg text-sm font-medium transition-all duration-200" onclick="event.stopPropagation(); copyKeys('invalid')">
|
||||
<i class="fas fa-copy"></i>
|
||||
批量复制
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="key-content p-4 bg-white bg-opacity-40">
|
||||
<ul id="invalidKeys" class="grid grid-cols-1 md:grid-cols-2 gap-3">
|
||||
{% if invalid_keys %}
|
||||
{% for key, fail_count in invalid_keys.items() %}
|
||||
<li class="bg-white rounded-lg p-3 shadow-sm hover:shadow-md transition-all duration-300 border border-gray-100 hover:border-danger-300 transform hover:-translate-y-1">
|
||||
<div class="flex flex-col justify-between h-full gap-3">
|
||||
<div class="flex flex-wrap items-center gap-2">
|
||||
<span class="inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium bg-danger-50 text-danger-600">
|
||||
<i class="fas fa-times mr-1"></i> 无效
|
||||
</span>
|
||||
<div class="flex items-center gap-1">
|
||||
<span class="key-text font-mono text-gray-700" data-full-key="{{ key }}">{{ key[:4] + '...' + key[-4:] }}</span>
|
||||
<button class="text-gray-500 hover:text-primary-600 transition-colors" onclick="toggleKeyVisibility(this)">
|
||||
<i class="fas fa-eye"></i>
|
||||
</button>
|
||||
</div>
|
||||
<span class="inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium bg-amber-50 text-amber-600">
|
||||
<i class="fas fa-exclamation-triangle mr-1"></i>
|
||||
失败: {{ fail_count }}
|
||||
</span>
|
||||
</div>
|
||||
<div class="flex flex-wrap items-center gap-2">
|
||||
<button class="flex items-center gap-1 bg-success-500 hover:bg-success-600 text-white px-3 py-1.5 rounded-lg text-sm font-medium transition-all duration-200" onclick="verifyKey('{{ key }}', this)">
|
||||
<i class="fas fa-check-circle"></i>
|
||||
验证
|
||||
</button>
|
||||
<button class="flex items-center gap-1 bg-blue-500 hover:bg-blue-600 text-white px-3 py-1.5 rounded-lg text-sm font-medium transition-all duration-200" onclick="resetKeyFailCount('{{ key }}', this)">
|
||||
<i class="fas fa-redo-alt"></i>
|
||||
重置
|
||||
</button>
|
||||
<button class="flex items-center gap-1 bg-gray-500 hover:bg-gray-600 text-white px-3 py-1.5 rounded-lg text-sm font-medium transition-all duration-200" onclick="copyKey('{{ key }}')">
|
||||
<i class="fas fa-copy"></i>
|
||||
复制
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</li>
|
||||
{% endfor %}
|
||||
{% else %}
|
||||
<li class="text-center text-gray-500 py-4">暂无无效密钥</li>
|
||||
{% endif %}
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Removed old total keys display -->
|
||||
</div>
|
||||
<div class="total">
|
||||
<i class="fas fa-key"></i> 总密钥数:{{ total }}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="scroll-buttons">
|
||||
<button class="scroll-btn" onclick="scrollToTop()" title="回到顶部">
|
||||
<i class="fas fa-chevron-up"></i>
|
||||
</button>
|
||||
<button class="scroll-btn" onclick="scrollToBottom()" title="滚动到底部">
|
||||
<i class="fas fa-chevron-down"></i>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div id="copyStatus"></div>
|
||||
|
||||
<div class="copyright">
|
||||
© <script>document.write(new Date().getFullYear())</script> by <a href="https://linux.do/u/snaily" target="_blank"><img src="https://linux.do/user_avatar/linux.do/snaily/288/306510_2.gif" alt="snaily">snaily</a> |
|
||||
<a href="https://github.com/snailyp/gemini-balance" target="_blank"><i class="fab fa-github"></i> GitHub</a>
|
||||
</div>
|
||||
|
||||
<script src="/static/js/keys_status.js"></script>
|
||||
</body>
|
||||
</html>
|
||||
<!-- Scroll buttons are now in base.html -->
|
||||
<div class="scroll-buttons">
|
||||
<button class="scroll-button" onclick="scrollToTop()" title="回到顶部">
|
||||
<i class="fas fa-chevron-up"></i>
|
||||
</button>
|
||||
<button class="scroll-button" onclick="scrollToBottom()" title="滚动到底部">
|
||||
<i class="fas fa-chevron-down"></i>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!-- Notification component is now in base.html (use id="notification") -->
|
||||
<div id="notification" class="notification"></div>
|
||||
<!-- 重置确认模态框 -->
|
||||
<div id="resetModal" class="fixed inset-0 bg-black bg-opacity-50 flex items-center justify-center z-50 hidden">
|
||||
<div class="bg-white rounded-lg p-6 shadow-xl max-w-md w-full animate-fade-in">
|
||||
<div class="flex items-center justify-between mb-4">
|
||||
<h3 class="text-lg font-semibold text-gray-800" id="resetModalTitle">批量重置失败次数</h3>
|
||||
<button onclick="closeResetModal()" class="text-gray-500 hover:text-gray-700 focus:outline-none">
|
||||
<i class="fas fa-times"></i>
|
||||
</button>
|
||||
</div>
|
||||
<div class="mb-6">
|
||||
<p class="text-gray-600" id="resetModalMessage"></p>
|
||||
</div>
|
||||
<div class="flex justify-end gap-3">
|
||||
<button onclick="closeResetModal()" class="px-4 py-2 bg-gray-300 hover:bg-gray-400 text-gray-800 rounded-lg transition-colors">
|
||||
取消
|
||||
</button>
|
||||
<button id="confirmResetBtn" class="px-4 py-2 bg-blue-500 hover:bg-blue-600 text-white rounded-lg transition-colors">
|
||||
确认
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<!-- 验证确认模态框移到 resetModal 外部,避免嵌套导致显示异常 -->
|
||||
<div id="verifyModal" class="fixed inset-0 bg-black bg-opacity-50 flex items-center justify-center z-50 hidden">
|
||||
<div class="bg-white rounded-lg p-6 shadow-xl max-w-md w-full animate-fade-in">
|
||||
<div class="flex items-center justify-between mb-4">
|
||||
<h3 class="text-lg font-semibold text-gray-800" id="verifyModalTitle">批量验证密钥</h3>
|
||||
<button onclick="closeVerifyModal()" class="text-gray-500 hover:text-gray-700 focus:outline-none">
|
||||
<i class="fas fa-times"></i>
|
||||
</button>
|
||||
</div>
|
||||
<div class="mb-6">
|
||||
<p class="text-gray-600" id="verifyModalMessage"></p>
|
||||
</div>
|
||||
<div class="flex justify-end gap-3">
|
||||
<button onclick="closeVerifyModal()" class="px-4 py-2 bg-gray-300 hover:bg-gray-400 text-gray-800 rounded-lg transition-colors">
|
||||
取消
|
||||
</button>
|
||||
<button id="confirmVerifyBtn" class="px-4 py-2 bg-teal-500 hover:bg-teal-600 text-white rounded-lg transition-colors">
|
||||
确认验证
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 操作结果模态框 -->
|
||||
<div id="resultModal" class="fixed inset-0 bg-black bg-opacity-50 flex items-center justify-center z-50 hidden">
|
||||
<div class="bg-white rounded-2xl p-0 shadow-2xl max-w-lg w-full animate-fade-in border border-gray-200">
|
||||
<div class="flex items-center justify-between px-6 pt-6 pb-2 border-b">
|
||||
<h3 class="text-xl font-bold text-gray-800 text-center w-full" id="resultModalTitle" style="letter-spacing:0.05em;">操作结果</h3>
|
||||
<button onclick="closeResultModal()" class="absolute right-6 top-6 text-gray-400 hover:text-gray-700 focus:outline-none text-2xl">
|
||||
<i class="fas fa-times"></i>
|
||||
</button>
|
||||
</div>
|
||||
<div class="flex flex-col items-center px-8 pt-6 pb-2">
|
||||
<div id="resultIcon" class="text-6xl mb-3"></div>
|
||||
</div>
|
||||
<div class="px-8 pb-2 w-full">
|
||||
<div id="resultModalMessage"
|
||||
class="text-gray-700 text-base leading-relaxed break-all whitespace-pre-line max-h-60 overflow-y-auto border border-gray-100 rounded-lg bg-gray-50 p-4 shadow-inner"
|
||||
style="font-family: 'JetBrains Mono', 'Fira Mono', 'Consolas', 'monospace';">
|
||||
</div>
|
||||
</div>
|
||||
<div class="flex justify-center px-8 pb-6 pt-2">
|
||||
<button id="resultModalConfirmBtn" onclick="closeResultModal()" class="px-6 py-2 bg-primary-600 hover:bg-primary-700 text-white rounded-lg font-semibold text-base shadow transition-colors">
|
||||
确定
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- API 调用详情模态框 -->
|
||||
<div id="apiCallDetailsModal" class="fixed inset-0 bg-black bg-opacity-50 flex items-center justify-center z-50 hidden">
|
||||
<div class="bg-white rounded-lg p-6 shadow-xl max-w-3xl w-full animate-fade-in"> <!-- Increased max-width -->
|
||||
<div class="flex items-center justify-between mb-4 border-b pb-3">
|
||||
<h3 class="text-xl font-semibold text-gray-800" id="apiCallDetailsModalTitle">API 调用详情</h3>
|
||||
<button onclick="closeApiCallDetailsModal()" class="text-gray-500 hover:text-gray-700 focus:outline-none text-xl">
|
||||
<i class="fas fa-times"></i>
|
||||
</button>
|
||||
</div>
|
||||
<div id="apiCallDetailsContent" class="mb-6 max-h-[60vh] overflow-y-auto pr-2"> <!-- Increased max-height and added padding-right -->
|
||||
<!-- 详细数据将加载到这里 -->
|
||||
<div class="text-center py-10">
|
||||
<i class="fas fa-spinner fa-spin text-primary-600 text-3xl"></i>
|
||||
<p class="text-gray-500 mt-2">加载中...</p>
|
||||
</div>
|
||||
</div>
|
||||
<div class="flex justify-end pt-4 border-t">
|
||||
<button onclick="closeApiCallDetailsModal()" class="px-5 py-2 bg-gray-200 hover:bg-gray-300 text-gray-800 rounded-lg transition-colors text-sm font-medium">
|
||||
关闭
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Footer is now in base.html -->
|
||||
|
||||
{% endblock %}
|
||||
|
||||
{% block body_scripts %}
|
||||
<script>
|
||||
// keys_status.html specific JavaScript initialization
|
||||
document.addEventListener('DOMContentLoaded', () => {
|
||||
// Filter functionality based on fail count threshold
|
||||
const thresholdInput = document.getElementById('failCountThreshold');
|
||||
const validKeysList = document.getElementById('validKeys');
|
||||
|
||||
function filterValidKeys() {
|
||||
const threshold = parseInt(thresholdInput.value, 10);
|
||||
if (isNaN(threshold)) return; // Do nothing if input is not a number
|
||||
|
||||
const keys = validKeysList.querySelectorAll('li');
|
||||
let visibleCount = 0;
|
||||
keys.forEach(keyItem => {
|
||||
// Check if it's a key item (has data-fail-count) before processing
|
||||
if (keyItem.hasAttribute('data-fail-count')) {
|
||||
const failCount = parseInt(keyItem.getAttribute('data-fail-count'), 10);
|
||||
if (failCount >= threshold) {
|
||||
keyItem.style.display = ''; // Show item
|
||||
visibleCount++;
|
||||
} else {
|
||||
keyItem.style.display = 'none'; // Hide item
|
||||
}
|
||||
}
|
||||
});
|
||||
// Optional: Show a message if no keys match the filter
|
||||
const noMatchMsgId = 'no-valid-keys-msg';
|
||||
let noMatchMsg = validKeysList.querySelector(`#${noMatchMsgId}`);
|
||||
if (visibleCount === 0 && keys.length > 0) { // Only show if there were keys initially
|
||||
if (!noMatchMsg) {
|
||||
noMatchMsg = document.createElement('li');
|
||||
noMatchMsg.id = noMatchMsgId;
|
||||
noMatchMsg.className = 'text-center text-gray-500 py-4';
|
||||
noMatchMsg.textContent = '没有符合条件的有效密钥';
|
||||
validKeysList.appendChild(noMatchMsg);
|
||||
}
|
||||
noMatchMsg.style.display = '';
|
||||
} else if (noMatchMsg) {
|
||||
noMatchMsg.style.display = 'none';
|
||||
}
|
||||
}
|
||||
|
||||
if (thresholdInput && validKeysList) {
|
||||
thresholdInput.addEventListener('input', filterValidKeys);
|
||||
// Initial filter on load
|
||||
filterValidKeys();
|
||||
}
|
||||
|
||||
// Initialize other elements or event listeners if needed
|
||||
// The main logic (verifyKey, resetKeyFailCount, copyKey, etc.) is in keys_status.js
|
||||
// The toggleSection logic is now specific to this page
|
||||
window.toggleSection = function(header, sectionId) {
|
||||
const toggleIcon = header.querySelector('.toggle-icon');
|
||||
const content = header.nextElementSibling; // Assumes content is immediately after header
|
||||
if (toggleIcon && content) {
|
||||
toggleIcon.classList.toggle('collapsed');
|
||||
content.classList.toggle('collapsed');
|
||||
}
|
||||
}
|
||||
});
|
||||
</script>
|
||||
{% endblock %}
|
||||
|
||||
@@ -144,3 +144,5 @@ def is_valid_api_key(key: str) -> bool:
|
||||
return len(key) >= 30
|
||||
|
||||
return False
|
||||
|
||||
|
||||
|
||||
BIN
files/image.png
Normal file
|
After Width: | Height: | Size: 347 KiB |
BIN
files/image1.png
Normal file
|
After Width: | Height: | Size: 281 KiB |
BIN
files/image2.png
Normal file
|
After Width: | Height: | Size: 328 KiB |
BIN
files/image3.png
Normal file
|
After Width: | Height: | Size: 230 KiB |
BIN
files/image4.png
Normal file
|
After Width: | Height: | Size: 459 KiB |
BIN
files/image5.png
Normal file
|
After Width: | Height: | Size: 292 KiB |
BIN
files/image6.png
Normal file
|
After Width: | Height: | Size: 163 KiB |
BIN
files/image7.png
Normal file
|
After Width: | Height: | Size: 665 KiB |
BIN
files/image8.png
Normal file
|
After Width: | Height: | Size: 97 KiB |
@@ -9,3 +9,11 @@ uvicorn
|
||||
google-genai
|
||||
jinja2
|
||||
python-multipart
|
||||
cryptography # 支持 MySQL 8+ caching_sha2_password 验证
|
||||
# 数据库相关依赖
|
||||
pymysql
|
||||
sqlalchemy
|
||||
aiomysql
|
||||
databases
|
||||
python-dotenv
|
||||
apscheduler # 添加定时任务库
|
||||
|
||||