Compare commits

...

18 Commits

Author SHA1 Message Date
snaily
88d483c1ef Merge pull request #4 from toddyoe/main
chore: add system instruction to enhance compliance with function call
2025-02-27 19:17:39 +08:00
Toddy
8d48db026c chore: add system instruction to enhance compliance with function call 2025-02-27 10:35:25 +00:00
snaily
a592269198 Merge pull request #3 from toddyoe/main
feat: support function call
2025-02-27 16:14:50 +08:00
Toddy
18a5fe6109 fix: adapt gemini format 2025-02-27 07:35:12 +00:00
Toddy
348cbbdf2a feat: support function call 2025-02-27 05:36:39 +00:00
yinpeng
64235143dd ci: 精简 release workflow 文件
- 移除了 release-drafter 相关的步骤
- 保留了代码检出和创建 Release 的步骤
- 简化了工作流结构,提高了可读性
2025-02-15 01:06:32 +08:00
yinpeng
d566c28fa2 feat(gemini): 添加 API 密钥验证功能
- 在 gemini_routes.py 中添加 verify_key 路由,用于验证 API 密钥的有效性
- 在 keys_status 页面中添加验证按钮和相关逻辑
- 优化 keys_status 页面的样式,增加密钥验证相关 CSS 类
- 在 config.py 中添加 TEST_MODEL 设置,用于密钥验证测试
2025-02-15 01:00:47 +08:00
yinpeng
c1893d918e build: 更新发布流程并移除 release-drafter 配置
- 删除了 release-drafter.yml 文件,不再使用 release-drafter 自动生成发布说明
- 更新了 release.yml 工作流,移除了自动填充发布说明的步骤
- 保留了创建 ZIP 文件和上传构建文件的步骤,但标记为可选
2025-02-14 01:55:44 +08:00
yinpeng
4a02475cc1 ci: 优化发布流程,使用 release-drafter 自动生成发布说明 2025-02-14 01:46:24 +08:00
yinpeng
6e55a0985c ci: 优化发布流程,添加自动生成发布说明和资源打包功能 2025-02-14 01:40:20 +08:00
yinpeng
7b433aab91 refactor(static): 将 CSS 和 JS 代码分离到单独的文件中
- 将 auth.html 中的 CSS 代码提取到 auth.css 文件中
- 将 auth.html 中的 JS 代码提取到 auth.js 文件中
- 更新 auth.html,引入外部的 CSS 和 JS 文件
- 新增 keys_status.css 和 keys_status.js 文件,用于 keys_status 页面
2025-02-14 00:21:28 +08:00
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
15 changed files with 1153 additions and 674 deletions

View File

@@ -6,9 +6,10 @@ on:
- 'v*' # 当推送以 "v" 开头的标签时触发(如 v1.0.0, v2.1.0
jobs:
release:
update-release-draft:
permissions:
contents: write # 添加写入权限
contents: write
pull-requests: write
runs-on: ubuntu-latest
steps:
# Step 1: 检出代码库

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

