Compare commits

...

9 Commits

Author SHA1 Message Date
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
11 changed files with 463 additions and 36 deletions

View File

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

View File

@@ -47,7 +47,7 @@ async def list_models(_=Depends(security_service.verify_key),
@router.post("/models/{model_name}:generateContent") @router.post("/models/{model_name}:generateContent")
@router_v1beta.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( async def generate_content(
model_name: str, model_name: str,
request: GeminiRequest, request: GeminiRequest,
@@ -77,7 +77,7 @@ async def generate_content(
@router.post("/models/{model_name}:streamGenerateContent") @router.post("/models/{model_name}:streamGenerateContent")
@router_v1beta.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( async def stream_generate_content(
model_name: str, model_name: str,
request: GeminiRequest, request: GeminiRequest,

View File

@@ -46,7 +46,7 @@ async def list_models(
@router.post("/v1/chat/completions") @router.post("/v1/chat/completions")
@router.post("/hf/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( async def chat_completion(
request: ChatRequest, request: ChatRequest,
_=Depends(security_service.verify_authorization), _=Depends(security_service.verify_authorization),

View File

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

View File

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

View File

@@ -3,7 +3,6 @@
from typing import TypeVar, Callable from typing import TypeVar, Callable
from functools import wraps from functools import wraps
from app.core.logger import get_retry_logger from app.core.logger import get_retry_logger
from app.services.key_manager import KeyManager
T = TypeVar('T') T = TypeVar('T')
logger = get_retry_logger() logger = get_retry_logger()
@@ -12,9 +11,8 @@ logger = get_retry_logger()
class RetryHandler: 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.max_retries = max_retries
self.key_manager = key_manager
self.key_arg = key_arg self.key_arg = key_arg
def __call__(self, func: Callable[..., T]) -> Callable[..., T]: def __call__(self, func: Callable[..., T]) -> Callable[..., T]:
@@ -29,9 +27,11 @@ class RetryHandler:
last_exception = e last_exception = e
logger.warning(f"API call failed with error: {str(e)}. Attempt {attempt + 1} of {self.max_retries}") 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) 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 kwargs[self.key_arg] = new_key
logger.info(f"Switched to new API key: {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,6 +4,12 @@
<meta charset="UTF-8"> <meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0"> <meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>验证页面</title> <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 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="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.0.0/css/all.min.css">
<style> <style>
@@ -28,6 +34,64 @@
backdrop-filter: blur(10px); backdrop-filter: blur(10px);
transition: all 0.4s cubic-bezier(0.4, 0, 0.2, 1); 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 { .container:hover {
transform: translateY(-5px); transform: translateY(-5px);
box-shadow: 0 20px 40px rgba(0,0,0,0.25); box-shadow: 0 20px 40px rgba(0,0,0,0.25);
@@ -167,5 +231,52 @@
<p class="error-message">{{ error }}</p> <p class="error-message">{{ error }}</p>
{% endif %} {% endif %}
</div> </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> </body>
</html> </html>

View File

@@ -4,6 +4,12 @@
<meta charset="UTF-8"> <meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0"> <meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>API密钥状态</title> <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 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="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.0.0/css/all.min.css">
<style> <style>
@@ -25,6 +31,82 @@
backdrop-filter: blur(10px); backdrop-filter: blur(10px);
position: relative; position: relative;
margin: 20px auto; margin: 20px auto;
overflow-y: auto;
max-height: calc(100vh - 40px);
scrollbar-width: none; /* Firefox */
-ms-overflow-style: none; /* IE and Edge */
}
.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 { h1 {
color: #2c3e50; color: #2c3e50;
@@ -67,6 +149,30 @@
font-size: 1.5em; font-size: 1.5em;
padding-bottom: 10px; padding-bottom: 10px;
border-bottom: 2px solid rgba(0,0,0,0.1); 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 { ul {
list-style-type: none; list-style-type: none;
@@ -194,13 +300,13 @@
position: fixed; position: fixed;
right: 20px; right: 20px;
bottom: 20px; bottom: 20px;
display: flex; display: none;
flex-direction: column; flex-direction: column;
gap: 10px; gap: 10px;
z-index: 1000; z-index: 1000;
} }
.scroll-btn { .scroll-btn {
background: rgba(118, 75, 162, 0.9); background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
color: white; color: white;
width: 40px; width: 40px;
height: 40px; height: 40px;
@@ -216,29 +322,86 @@
box-shadow: 0 2px 10px rgba(0,0,0,0.2); box-shadow: 0 2px 10px rgba(0,0,0,0.2);
} }
.scroll-btn:hover { .scroll-btn:hover {
background: rgba(102, 126, 234, 0.9); background: linear-gradient(135deg, #764ba2 0%, #667eea 100%);
transform: scale(1.1); transform: scale(1.1);
} }
.scroll-btn:active { .scroll-btn:active {
transform: scale(0.95); 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> </style>
</head> </head>
<body> <body>
<div class="container"> <div class="container">
<button class="refresh-btn" onclick="refreshPage(this)">
<i class="fas fa-sync-alt"></i> 刷新
</button>
<h1>API密钥状态</h1> <h1>API密钥状态</h1>
<div class="key-list"> <div class="key-list">
<h2> <h2 onclick="toggleSection(this, 'validKeys')">
<span> <span>
<i class="fas fa-chevron-down toggle-icon"></i>
<i class="fas fa-check-circle" style="color: #27ae60;"></i> <i class="fas fa-check-circle" style="color: #27ae60;"></i>
有效密钥 有效密钥
</span> </span>
<button class="copy-btn" onclick="copyKeys('valid')"> <button class="copy-btn" onclick="event.stopPropagation(); copyKeys('valid')">
<i class="fas fa-copy"></i> <i class="fas fa-copy"></i>
批量复制 批量复制
</button> </button>
</h2> </h2>
<ul id="validKeys"> <div class="key-content">
<ul id="validKeys">
{% for key, fail_count in valid_keys.items() %} {% for key, fail_count in valid_keys.items() %}
<li> <li>
<div class="key-info"> <div class="key-info">
@@ -257,20 +420,23 @@
</button> </button>
</li> </li>
{% endfor %} {% endfor %}
</ul> </ul>
</div>
</div> </div>
<div class="key-list"> <div class="key-list">
<h2> <h2 onclick="toggleSection(this, 'invalidKeys')">
<span> <span>
<i class="fas fa-chevron-down toggle-icon"></i>
<i class="fas fa-times-circle" style="color: #e74c3c;"></i> <i class="fas fa-times-circle" style="color: #e74c3c;"></i>
无效密钥 无效密钥
</span> </span>
<button class="copy-btn" onclick="copyKeys('invalid')"> <button class="copy-btn" onclick="event.stopPropagation(); copyKeys('invalid')">
<i class="fas fa-copy"></i> <i class="fas fa-copy"></i>
批量复制 批量复制
</button> </button>
</h2> </h2>
<ul id="invalidKeys"> <div class="key-content">
<ul id="invalidKeys">
{% for key, fail_count in invalid_keys.items() %} {% for key, fail_count in invalid_keys.items() %}
<li> <li>
<div class="key-info"> <div class="key-info">
@@ -289,7 +455,8 @@
</button> </button>
</li> </li>
{% endfor %} {% endfor %}
</ul> </ul>
</div>
</div> </div>
<div class="total"> <div class="total">
<i class="fas fa-key"></i> 总密钥数:{{ total }} <i class="fas fa-key"></i> 总密钥数:{{ total }}
@@ -370,15 +537,17 @@
} }
function scrollToTop() { function scrollToTop() {
window.scrollTo({ const container = document.querySelector('.container');
container.scrollTo({
top: 0, top: 0,
behavior: 'smooth' behavior: 'smooth'
}); });
} }
function scrollToBottom() { function scrollToBottom() {
window.scrollTo({ const container = document.querySelector('.container');
top: document.documentElement.scrollHeight, container.scrollTo({
top: container.scrollHeight,
behavior: 'smooth' behavior: 'smooth'
}); });
} }
@@ -392,6 +561,70 @@
scrollButtons.style.display = 'none'; scrollButtons.style.display = 'none';
} }
}); });
function refreshPage(button) {
button.classList.add('loading');
button.disabled = true;
// 添加延迟以显示加载动画
setTimeout(() => {
window.location.reload();
}, 300);
}
</script> </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> </body>
</html> </html>