Compare commits

...

14 Commits

Author SHA1 Message Date
yinpeng
fc7280bb18 feat: 优化滚动按钮显示逻辑,监听容器高度变化自动切换 2025-02-13 01:05:30 +08:00
yinpeng
8d9c99bda2 feat: 优化密钥状态页面滚动体验,添加容器滚动和渐变按钮样式 2025-02-13 00:49:44 +08:00
yinpeng
ab701f9415 docs: 完善 Web 界面功能文档,补充界面特性和交互细节 2025-02-12 23:40:05 +08:00
yinpeng
c3e0d4b64f feat: 添加页面底部版权信息和作者链接 2025-02-12 23:34:18 +08:00
yinpeng
5b7f4de63c feat: 优化密钥状态页面交互体验,添加分组折叠和刷新功能 2025-02-12 18:55:44 +08:00
yinpeng
ede27a5d70 refactor: 移除 retry_handler 中未使用的 KeyManager 导入 2025-02-12 17:48:09 +08:00
yinpeng
5a4619444b fix: 修复 Gemini 多段文本响应内容拼接问题 2025-02-12 17:47:03 +08:00
yinpeng
b3851441f1 refactor: 优化 RetryHandler 装饰器以支持动态 KeyManager 注入 2025-02-12 17:10:02 +08:00
yinpeng
44f956e4e4 feat: Add PWA support with manifest and ServiceWorker integration
- Mounted static files directory to serve PWA assets like manifest.json and ServiceWorker scripts.
- Updated `auth.html` and `keys_status.html` templates:
  - Added `<link>` for manifest and icons to support Progressive Web App (PWA) features.
  - Added meta tags for theme color and Apple web app capabilities.
  - Integrated ServiceWorker registration script for offline capabilities.
2025-02-12 16:20:34 +08:00
yinpeng
3aa4384b9d feat: Add responsive styles for auth and keys status pages
- Implement media queries to improve layout and UI for smaller screen sizes on `auth.html` and `keys_status.html`.
- Adjust container widths, font sizes, padding, and other styles for screen widths below 768px and 480px.
- Enhance mobile usability by making elements stack vertically, resizing fonts, and optimizing spacing for better readability and interaction.
2025-02-12 15:46:37 +08:00
yinpeng
6db4b56186 Refactor keys_status.html for improved layout and scrolling behavior
- Removed duplicated padding and simplified CSS for `body`, ensuring proper spacing with 20px padding.
- Adjusted `.container` styles:
  - Removed custom scrollbar styles and overflow-related attributes.
  - Centered the element with `margin: 20px auto`.
- Updated scroll behavior:
  - Changed scroll functions to operate on `window` instead of `.container`.
  - Modified event listeners to use `window` for detecting scroll events.
- Cleaned up redundant or unused styles and improved readability.
2025-02-12 15:30:44 +08:00
yinpeng
8e77773d5a Enhance UI/UX for keys_status.html
- Added smooth scroll functionality with "Scroll to Top" and "Scroll to Bottom" buttons.
- Introduced a `scroll-buttons` section with styled buttons for scrolling.
- Improved `#copyStatus` styling for better visibility and alignment.
- Adjusted `.container` to support scrollable content with hidden scrollbars and a max-height.
- Ensured proper z-index for new elements to prevent overlapping issues.
- Enhanced hover and active states for scroll buttons to improve user experience.
- Added event listeners to dynamically show/hide scroll buttons based on user scroll position.
2025-02-12 15:16:22 +08:00
yinpeng
343f40476a feat: Improve UI/UX for API Key Status page and add enhancements
- Updated the overall design aesthetics of the authentication page.
  - Added `fadeIn`, `slideDown`, `slideUp`, and `shake` animations for better user interaction.
  - Improved error message styling with a subtle background, padding, and animation.

- Enhanced "API Key Status" page:
  - Implemented new theme with gradient backgrounds and glassmorphism effect.
  - Redesigned headings with underlines and improved hierarchy.
  - Added FontAwesome icons to improve the visual appeal and clarity (e.g., checkmarks, warnings, keys).
  - Applied better spacing, padding, and hover effects to list items and buttons.
  - Introduced animations for key lists to create fluid transitions on page load.
  - Differentiated valid and invalid keys using badges with appropriate colors and icons.

- Copy Key Interaction:
  - Improved key copying functionality:
    - Added animations and hover effects to "Copy" buttons.
    - Updated the copied key selector logic to target `.key-text` for cleaner code.
    - Changed copy confirmation message for better clarity.
  - Styled the copy success message (`#copyStatus`) to appear fixed at the bottom with a blur effect.