@@ -1,10 +1,10 @@
from fastapi import APIRouter, Depends, HTTPException
from fastapi.responses import StreamingResponse
from fastapi.responses import StreamingResponse, JSONResponse
from app.core.config import settings
from app.core.logger import get_gemini_logger
from app.core.security import SecurityService
from app.schemas.gemini_models import GeminiRequest
from app.schemas.gemini_models import GeminiContent, GeminiRequest
from app.services.gemini_chat_service import GeminiChatService
from app.services.key_manager import KeyManager, get_key_manager_instance
from app.services.model_service import ModelService
@@ -102,3 +102,30 @@ async def stream_generate_content(
except Exception as e:
logger.error(f"Streaming request failed: {str(e)}")
@router.post("/verify-key/{api_key}")
async def verify_key(api_key: str):
key_manager = await get_key_manager()
chat_service = GeminiChatService(settings.BASE_URL, key_manager)
"""验证Gemini API密钥的有效性"""
logger.info("-" * 50 + "verify_gemini_key" + "-" * 50)
logger.info("Verifying API key validity")
try:
# 使用generate_content接口测试key的有效性
gemini_requset = GeminiRequest(
contents=[
GeminiContent(
role="user",
parts=[{"text": "hi"}]
)
]
)
response = chat_service.generate_content(settings.TEST_MODEL,gemini_requset, api_key)
if response:
return JSONResponse({"status": "valid"})
return JSONResponse({"status": "invalid"})
except Exception as e:
logger.error(f"Key verification failed: {str(e)}")
return JSONResponse({"status": "invalid", "error": str(e)})

View File

@@ -16,6 +16,7 @@ class Settings(BaseSettings):
CREATE_IMAGE_MODEL: str = "imagen-3.0-generate-002"
UPLOAD_PROVIDER: str = "smms"
SMMS_SECRET_TOKEN: str = ""
TEST_MODEL: str = "gemini-1.5-flash"
def __init__(self):
super().__init__()

View File

@@ -1,14 +1,16 @@
# app/services/chat/message_converter.py
from abc import ABC, abstractmethod
from typing import List, Dict, Any
from typing import Any, Dict, List, Optional
SUPPORTED_ROLES = ["user", "model", "system"]
class MessageConverter(ABC):
"""消息转换器基类"""
@abstractmethod
def convert(self, messages: List[Dict[str, Any]]) -> List[Dict[str, Any]]:
def convert(self, messages: List[Dict[str, Any]]) -> tuple[List[Dict[str, Any]], Optional[Dict[str, Any]]]:
pass
@@ -30,24 +32,33 @@ def _convert_image(image_url: str) -> Dict[str, Any]:
class OpenAIMessageConverter(MessageConverter):
"""OpenAI消息格式转换器"""
def convert(self, messages: List[Dict[str, Any]]) -> List[Dict[str, Any]]:
def convert(self, messages: List[Dict[str, Any]]) -> tuple[List[Dict[str, Any]], Optional[Dict[str, Any]]]:
converted_messages = []
for msg in messages:
role = "user" if msg["role"] == "user" else "model"
parts = []
system_instruction = None
if isinstance(msg["content"], str):
for msg in messages:
role = msg.get("role", "")
if role not in SUPPORTED_ROLES:
role = "model"
parts = []
if isinstance(msg["content"], str) and msg["content"]:
# 请求 gemini 接口时如果包含 content 字段但内容为空时会返回 400 错误,所以需要判断是否为空并移除
parts.append({"text": msg["content"]})
elif isinstance(msg["content"], list):
for content in msg["content"]:
if isinstance(content, str):
if isinstance(content, str) and content:
parts.append({"text": content})
elif isinstance(content, dict):
if content["type"] == "text":
if content["type"] == "text" and content["text"]:
parts.append({"text": content["text"]})
elif content["type"] == "image_url":
parts.append(_convert_image(content["image_url"]["url"]))
converted_messages.append({"role": role, "parts": parts})
if parts:
if role == "system":
system_instruction = {"role": "system", "parts": parts}
else:
converted_messages.append({"role": role, "parts": parts})
return converted_messages
return converted_messages, system_instruction

View File

@@ -1,7 +1,10 @@
# app/services/chat/response_handler.py
import json
import random
import string
from abc import ABC, abstractmethod
from typing import Dict, Any, Optional
from typing import Dict, Any, List, Optional
import time
import uuid
from app.core.config import settings
@@ -29,40 +32,38 @@ class GeminiResponseHandler(ResponseHandler):
def _handle_openai_stream_response(response: Dict[str, Any], model: str, finish_reason: str) -> Dict[str, Any]:
text = _extract_text(response, model, stream=True)
text, tool_calls = _extract_result(response, model, stream=True, gemini_format=False)
if not text and not tool_calls:
delta = {}
else:
delta = {"content": text, "role": "assistant"}
if tool_calls:
delta["tool_calls"] = tool_calls
return {
"id": f"chatcmpl-{uuid.uuid4()}",
"object": "chat.completion.chunk",
"created": int(time.time()),
"model": model,
"choices": [{
"index": 0,
"delta": {"content": text} if text else {},
"finish_reason": finish_reason
}]
"choices": [{"index": 0, "delta": delta, "finish_reason": finish_reason}],
}
def _handle_openai_normal_response(response: Dict[str, Any], model: str, finish_reason: str) -> Dict[str, Any]:
text = _extract_text(response, model, stream=False)
text, tool_calls = _extract_result(response, model, stream=False, gemini_format=False)
return {
"id": f"chatcmpl-{uuid.uuid4()}",
"object": "chat.completion",
"created": int(time.time()),
"model": model,
"choices": [{
"index": 0,
"message": {
"role": "assistant",
"content": text
},
"finish_reason": finish_reason
}],
"usage": {
"prompt_tokens": 0,
"completion_tokens": 0,
"total_tokens": 0
}
"choices": [
{
"index": 0,
"message": {"role": "assistant", "content": text, "tool_calls": tool_calls},
"finish_reason": finish_reason,
}
],
"usage": {"prompt_tokens": 0, "completion_tokens": 0, "total_tokens": 0},
}
@@ -127,8 +128,8 @@ def _handle_openai_normal_image_response(image_str: str,model: str,finish_reason
}
def _extract_text(response: Dict[str, Any], model: str, stream: bool = False) -> str:
text = ""
def _extract_result(response: Dict[str, Any], model: str, stream: bool = False, gemini_format: bool = False) -> tuple[str, List[Dict[str, Any]]]:
text, tool_calls = "", []
if stream:
if response.get("candidates"):
candidate = response["candidates"][0]
@@ -212,6 +213,7 @@ def _extract_text(response: Dict[str, Any], model: str, stream: bool = False) ->
else:
text = ""
text = _add_search_link_text(model, candidate, text)
tool_calls = _extract_tool_calls(parts, gemini_format)
else:
if response.get("candidates"):
candidate = response["candidates"][0]
@@ -232,23 +234,67 @@ 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.get("text", "")
text = _add_search_link_text(model, candidate, text)
tool_calls = _extract_tool_calls(candidate["content"]["parts"], gemini_format)
else:
text = "暂无返回"
return text
return text, tool_calls
def _extract_tool_calls(parts: List[Dict[str, Any]], gemini_format: bool) -> List[Dict[str, Any]]:
"""提取工具调用信息"""
if not parts or not isinstance(parts, list):
return []
letters = string.ascii_lowercase + string.digits
tool_calls = list()
for i in range(len(parts)):
part = parts[i]
if not part or not isinstance(part, dict):
continue
item = part.get("functionCall", {})
if not item or not isinstance(item, dict):
continue
if gemini_format:
tool_calls.append(part)
else:
id = f"call_{''.join(random.sample(letters, 32))}"
name = item.get("name", "")
arguments = json.dumps(item.get("args", None) or {})
tool_calls.append(
{
"index": i,
"id": id,
"type": "function",
"function": {"name": name, "arguments": arguments},
}
)
return tool_calls
def _handle_gemini_stream_response(response: Dict[str, Any], model: str, stream: bool) -> Dict[str, Any]:
text = _extract_text(response, model, stream=stream)
content = {"parts": [{"text": text}], "role": "model"}
text, tool_calls = _extract_result(response, model, stream=stream, gemini_format=True)
if tool_calls:
content = {"parts": tool_calls, "role": "model"}
else:
content = {"parts": [{"text": text}], "role": "model"}
response["candidates"][0]["content"] = content
return response
def _handle_gemini_normal_response(response: Dict[str, Any], model: str, stream: bool) -> Dict[str, Any]:
text = _extract_text(response, model, stream=stream)
content = {"parts": [{"text": text}], "role": "model"}
text, tool_calls = _extract_result(response, model, stream=stream, gemini_format=True)
if tool_calls:
content = {"parts": tool_calls, "role": "model"}
else:
content = {"parts": [{"text": text}], "role": "model"}
response["candidates"][0]["content"] = content
return response

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()

View File

@@ -31,6 +31,12 @@ def _build_tools(model: str, payload: Dict[str, Any]) -> List[Dict[str, Any]]:
tools.append({"code_execution": {}})
if model.endswith("-search"):
tools.append({"googleSearch": {}})
if payload and isinstance(payload, dict) and "tools" in payload:
items = payload.get("tools", [])
if items and isinstance(items, list):
tools.extend(items)
return tools

View File

@@ -1,7 +1,8 @@
# app/services/chat_service.py
from copy import deepcopy
import json
from typing import Dict, Any, AsyncGenerator, List, Union
from typing import Dict, Any, AsyncGenerator, List, Optional, Union
from app.core.logger import get_openai_logger
from app.services.chat.message_converter import OpenAIMessageConverter
from app.services.chat.response_handler import OpenAIResponseHandler
@@ -39,6 +40,25 @@ def _build_tools(
tools.append({"code_execution": {}})
if model.endswith("-search"):
tools.append({"googleSearch": {}})
# 将 request 中的 tools 合并到 tools 中
if request.tools:
function_declarations = []
for tool in request.tools:
if not tool or not isinstance(tool, dict):
continue
if tool.get("type", "") == "function" and tool.get("function"):
function = deepcopy(tool.get("function"))
parameters = function.get("parameters", {})
if parameters.get("type") == "object" and not parameters.get("properties", {}):
function.pop("parameters", None)
function_declarations.append(function)
if function_declarations:
tools.append({"functionDeclarations": function_declarations})
return tools
@@ -67,10 +87,10 @@ def _get_safety_settings(model: str) -> List[Dict[str, str]]:
def _build_payload(
request: ChatRequest, messages: List[Dict[str, Any]]
request: ChatRequest, messages: List[Dict[str, Any]], instruction: Optional[Dict[str, Any]] = None
) -> Dict[str, Any]:
"""构建请求payload"""
return {
payload = {
"contents": messages,
"generationConfig": {
"temperature": request.temperature,
@@ -83,6 +103,16 @@ def _build_payload(
"safetySettings": _get_safety_settings(request.model),
}
if (
instruction
and isinstance(instruction, dict)
and instruction.get("role") == "system"
and instruction.get("parts")
):
payload["systemInstruction"] = instruction
return payload
class OpenAIChatService:
"""聊天服务"""
@@ -100,10 +130,10 @@ class OpenAIChatService:
) -> Union[Dict[str, Any], AsyncGenerator[str, None]]:
"""创建聊天完成"""
# 转换消息格式
messages = self.message_converter.convert(request.messages)
messages, instruction = self.message_converter.convert(request.messages)
# 构建请求payload
payload = _build_payload(request, messages)
payload = _build_payload(request, messages, instruction)
if request.stream:
return self._handle_stream_completion(request.model, payload, api_key)

249
app/static/css/auth.css Normal file
View File

@@ -0,0 +1,249 @@
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;
}
}

View File

@@ -0,0 +1,461 @@
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;
}
}

18
app/static/js/auth.js Normal file
View File

@@ -0,0 +1,18 @@
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();
}
});

View File

@@ -0,0 +1,175 @@
function copyToClipboard(text) {
if (navigator.clipboard && navigator.clipboard.writeText) {
return navigator.clipboard.writeText(text);
} else {
return new Promise((resolve, reject) => {
const textArea = document.createElement("textarea");
textArea.value = text;
textArea.style.position = "fixed";
document.body.appendChild(textArea);
textArea.focus();
textArea.select();
try {
const successful = document.execCommand('copy');
document.body.removeChild(textArea);
if (successful) {
resolve();
} else {
reject(new Error('复制失败'));
}
} catch (err) {
document.body.removeChild(textArea);
reject(err);
}
});
}
}
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)
.then(() => {
showCopyStatus(`已成功复制${type === 'valid' ? '有效' : '无效'}密钥到剪贴板`);
})
.catch((err) => {
console.error('无法复制文本: ', err);
showCopyStatus('复制失败,请重试');
});
}
function copyKey(key) {
copyToClipboard(key)
.then(() => {
showCopyStatus(`已成功复制密钥到剪贴板`);
})
.catch((err) => {
console.error('无法复制文本: ', err);
showCopyStatus('复制失败,请重试');
});
}
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);
}
async function verifyKey(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/verify-key/${key}`, {
method: 'POST'
});
const data = await response.json();
// 根据验证结果更新UI
if (data.status === 'valid') {
showCopyStatus('密钥验证成功', 'success');
button.style.backgroundColor = '#27ae60';
} else {
showCopyStatus('密钥验证失败', 'error');
button.style.backgroundColor = '#e74c3c';
}
// 3秒后恢复按钮原始状态
setTimeout(() => {
button.innerHTML = originalHtml;
button.disabled = false;
button.style.backgroundColor = '';
}, 3000);
} catch (error) {
console.error('验证失败:', error);
showCopyStatus('验证请求失败', 'error');
button.disabled = false;
button.innerHTML = '<i class="fas fa-check-circle"></i> 验证';
}
}
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'
});
}
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) {
button.classList.add('loading');
button.disabled = true;
setTimeout(() => {
window.location.reload();
}, 300);
}
function toggleSection(header, sectionId) {
const toggleIcon = header.querySelector('.toggle-icon');
const content = header.nextElementSibling;
toggleIcon.classList.toggle('collapsed');
content.classList.toggle('collapsed');
}
// 初始化
document.addEventListener('DOMContentLoaded', () => {
// 检查滚动按钮
updateScrollButtons();
// 监听展开/折叠事件
document.querySelectorAll('.key-list h2').forEach(header => {
header.addEventListener('click', () => {
setTimeout(updateScrollButtons, 300);
});
});
// 更新版权年份
const copyrightYear = document.querySelector('.copyright script');
if (copyrightYear) {
copyrightYear.textContent = new Date().getFullYear();
}
});
// Service Worker registration
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);
});
});
}

View File

@@ -12,205 +12,7 @@
<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, #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);
}
@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 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;
}
@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>
<link rel="stylesheet" href="/static/css/auth.css">
</head>
<body>
<div class="container">
@@ -231,18 +33,10 @@
<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>
<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>

View File

@@ -12,307 +12,28 @@
<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: 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;
}
@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: 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 {
transform: translateY(-5px);
box-shadow: 0 10px 20px rgba(0,0,0,0.1);
}
.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);
}
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;
}
.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;
}
.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%);
background: rgba(39, 174, 96, 0.95);
color: white;
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;
}
.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;
}
@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: flex;
flex-direction: column;
gap: 10px;
z-index: 1000;
}
.scroll-btn {
background: rgba(118, 75, 162, 0.9);
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: rgba(102, 126, 234, 0.9);
transform: scale(1.1);
}
.scroll-btn:active {
transform: scale(0.95);
}
</style>
<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>
<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="copyKeys('valid')">
<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>
<div class="key-info">
@@ -325,26 +46,35 @@
失败: {{ fail_count }}
</span>
</div>
<button class="copy-btn" onclick="copyKey('{{ key }}')">
<i class="fas fa-copy"></i>
复制
</button>
<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>
</ul>
</div>
</div>
<div class="key-list">
<h2>
<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="copyKeys('invalid')">
<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>
<div class="key-info">
@@ -357,13 +87,20 @@
失败: {{ fail_count }}
</span>
</div>
<button class="copy-btn" onclick="copyKey('{{ key }}')">
<i class="fas fa-copy"></i>
复制
</button>
<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>
</ul>
</div>
</div>
<div class="total">
<i class="fas fa-key"></i> 总密钥数:{{ total }}
@@ -381,104 +118,11 @@
<div id="copyStatus"></div>
<script>
function copyToClipboard(text) {
if (navigator.clipboard && navigator.clipboard.writeText) {
return navigator.clipboard.writeText(text);
} else {
return new Promise((resolve, reject) => {
const textArea = document.createElement("textarea");
textArea.value = text;
textArea.style.position = "fixed";
document.body.appendChild(textArea);
textArea.focus();
textArea.select();
try {
const successful = document.execCommand('copy');
document.body.removeChild(textArea);
if (successful) {
resolve();
} else {
reject(new Error('复制失败'));
}
} catch (err) {
document.body.removeChild(textArea);
reject(err);
}
});
}
}
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)
.then(() => {
showCopyStatus(`已成功复制${type === 'valid' ? '有效' : '无效'}密钥到剪贴板`);
})
.catch((err) => {
console.error('无法复制文本: ', err);
showCopyStatus('复制失败,请重试');
});
}
function copyKey(key) {
copyToClipboard(key)
.then(() => {
showCopyStatus(`已成功复制密钥到剪贴板`);
})
.catch((err) => {
console.error('无法复制文本: ', err);
showCopyStatus('复制失败,请重试');
});
}
function showCopyStatus(message) {
const statusElement = document.getElementById('copyStatus');
statusElement.textContent = message;
statusElement.style.opacity = 1;
setTimeout(() => {
statusElement.style.opacity = 0;
}, 2000);
}
function scrollToTop() {
window.scrollTo({
top: 0,
behavior: 'smooth'
});
}
function scrollToBottom() {
window.scrollTo({
top: document.documentElement.scrollHeight,
behavior: 'smooth'
});
}
// 监听窗口滚动事件来显示/隐藏滚动按钮
window.addEventListener('scroll', function() {
const scrollButtons = document.querySelector('.scroll-buttons');
if (window.scrollY > 100) {
scrollButtons.style.display = 'flex';
} else {
scrollButtons.style.display = 'none';
}
});
</script>
<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>
<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>