- Key List Enhancements:
  - Added fail count badges for individual keys with red warning styles.
  - Styled buttons for batch copying to display icons alongside text, matching the overall design.

- Accessibility and Readability:
  - Refactored text sizes, weights, and alignments for smoother readability.
  - Enhanced color contrast and alignment for better accessibility.

Notes:
- New CSS animations have been smoothly integrated with no breaking changes.
- All changes prioritize maintaining current functionality while enhancing user experience.
2025-02-12 14:46:34 +08:00
yinpeng
e024d55006 feat: update workflows for docker and release processes
- Updated `.github/workflows/docker-publish.yml`:
  - Commented out the branch trigger for `main` in the `push` event to allow only tag-based Docker builds (tags like `v*.*.*`).

- Updated `.github/workflows/release.yml`:
  - Removed default release body template containing placeholder release notes. This simplifies the release creation process and avoids predefined content.
  - No functional changes to release asset upload configurations, minor format adjustment to ensure no missing newline at the file end.
2025-02-12 14:20:46 +08:00
13 changed files with 806 additions and 107 deletions

View File

@@ -2,7 +2,7 @@ name: Docker Image CI
on:
push:
branches: [ "main" ]
# branches: [ "main" ]
tags: [ 'v*.*.*' ]
pull_request:
branches: [ "main" ]

View File

@@ -24,10 +24,6 @@ jobs:
with:
tag_name: ${{ github.ref_name }}
release_name: ${{ github.ref_name }}
body: |
## Release Notes
- 自动发布版本。
- 请根据需求更新对应内容。
draft: false
prerelease: false
@@ -45,4 +41,4 @@ jobs:
upload_url: ${{ steps.create_release.outputs.upload_url }}
asset_path: ./gemini-balance.zip # 替换为你的构建文件路径
asset_name: gemini-balance.zip # 替换为你的文件名
asset_content_type: application/zip
asset_content_type: application/zip

View File

@@ -272,28 +272,45 @@ uvicorn app.main:app --host 0.0.0.0 --port 8000 --reload
### Web界面功能
#### 验证页面
#### 验证页面 (auth.html)
- **URL**: `/auth`
- **说明**: 提供了一个简洁的Web界面用于验证访问令牌
- **功能**:
- 美观的用户界面,支持响应式设计
- **功能特点**:
- 现代化的渐变背景设计
- 响应式布局,完美支持移动端
- 毛玻璃效果的卡片设计
- 优雅的动画效果(淡入、滑动、悬浮)
- 安全的令牌验证机制
- 错误提示功能
- 支持移动端访问
- 清晰的错误提示功能
- PWA支持可安装为本地应用
- 底部版权信息和GitHub链接
- 支持暗色主题适配
#### API密钥状态管理
#### API密钥状态管理 (keys_status.html)
- **URL**: `/v1/keys/list`
- **Method**: `GET`
- **Header**: `Authorization: Bearer <your-auth-token>`
- **说明**:
- **功能特点**:
- 只有使用 `AUTH_TOKEN` 才能访问此接口
- 提供了可视化的Web界面展示API密钥状态
- 支持查看有效和无效的API密钥列表
- 显示每个密钥的失败次数统计
- 提供一键复制功能(支持复制单个密钥或批量复制
- 实时显示密钥总数统计
- 分类展示API密钥状态(有效/无效)
- 可折叠的密钥列表分组
- 每个密钥显示:
- 状态标识(有效/无效
- 密钥内容
- 失败次数统计
- 高级功能:
- 一键复制单个密钥
- 批量复制分组密钥JSON格式
- 实时刷新功能
- 回到顶部/底部快捷按钮
- 界面特性:
- 响应式设计,适配各种屏幕
- 优雅的动画效果
- 操作反馈(复制成功提示)
- PWA支持
- 暗色主题适配
### 图片生成 (Image Generation)

View File

@@ -47,7 +47,7 @@ async def list_models(_=Depends(security_service.verify_key),
@router.post("/models/{model_name}:generateContent")
@router_v1beta.post("/models/{model_name}:generateContent")
@RetryHandler(max_retries=3, key_manager=Depends(get_key_manager), key_arg="api_key")
@RetryHandler(max_retries=3, key_arg="api_key")
async def generate_content(
model_name: str,
request: GeminiRequest,
@@ -77,7 +77,7 @@ async def generate_content(
@router.post("/models/{model_name}:streamGenerateContent")
@router_v1beta.post("/models/{model_name}:streamGenerateContent")
@RetryHandler(max_retries=3, key_manager=Depends(get_key_manager), key_arg="api_key")
@RetryHandler(max_retries=3, key_arg="api_key")
async def stream_generate_content(
model_name: str,
request: GeminiRequest,

View File

@@ -46,7 +46,7 @@ async def list_models(
@router.post("/v1/chat/completions")
@router.post("/hf/v1/chat/completions")
@RetryHandler(max_retries=3, key_manager=Depends(get_key_manager), key_arg="api_key")
@RetryHandler(max_retries=3, key_arg="api_key")
async def chat_completion(
request: ChatRequest,
_=Depends(security_service.verify_authorization),

View File

@@ -2,6 +2,7 @@ from fastapi import FastAPI, Request
from fastapi.middleware.cors import CORSMiddleware
from fastapi.responses import HTMLResponse, RedirectResponse
from fastapi.templating import Jinja2Templates
from fastapi.staticfiles import StaticFiles
from app.core.logger import get_main_logger
from app.core.security import verify_auth_token
from app.services.key_manager import get_key_manager_instance
@@ -19,6 +20,9 @@ app = FastAPI()
# 配置Jinja2模板
templates = Jinja2Templates(directory="app/templates")
# 配置静态文件
app.mount("/static", StaticFiles(directory="app/static"), name="static")
# 创建 KeyManager 实例
key_manager = None

View File

@@ -232,7 +232,9 @@ def _extract_text(response: Dict[str, Any], model: str, stream: bool = False) ->
else:
text = candidate["content"]["parts"][0]["text"]
else:
text = candidate["content"]["parts"][0]["text"]
text = ""
for part in candidate["content"]["parts"]:
text += part["text"]
text = _add_search_link_text(model, candidate, text)
else:
text = "暂无返回"

View File

@@ -3,7 +3,6 @@
from typing import TypeVar, Callable
from functools import wraps
from app.core.logger import get_retry_logger
from app.services.key_manager import KeyManager
T = TypeVar('T')
logger = get_retry_logger()
@@ -12,9 +11,8 @@ logger = get_retry_logger()
class RetryHandler:
"""重试处理装饰器"""
def __init__(self, max_retries: int = 3, key_manager: KeyManager = None, key_arg: str = "api_key"):
def __init__(self, max_retries: int = 3, key_arg: str = "api_key"):
self.max_retries = max_retries
self.key_manager = key_manager
self.key_arg = key_arg
def __call__(self, func: Callable[..., T]) -> Callable[..., T]:
@@ -29,9 +27,11 @@ class RetryHandler:
last_exception = e
logger.warning(f"API call failed with error: {str(e)}. Attempt {attempt + 1} of {self.max_retries}")
if self.key_manager:
# 从函数参数中获取 key_manager
key_manager = kwargs.get('key_manager')
if key_manager:
old_key = kwargs.get(self.key_arg)
new_key = await self.key_manager.handle_api_failure(old_key)
new_key = await key_manager.handle_api_failure(old_key)
kwargs[self.key_arg] = new_key
logger.info(f"Switched to new API key: {new_key}")

Binary file not shown.

After

Width:  |  Height:  |  Size: 25 KiB

17
app/static/manifest.json Normal file
View File

@@ -0,0 +1,17 @@
{
"name": "Gemini Balance",
"short_name": "GBalance",
"description": "Gemini API密钥管理工具",
"start_url": "/",
"display": "standalone",
"background_color": "#667eea",
"theme_color": "#764ba2",
"icons": [
{
"src": "/static/icons/icon-192x192.png",
"sizes": "192x192",
"type": "image/png",
"purpose": "any maskable"
}
]
}

View File

@@ -0,0 +1,43 @@
const CACHE_NAME = 'gbalance-cache-v1';
const urlsToCache = [
'/',
'/static/manifest.json',
'/static/icons/icon-192x192.png'
];
self.addEventListener('install', event => {
event.waitUntil(
caches.open(CACHE_NAME)
.then(cache => {
console.log('Opened cache');
return cache.addAll(urlsToCache);
})
);
});
self.addEventListener('fetch', event => {
event.respondWith(
caches.match(event.request)
.then(response => {
if (response) {
return response;
}
return fetch(event.request);
})
);
});
self.addEventListener('activate', event => {
const cacheWhitelist = [CACHE_NAME];
event.waitUntil(
caches.keys().then(cacheNames => {
return Promise.all(
cacheNames.map(cacheName => {
if (cacheWhitelist.indexOf(cacheName) === -1) {
return caches.delete(cacheName);
}
})
);
})
);
});

View File

@@ -4,14 +4,21 @@
<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">
<style>
body {
font-family: 'Roboto', sans-serif;
line-height: 1.6;
margin: 0;
padding: 0;
background: linear-gradient(135deg, #f5f7fa 0%, #c3cfe2 100%);
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
min-height: 100vh;
display: flex;
justify-content: center;
@@ -20,70 +27,256 @@
.container {
max-width: 400px;
width: 90%;
background: white;
background: rgba(255, 255, 255, 0.95);
padding: 40px;
border-radius: 15px;
box-shadow: 0 10px 30px rgba(0,0,0,0.1);
transition: all 0.3s ease;
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);
}
@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;
}
}
.container:hover {
transform: translateY(-5px);
box-shadow: 0 15px 35px rgba(0,0,0,0.15);
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 {
margin: 10px 0;
padding: 12px;
border: 1px solid #e0e0e0;
border-radius: 5px;
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: #3498db;
box-shadow: 0 0 5px rgba(52, 152, 219, 0.5);
border-color: #764ba2;
box-shadow: 0 0 10px rgba(118, 75, 162, 0.2);
outline: none;
}
button {
background-color: #3498db;
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
color: white;
border: none;
padding: 12px;
border-radius: 5px;
padding: 14px;
border-radius: 10px;
cursor: pointer;
font-size: 16px;
font-weight: bold;
transition: background-color 0.3s ease;
transition: all 0.3s ease;
position: relative;
overflow: hidden;
}
button:hover {
background-color: #2980b9;
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;
}
@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); }
}
</style>
</head>
<body>
<div class="container">
<h2>请输入验证令牌</h2>
<div class="logo">
<i class="fas fa-shield-alt"></i>
</div>
<h2>安全验证</h2>
<form id="auth-form" action="/auth" method="post">
<input type="password" id="auth-token" name="auth_token" required placeholder="输入验证令牌">
<button type="submit">提交</button>
<div class="input-group">
<i class="fas fa-key"></i>
<input type="password" id="auth-token" name="auth_token" required placeholder="请输入验证令牌">
</div>
<button type="submit">
验证访问
</button>
</form>
{% if error %}
<p class="error-message">{{ error }}</p>
{% endif %}
</div>
<script>
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);
});
});
}
</script>
<style>
.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;
}
</style>
<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>
</body>
</html>

View File

@@ -4,150 +4,476 @@
<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">
<style>
body {
font-family: 'Roboto', sans-serif;
line-height: 1.6;
margin: 0;
padding: 0;
background: linear-gradient(135deg, #f5f7fa 0%, #c3cfe2 100%);
padding: 20px;
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
min-height: 100vh;
display: flex;
justify-content: center;
align-items: center;
}
.container {
max-width: 900px;
width: 90%;
background: white;
width: 95%;
background: rgba(255, 255, 255, 0.95);
padding: 40px;
border-radius: 15px;
box-shadow: 0 10px 30px rgba(0,0,0,0.1);
transition: all 0.3s ease;
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; /* Firefox */
-ms-overflow-style: none; /* IE and Edge */
}
.container:hover {
transform: translateY(-5px);
box-shadow: 0 15px 35px rgba(0,0,0,0.15);
.container::-webkit-scrollbar {
display: none; /* Chrome, Safari, Opera */
}
@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;
}
.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;
}
}
@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;
}
}
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: #f8f9fa;
padding: 20px;
border-radius: 10px;
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);
}
.key-list:hover {
background: #e9ecef;
transform: translateY(-5px);
box-shadow: 0 10px 20px rgba(0,0,0,0.1);
}
.key-list h2 {
color: #2c3e50;
margin-bottom: 15px;
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 #e0e0e0;
margin-bottom: 10px;
padding: 12px;
border-radius: 5px;
transition: all 0.2s ease;
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: 2px 2px 5px rgba(0,0,0,0.1);
box-shadow: 0 5px 15px rgba(0,0,0,0.1);
}
.total {
font-weight: bold;
margin-top: 20px;
text-align: center;
font-size: 1.2em;
.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;
}
.copy-btn {
background-color: #3498db;
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
color: white;
border: none;
padding: 8px 15px;
border-radius: 5px;
border-radius: 8px;
cursor: pointer;
font-size: 14px;
font-weight: bold;
transition: background-color 0.3s ease;
transition: all 0.3s ease;
display: flex;
align-items: center;
gap: 5px;
}
.copy-btn:hover {
background-color: #2980b9;
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 {
text-align: center;
margin-top: 20px;
color: #27ae60;
position: fixed;
top: 50%;
left: 50%;
transform: translate(-50%, -50%);
background: rgba(39, 174, 96, 0.95);
color: white;
padding: 15px 30px;
border-radius: 25px;
font-weight: bold;
opacity: 0;
transition: opacity 0.3s ease;
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;
}
.fail-count {
color: #e74c3c;
.status-badge {
padding: 4px 12px;
border-radius: 15px;
font-size: 0.9em;
margin-left: 10px;
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;
}
@keyframes fadeIn {
from { opacity: 0; transform: translateY(20px); }
to { opacity: 1; transform: translateY(0); }
}
.key-list {
animation: fadeIn 0.5s ease forwards;
}
.key-list:nth-child(2) {
animation-delay: 0.2s;
}
.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: absolute;
top: 20px;
right: 20px;
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);
z-index: 10;
}
.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;
}
@keyframes spin {
from { transform: rotate(0deg); }
to { transform: rotate(360deg); }
}
@media (max-width: 768px) {
.refresh-btn {
top: 10px;
right: 10px;
padding: 8px 16px;
font-size: 12px;
}
}
</style>
</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>
有效密钥
<button class="copy-btn" onclick="copyKeys('valid')">一键复制</button>
<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>
批量复制
</button>
</h2>
<ul id="validKeys">
<div class="key-content">
<ul id="validKeys">
{% for key, fail_count in valid_keys.items() %}
<li>
<span>{{ key }}</span>
<span class="fail-count">(失败次数: {{ fail_count }})</span>
<button class="copy-btn" onclick="copyKey('{{ key }}')">复制</button>
<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>
<button class="copy-btn" onclick="copyKey('{{ key }}')">
<i class="fas fa-copy"></i>
复制
</button>
</li>
{% endfor %}
</ul>
</ul>
</div>
</div>
<div class="key-list">
<h2>
无效密钥
<button class="copy-btn" onclick="copyKeys('invalid')">一键复制</button>
<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>
<ul id="invalidKeys">
<div class="key-content">
<ul id="invalidKeys">
{% for key, fail_count in invalid_keys.items() %}
<li>
<span>{{ key }}</span>
<span class="fail-count">(失败次数: {{ fail_count }})</span>
<button class="copy-btn" onclick="copyKey('{{ key }}')">复制</button>
<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>
<button class="copy-btn" onclick="copyKey('{{ key }}')">
<i class="fas fa-copy"></i>
复制
</button>
</li>
{% endfor %}
</ul>
</ul>
</div>
</div>
<div class="total">
总密钥数:{{ total }}
<i class="fas fa-key"></i> 总密钥数:{{ total }}
</div>
<div id="copyStatus"></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>
<script>
function copyToClipboard(text) {
if (navigator.clipboard && navigator.clipboard.writeText) {
@@ -177,12 +503,12 @@
}
function copyKeys(type) {
const keys = Array.from(document.querySelectorAll(`#${type}Keys li span:first-child`)).map(span => span.textContent.trim());
const keys = Array.from(document.querySelectorAll(`#${type}Keys .key-text`)).map(span => span.textContent.trim());
const jsonKeys = JSON.stringify(keys);
copyToClipboard(jsonKeys)
.then(() => {
showCopyStatus(`已成功复制 ${type === 'valid' ? '有效' : '无效'} 密钥到剪贴板`);
showCopyStatus(`已成功复制${type === 'valid' ? '有效' : '无效'}密钥到剪贴板`);
})
.catch((err) => {
console.error('无法复制文本: ', err);
@@ -209,6 +535,107 @@
statusElement.style.opacity = 0;
}, 2000);
}
function scrollToTop() {
const container = document.querySelector('.container');
container.scrollTo({
top: 0,
behavior: 'smooth'
});
}
function scrollToBottom() {
const container = document.querySelector('.container');
container.scrollTo({
top: container.scrollHeight,
behavior: 'smooth'
});
}
// 监听container高度变化来显示/隐藏滚动按钮
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';
}
}
// 初始检查
updateScrollButtons();
// 监听展开/折叠事件
document.querySelectorAll('.key-list h2').forEach(header => {
header.addEventListener('click', () => {
setTimeout(updateScrollButtons, 300); // 等待展开动画完成
});
});
function refreshPage(button) {
button.classList.add('loading');
button.disabled = true;
// 添加延迟以显示加载动画
setTimeout(() => {
window.location.reload();
}, 300);
}
</script>
<script>
function toggleSection(header, sectionId) {
const toggleIcon = header.querySelector('.toggle-icon');
const content = header.nextElementSibling;
toggleIcon.classList.toggle('collapsed');
content.classList.toggle('collapsed');
}
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);
});
});
}
</script>
<style>
.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;
}
</style>
<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>
</body>
</html>