mirror of
https://github.com/CJackHwang/ds2api.git
synced 2026-05-05 00:45:29 +08:00
feat: Implement a new, modern landing page with dynamic styling and feature highlights.
This commit is contained in:
@@ -32,7 +32,7 @@
|
||||
[](https://vercel.com/new/clone?repository-url=https%3A%2F%2Fgithub.com%2FCJackHwang%2Fds2api&env=DS2API_ADMIN_KEY&envDescription=管理面板访问密码(必填)&envLink=https%3A%2F%2Fgithub.com%2FCJackHwang%2Fds2api%23环境变量&project-name=ds2api&repository-name=ds2api)
|
||||
|
||||
1. 点击上方按钮,填写管理密码 `DS2API_ADMIN_KEY`
|
||||
2. 部署完成后访问 `/webui` 管理界面
|
||||
2. 部署完成后访问 `/admin` 管理界面
|
||||
3. 添加 DeepSeek 账号和 API Key
|
||||
4. 点击「同步到 Vercel」保存配置
|
||||
|
||||
@@ -105,7 +105,7 @@ curl https://your-domain.com/v1/chat/completions \
|
||||
|
||||
| 接口 | 说明 |
|
||||
|-----|------|
|
||||
| `GET /webui` | 管理界面 |
|
||||
| `GET /admin` | 管理界面 |
|
||||
| `GET /admin/config` | 获取配置 |
|
||||
| `POST /admin/accounts/test` | 测试单个账号 |
|
||||
| `POST /admin/accounts/test-all` | 批量测试账号 |
|
||||
|
||||
3
app.py
3
app.py
@@ -54,8 +54,9 @@ from routes.admin import router as admin_router
|
||||
|
||||
app.include_router(openai_router)
|
||||
app.include_router(claude_router)
|
||||
app.include_router(home_router)
|
||||
# admin_router 必须在 home_router 之前,否则 home.py 的 /admin/{path:path} 会拦截 admin API
|
||||
app.include_router(admin_router)
|
||||
app.include_router(home_router)
|
||||
|
||||
|
||||
# ----------------------------------------------------------------------
|
||||
|
||||
@@ -15,8 +15,8 @@ from core.config import logger
|
||||
router = APIRouter()
|
||||
security = HTTPBearer(auto_error=False)
|
||||
|
||||
# Admin Key 验证
|
||||
ADMIN_KEY = os.getenv("DS2API_ADMIN_KEY", "")
|
||||
# Admin Key 验证(默认值适用于开发/演示环境,生产环境请务必修改)
|
||||
ADMIN_KEY = os.getenv("DS2API_ADMIN_KEY", "your-admin-secret-key")
|
||||
|
||||
# JWT 配置
|
||||
JWT_SECRET = os.getenv("DS2API_JWT_SECRET", ADMIN_KEY or "ds2api-default-secret")
|
||||
@@ -105,17 +105,6 @@ async def admin_login(request: Request):
|
||||
admin_key = data.get("admin_key", "")
|
||||
expire_hours = data.get("expire_hours", JWT_EXPIRE_HOURS)
|
||||
|
||||
# 开发模式:如果没有配置 ADMIN_KEY,允许任意登录
|
||||
if not ADMIN_KEY:
|
||||
logger.warning("[admin_login] 开发模式:未配置 ADMIN_KEY,允许任意登录")
|
||||
token = create_jwt_token(expire_hours)
|
||||
return JSONResponse(content={
|
||||
"success": True,
|
||||
"token": token,
|
||||
"expires_in": expire_hours * 3600,
|
||||
"warning": "开发模式 - 未配置 ADMIN_KEY"
|
||||
})
|
||||
|
||||
if admin_key != ADMIN_KEY:
|
||||
raise HTTPException(status_code=401, detail="Invalid admin key")
|
||||
|
||||
@@ -147,10 +136,6 @@ async def verify_token(credentials: HTTPAuthorizationCredentials = Depends(secur
|
||||
|
||||
def verify_admin(credentials: HTTPAuthorizationCredentials = Depends(security)):
|
||||
"""验证 Admin 权限(支持 JWT 和直接 admin key)"""
|
||||
# 开发模式:如果没有配置 ADMIN_KEY,允许所有操作
|
||||
if not ADMIN_KEY:
|
||||
return True
|
||||
|
||||
if not credentials:
|
||||
raise HTTPException(status_code=401, detail="Authentication required")
|
||||
|
||||
|
||||
254
routes/home.py
254
routes/home.py
@@ -16,117 +16,255 @@ WELCOME_HTML = """<!DOCTYPE html>
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>DS2API - DeepSeek to OpenAI API</title>
|
||||
<meta name="description" content="DS2API - 将 DeepSeek 网页版转换为 OpenAI 兼容 API">
|
||||
<meta name="robots" content="noindex, nofollow">
|
||||
<link rel="preconnect" href="https://fonts.googleapis.com">
|
||||
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
|
||||
<link href="https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700&family=Orbitron:wght@700&display=swap" rel="stylesheet">
|
||||
<link rel="icon" type="image/svg+xml" href="data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 100 100'%3E%3Cdefs%3E%3ClinearGradient id='g' x1='0%25' y1='0%25' x2='100%25' y2='100%25'%3E%3Cstop offset='0%25' stop-color='%23f59e0b'/%3E%3Cstop offset='100%25' stop-color='%23ef4444'/%3E%3C/linearGradient%3E%3C/defs%3E%3Crect rx='20' width='100' height='100' fill='url(%23g)'/%3E%3Ctext x='50' y='68' font-family='Arial,sans-serif' font-size='48' font-weight='bold' fill='white' text-anchor='middle'%3EDS%3C/text%3E%3C/svg%3E">
|
||||
<style>
|
||||
:root {
|
||||
--primary: #f59e0b;
|
||||
--primary-glow: rgba(245, 158, 11, 0.4);
|
||||
--secondary: #ef4444;
|
||||
--bg: #030712;
|
||||
--card-bg: rgba(255, 255, 255, 0.03);
|
||||
--card-border: rgba(255, 255, 255, 0.08);
|
||||
--text-main: #f9fafb;
|
||||
--text-dim: #9ca3af;
|
||||
}
|
||||
|
||||
* { margin: 0; padding: 0; box-sizing: border-box; }
|
||||
|
||||
body {
|
||||
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
|
||||
background: linear-gradient(135deg, #1e3a5f 0%, #0f172a 100%);
|
||||
font-family: 'Inter', -apple-system, BlinkMacSystemFont, sans-serif;
|
||||
background-color: var(--bg);
|
||||
color: var(--text-main);
|
||||
min-height: 100vh;
|
||||
overflow-x: hidden;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
color: #fff;
|
||||
position: relative;
|
||||
}
|
||||
|
||||
/* Animated Background */
|
||||
.bg-glow {
|
||||
position: fixed;
|
||||
top: 0;
|
||||
left: 0;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
z-index: -1;
|
||||
background:
|
||||
radial-gradient(circle at 20% 30%, rgba(245, 158, 11, 0.05) 0%, transparent 40%),
|
||||
radial-gradient(circle at 80% 70%, rgba(239, 68, 68, 0.05) 0%, transparent 40%);
|
||||
}
|
||||
|
||||
.blob {
|
||||
position: absolute;
|
||||
width: 400px;
|
||||
height: 400px;
|
||||
background: linear-gradient(135deg, var(--primary), var(--secondary));
|
||||
filter: blur(80px);
|
||||
opacity: 0.15;
|
||||
border-radius: 50%;
|
||||
z-index: -1;
|
||||
animation: move 20s infinite alternate;
|
||||
}
|
||||
|
||||
@keyframes move {
|
||||
from { transform: translate(-10%, -10%) scale(1); }
|
||||
to { transform: translate(10%, 10%) scale(1.1); }
|
||||
}
|
||||
|
||||
.container {
|
||||
text-align: center;
|
||||
width: 100%;
|
||||
max-width: 900px;
|
||||
padding: 2rem;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.logo-section {
|
||||
margin-bottom: 3rem;
|
||||
animation: fadeInUp 0.8s ease-out;
|
||||
}
|
||||
|
||||
.logo {
|
||||
font-size: 4rem;
|
||||
font-weight: bold;
|
||||
background: linear-gradient(135deg, #f59e0b, #ef4444);
|
||||
font-family: 'Orbitron', sans-serif;
|
||||
font-size: clamp(3rem, 10vw, 5rem);
|
||||
font-weight: 700;
|
||||
background: linear-gradient(135deg, var(--primary), var(--secondary));
|
||||
-webkit-background-clip: text;
|
||||
-webkit-text-fill-color: transparent;
|
||||
background-clip: text;
|
||||
margin-bottom: 1rem;
|
||||
letter-spacing: -2px;
|
||||
margin-bottom: 0.5rem;
|
||||
display: inline-block;
|
||||
}
|
||||
|
||||
.subtitle {
|
||||
color: #94a3b8;
|
||||
font-size: 1.2rem;
|
||||
margin-bottom: 2rem;
|
||||
color: var(--text-dim);
|
||||
font-size: 1.25rem;
|
||||
max-width: 600px;
|
||||
margin: 0 auto;
|
||||
line-height: 1.6;
|
||||
}
|
||||
.links {
|
||||
|
||||
.actions {
|
||||
display: flex;
|
||||
gap: 1rem;
|
||||
justify-content: center;
|
||||
margin-bottom: 4rem;
|
||||
flex-wrap: wrap;
|
||||
animation: fadeInUp 0.8s ease-out 0.2s backwards;
|
||||
}
|
||||
|
||||
.btn {
|
||||
display: inline-block;
|
||||
padding: 0.75rem 1.5rem;
|
||||
border-radius: 0.5rem;
|
||||
padding: 0.8rem 2rem;
|
||||
border-radius: 12px;
|
||||
text-decoration: none;
|
||||
font-weight: 500;
|
||||
transition: all 0.2s;
|
||||
font-weight: 600;
|
||||
font-size: 1rem;
|
||||
transition: all 0.3s cubic-bezier(0.4, 0, 0.2, 1);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
}
|
||||
|
||||
.btn-primary {
|
||||
background: linear-gradient(135deg, #f59e0b, #ef4444);
|
||||
color: #fff;
|
||||
background: linear-gradient(135deg, var(--primary), var(--secondary));
|
||||
color: white;
|
||||
box-shadow: 0 4px 15px var(--primary-glow);
|
||||
}
|
||||
|
||||
.btn-primary:hover {
|
||||
transform: translateY(-2px);
|
||||
box-shadow: 0 4px 20px rgba(245, 158, 11, 0.4);
|
||||
transform: translateY(-3px) scale(1.02);
|
||||
box-shadow: 0 8px 25px var(--primary-glow);
|
||||
}
|
||||
|
||||
.btn-secondary {
|
||||
border: 1px solid #475569;
|
||||
color: #94a3b8;
|
||||
background: var(--card-bg);
|
||||
color: var(--text-main);
|
||||
border: 1px solid var(--card-border);
|
||||
backdrop-filter: blur(10px);
|
||||
}
|
||||
|
||||
.btn-secondary:hover {
|
||||
border-color: #64748b;
|
||||
color: #fff;
|
||||
background: rgba(255, 255, 255, 0.08);
|
||||
border-color: rgba(255, 255, 255, 0.2);
|
||||
transform: translateY(-2px);
|
||||
}
|
||||
.features {
|
||||
margin-top: 3rem;
|
||||
|
||||
.features-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fit, minmax(200px, 1fr));
|
||||
gap: 1.5rem;
|
||||
max-width: 800px;
|
||||
margin-top: 1rem;
|
||||
animation: fadeInUp 0.8s ease-out 0.4s backwards;
|
||||
}
|
||||
.feature {
|
||||
background: rgba(255,255,255,0.05);
|
||||
|
||||
.feature-card {
|
||||
background: var(--card-bg);
|
||||
border: 1px solid var(--card-border);
|
||||
border-radius: 16px;
|
||||
padding: 1.5rem;
|
||||
border-radius: 0.75rem;
|
||||
border: 1px solid rgba(255,255,255,0.1);
|
||||
text-align: left;
|
||||
transition: all 0.3s ease;
|
||||
backdrop-filter: blur(8px);
|
||||
}
|
||||
.feature h3 {
|
||||
font-size: 1rem;
|
||||
|
||||
.feature-card:hover {
|
||||
border-color: rgba(245, 158, 11, 0.3);
|
||||
background: rgba(255, 255, 255, 0.05);
|
||||
transform: translateY(-5px);
|
||||
}
|
||||
|
||||
.feature-icon {
|
||||
font-size: 1.5rem;
|
||||
margin-bottom: 1rem;
|
||||
display: block;
|
||||
}
|
||||
|
||||
.feature-card h3 {
|
||||
font-size: 1.1rem;
|
||||
margin-bottom: 0.5rem;
|
||||
font-weight: 600;
|
||||
}
|
||||
.feature p {
|
||||
color: #94a3b8;
|
||||
|
||||
.feature-card p {
|
||||
color: var(--text-dim);
|
||||
font-size: 0.9rem;
|
||||
line-height: 1.5;
|
||||
}
|
||||
|
||||
footer {
|
||||
margin-top: 4rem;
|
||||
padding: 2rem;
|
||||
color: var(--text-dim);
|
||||
font-size: 0.875rem;
|
||||
animation: fadeInUp 0.8s ease-out 0.6s backwards;
|
||||
}
|
||||
|
||||
@keyframes fadeInUp {
|
||||
from { opacity: 0; transform: translateY(20px); }
|
||||
to { opacity: 1; transform: translateY(0); }
|
||||
}
|
||||
|
||||
@media (max-width: 640px) {
|
||||
.logo { font-size: 3.5rem; }
|
||||
.container { padding: 1.5rem; }
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div class="bg-glow"></div>
|
||||
<div class="blob" style="top: 10%; left: 15%;"></div>
|
||||
<div class="blob" style="bottom: 10%; right: 15%; animation-delay: -5s;"></div>
|
||||
|
||||
<div class="container">
|
||||
<div class="logo">DS2API</div>
|
||||
<p class="subtitle">DeepSeek to OpenAI Compatible API</p>
|
||||
<div class="links">
|
||||
<a href="/webui" class="btn btn-primary">🎛️ 管理面板</a>
|
||||
<a href="/v1/models" class="btn btn-secondary">📡 API 端点</a>
|
||||
<a href="https://github.com/CJackHwang/ds2api" class="btn btn-secondary" target="_blank">📦 GitHub</a>
|
||||
<header class="logo-section">
|
||||
<div class="logo">DS2API</div>
|
||||
<p class="subtitle">DeepSeek to OpenAI & Claude Compatible API Interface</p>
|
||||
</header>
|
||||
|
||||
<div class="actions">
|
||||
<a href="/admin" class="btn btn-primary">
|
||||
<span>🎛️</span> 管理面板
|
||||
</a>
|
||||
<a href="/v1/models" class="btn btn-secondary">
|
||||
<span>📡</span> API 状态
|
||||
</a>
|
||||
<a href="https://github.com/CJackHwang/ds2api" class="btn btn-secondary" target="_blank">
|
||||
<span>📦</span> GitHub
|
||||
</a>
|
||||
</div>
|
||||
<div class="features">
|
||||
<div class="feature">
|
||||
<h3>🚀 OpenAI 兼容</h3>
|
||||
<p>完全兼容 OpenAI API 格式</p>
|
||||
|
||||
<div class="features-grid">
|
||||
<div class="feature-card">
|
||||
<span class="feature-icon">🚀</span>
|
||||
<h3>全面兼容</h3>
|
||||
<p>完美适配 OpenAI 与 Claude API 格式,无缝集成现有工具。</p>
|
||||
</div>
|
||||
<div class="feature">
|
||||
<h3>🔄 多账号轮询</h3>
|
||||
<p>Round-Robin 负载均衡</p>
|
||||
<div class="feature-card">
|
||||
<span class="feature-icon">⚖️</span>
|
||||
<h3>负载均衡</h3>
|
||||
<p>内置智能轮询机制,支持多账号并发,稳定高效。</p>
|
||||
</div>
|
||||
<div class="feature">
|
||||
<h3>🧠 深度思考</h3>
|
||||
<p>支持 R1 推理模式</p>
|
||||
<div class="feature-card">
|
||||
<span class="feature-icon">🧠</span>
|
||||
<h3>深度思考</h3>
|
||||
<p>完整支持 DeepSeek-R1 推理过程输出,让思考可见。</p>
|
||||
</div>
|
||||
<div class="feature">
|
||||
<h3>🔍 联网搜索</h3>
|
||||
<p>DeepSeek 搜索增强</p>
|
||||
<div class="feature-card">
|
||||
<span class="feature-icon">🔍</span>
|
||||
<h3>联网搜索</h3>
|
||||
<p>集成 DeepSeek 原生搜索能力,获取最新实时资讯。</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<footer>
|
||||
<p>© 2024 DS2API Project. Designed for flexibility & performance.</p>
|
||||
</footer>
|
||||
</div>
|
||||
</body>
|
||||
</html>"""
|
||||
@@ -137,8 +275,8 @@ def index(request: Request):
|
||||
return HTMLResponse(content=WELCOME_HTML)
|
||||
|
||||
|
||||
@router.get("/webui")
|
||||
@router.get("/webui/{path:path}")
|
||||
@router.get("/admin")
|
||||
@router.get("/admin/{path:path}")
|
||||
async def webui(request: Request, path: str = ""):
|
||||
"""提供 WebUI 静态文件"""
|
||||
# 检查 static/admin 目录是否存在
|
||||
|
||||
@@ -13,6 +13,7 @@
|
||||
"lucide-react": "^0.563.0",
|
||||
"react": "^18.2.0",
|
||||
"react-dom": "^18.2.0",
|
||||
"react-router-dom": "^7.13.0",
|
||||
"tailwind-merge": "^3.4.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
|
||||
@@ -1,4 +1,11 @@
|
||||
import { useState, useEffect } from 'react'
|
||||
import {
|
||||
Routes,
|
||||
Route,
|
||||
Navigate,
|
||||
useNavigate,
|
||||
useLocation
|
||||
} from 'react-router-dom'
|
||||
import {
|
||||
LayoutDashboard,
|
||||
Key,
|
||||
@@ -17,6 +24,7 @@ import ApiTester from './components/ApiTester'
|
||||
import BatchImport from './components/BatchImport'
|
||||
import VercelSync from './components/VercelSync'
|
||||
import Login from './components/Login'
|
||||
import LandingPage from './components/LandingPage'
|
||||
|
||||
const NAV_ITEMS = [
|
||||
{ id: 'accounts', label: '账号管理', icon: Users, description: '管理 DeepSeek 账号池' },
|
||||
@@ -25,39 +33,10 @@ const NAV_ITEMS = [
|
||||
{ id: 'vercel', label: 'Vercel 同步', icon: Cloud, description: '同步配置到 Vercel' },
|
||||
]
|
||||
|
||||
export default function App() {
|
||||
function Dashboard({ token, onLogout, config, fetchConfig, showMessage, message }) {
|
||||
const [activeTab, setActiveTab] = useState('accounts')
|
||||
const [config, setConfig] = useState({ keys: [], accounts: [] })
|
||||
const [loading, setLoading] = useState(true)
|
||||
const [message, setMessage] = useState(null)
|
||||
const [token, setToken] = useState(null)
|
||||
const [authChecking, setAuthChecking] = useState(true)
|
||||
const [sidebarOpen, setSidebarOpen] = useState(false)
|
||||
|
||||
// 检查已存储的 Token
|
||||
useEffect(() => {
|
||||
const checkAuth = async () => {
|
||||
const storedToken = localStorage.getItem('ds2api_token') || sessionStorage.getItem('ds2api_token')
|
||||
const expiresAt = parseInt(localStorage.getItem('ds2api_token_expires') || sessionStorage.getItem('ds2api_token_expires') || '0')
|
||||
|
||||
if (storedToken && expiresAt > Date.now()) {
|
||||
try {
|
||||
const res = await fetch('/admin/verify', {
|
||||
headers: { 'Authorization': `Bearer ${storedToken}` }
|
||||
})
|
||||
if (res.ok) {
|
||||
setToken(storedToken)
|
||||
} else {
|
||||
handleLogout()
|
||||
}
|
||||
} catch {
|
||||
setToken(storedToken)
|
||||
}
|
||||
}
|
||||
setAuthChecking(false)
|
||||
}
|
||||
checkAuth()
|
||||
}, [])
|
||||
const [loading, setLoading] = useState(false)
|
||||
|
||||
const authFetch = async (url, options = {}) => {
|
||||
const headers = {
|
||||
@@ -67,52 +46,12 @@ export default function App() {
|
||||
const res = await fetch(url, { ...options, headers })
|
||||
|
||||
if (res.status === 401) {
|
||||
handleLogout()
|
||||
onLogout()
|
||||
throw new Error('认证已过期,请重新登录')
|
||||
}
|
||||
return res
|
||||
}
|
||||
|
||||
const fetchConfig = async () => {
|
||||
if (!token) return
|
||||
try {
|
||||
setLoading(true)
|
||||
const res = await authFetch('/admin/config')
|
||||
if (res.ok) {
|
||||
const data = await res.json()
|
||||
setConfig(data)
|
||||
}
|
||||
} catch (e) {
|
||||
console.error('获取配置失败:', e)
|
||||
showMessage('error', e.message)
|
||||
} finally {
|
||||
setLoading(false)
|
||||
}
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
if (token) {
|
||||
fetchConfig()
|
||||
}
|
||||
}, [token])
|
||||
|
||||
const showMessage = (type, text) => {
|
||||
setMessage({ type, text })
|
||||
setTimeout(() => setMessage(null), 5000)
|
||||
}
|
||||
|
||||
const handleLogin = (newToken) => {
|
||||
setToken(newToken)
|
||||
}
|
||||
|
||||
const handleLogout = () => {
|
||||
setToken(null)
|
||||
localStorage.removeItem('ds2api_token')
|
||||
localStorage.removeItem('ds2api_token_expires')
|
||||
sessionStorage.removeItem('ds2api_token')
|
||||
sessionStorage.removeItem('ds2api_token_expires')
|
||||
}
|
||||
|
||||
const renderTab = () => {
|
||||
switch (activeTab) {
|
||||
case 'accounts':
|
||||
@@ -128,43 +67,8 @@ export default function App() {
|
||||
}
|
||||
}
|
||||
|
||||
if (authChecking) {
|
||||
return (
|
||||
<div className="min-h-screen flex items-center justify-center bg-background">
|
||||
<div className="flex flex-col items-center gap-4">
|
||||
<div className="w-8 h-8 border-4 border-primary border-t-transparent rounded-full animate-spin"></div>
|
||||
<p className="text-muted-foreground animate-pulse">正在检查登录状态...</p>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
if (!token) {
|
||||
return (
|
||||
<div className="min-h-screen flex flex-col bg-background relative overflow-hidden">
|
||||
{/* Background decorative elements */}
|
||||
<div className="absolute top-0 left-0 w-full h-full overflow-hidden pointer-events-none z-0">
|
||||
<div className="absolute top-[-10%] right-[-10%] w-[50%] h-[50%] bg-primary/5 rounded-full blur-[120px]"></div>
|
||||
<div className="absolute bottom-[-10%] left-[-10%] w-[50%] h-[50%] bg-accent/5 rounded-full blur-[120px]"></div>
|
||||
</div>
|
||||
|
||||
{message && (
|
||||
<div className={clsx(
|
||||
"fixed top-4 right-4 z-50 px-4 py-3 rounded-lg shadow-lg border animate-in slide-in-from-top-2 fade-in",
|
||||
message.type === 'error' ? "bg-destructive/10 border-destructive/20 text-destructive" :
|
||||
"bg-primary/10 border-primary/20 text-primary"
|
||||
)}>
|
||||
{message.text}
|
||||
</div>
|
||||
)}
|
||||
<Login onLogin={handleLogin} onMessage={showMessage} />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex h-screen bg-background overflow-hidden text-foreground">
|
||||
{/* Mobile Sidebar Overlay */}
|
||||
{sidebarOpen && (
|
||||
<div
|
||||
className="fixed inset-0 bg-background/80 backdrop-blur-sm z-40 lg:hidden"
|
||||
@@ -172,7 +76,6 @@ export default function App() {
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* Sidebar */}
|
||||
<aside className={clsx(
|
||||
"fixed lg:static inset-y-0 left-0 z-50 w-64 bg-card border-r border-border transition-transform duration-300 ease-in-out lg:transform-none flex flex-col shadow-2xl lg:shadow-none",
|
||||
sidebarOpen ? "translate-x-0" : "-translate-x-full"
|
||||
@@ -233,7 +136,7 @@ export default function App() {
|
||||
</div>
|
||||
</div>
|
||||
<button
|
||||
onClick={handleLogout}
|
||||
onClick={onLogout}
|
||||
className="w-full h-10 flex items-center justify-center gap-2 rounded-lg border border-border text-xs font-medium text-muted-foreground hover:bg-destructive/10 hover:text-destructive hover:border-destructive/20 transition-all"
|
||||
>
|
||||
<LogOut className="w-3.5 h-3.5" />
|
||||
@@ -243,9 +146,7 @@ export default function App() {
|
||||
</div>
|
||||
</aside>
|
||||
|
||||
{/* Main Content */}
|
||||
<main className="flex-1 flex flex-col min-w-0 overflow-hidden relative">
|
||||
{/* Mobile Header */}
|
||||
<header className="lg:hidden h-14 flex items-center justify-between px-4 border-b border-border bg-card">
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="w-6 h-6 rounded bg-primary flex items-center justify-center text-primary-foreground text-[10px]">
|
||||
@@ -261,7 +162,6 @@ export default function App() {
|
||||
</button>
|
||||
</header>
|
||||
|
||||
{/* Content Area */}
|
||||
<div className="flex-1 overflow-auto bg-background p-4 lg:p-10">
|
||||
<div className="max-w-6xl mx-auto space-y-4 lg:space-y-6">
|
||||
<div className="hidden lg:block mb-8">
|
||||
@@ -285,14 +185,7 @@ export default function App() {
|
||||
)}
|
||||
|
||||
<div className="animate-in fade-in duration-500">
|
||||
{loading ? (
|
||||
<div className="flex flex-col items-center justify-center py-20 text-muted-foreground">
|
||||
<div className="w-8 h-8 border-4 border-primary border-t-transparent rounded-full animate-spin mb-4"></div>
|
||||
<p>正在加载数据,请稍候...</p>
|
||||
</div>
|
||||
) : (
|
||||
renderTab()
|
||||
)}
|
||||
{renderTab()}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -300,3 +193,139 @@ export default function App() {
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default function App() {
|
||||
const navigate = useNavigate()
|
||||
const location = useLocation()
|
||||
const [config, setConfig] = useState({ keys: [], accounts: [] })
|
||||
const [loading, setLoading] = useState(true)
|
||||
const [message, setMessage] = useState(null)
|
||||
const [token, setToken] = useState(null)
|
||||
const [authChecking, setAuthChecking] = useState(true)
|
||||
|
||||
const isProduction = import.meta.env.MODE === 'production'
|
||||
const isAdminRoute = location.pathname.startsWith('/admin') || isProduction
|
||||
|
||||
useEffect(() => {
|
||||
// 只在 admin 路由时检查登录状态
|
||||
if (!isAdminRoute) {
|
||||
setAuthChecking(false)
|
||||
return
|
||||
}
|
||||
|
||||
const checkAuth = async () => {
|
||||
const storedToken = localStorage.getItem('ds2api_token') || sessionStorage.getItem('ds2api_token')
|
||||
const expiresAt = parseInt(localStorage.getItem('ds2api_token_expires') || sessionStorage.getItem('ds2api_token_expires') || '0')
|
||||
|
||||
if (storedToken && expiresAt > Date.now()) {
|
||||
try {
|
||||
const res = await fetch('/admin/verify', {
|
||||
headers: { 'Authorization': `Bearer ${storedToken}` }
|
||||
})
|
||||
if (res.ok) {
|
||||
setToken(storedToken)
|
||||
} else {
|
||||
handleLogout()
|
||||
}
|
||||
} catch {
|
||||
setToken(storedToken)
|
||||
}
|
||||
}
|
||||
setAuthChecking(false)
|
||||
}
|
||||
checkAuth()
|
||||
}, [isAdminRoute])
|
||||
|
||||
const fetchConfig = async () => {
|
||||
if (!token) return
|
||||
try {
|
||||
setLoading(true)
|
||||
const res = await fetch('/admin/config', {
|
||||
headers: { 'Authorization': `Bearer ${token}` }
|
||||
})
|
||||
if (res.ok) {
|
||||
const data = await res.json()
|
||||
setConfig(data)
|
||||
}
|
||||
} catch (e) {
|
||||
console.error('获取配置失败:', e)
|
||||
showMessage('error', e.message)
|
||||
} finally {
|
||||
setLoading(false)
|
||||
}
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
if (token) {
|
||||
fetchConfig()
|
||||
}
|
||||
}, [token])
|
||||
|
||||
const showMessage = (type, text) => {
|
||||
setMessage({ type, text })
|
||||
setTimeout(() => setMessage(null), 5000)
|
||||
}
|
||||
|
||||
const handleLogin = (newToken) => {
|
||||
setToken(newToken)
|
||||
}
|
||||
|
||||
const handleLogout = () => {
|
||||
setToken(null)
|
||||
localStorage.removeItem('ds2api_token')
|
||||
localStorage.removeItem('ds2api_token_expires')
|
||||
sessionStorage.removeItem('ds2api_token')
|
||||
sessionStorage.removeItem('ds2api_token_expires')
|
||||
}
|
||||
|
||||
// 在 admin 路由时,等待认证检查完成
|
||||
if (isAdminRoute && authChecking) {
|
||||
return (
|
||||
<div className="min-h-screen flex items-center justify-center bg-background">
|
||||
<div className="flex flex-col items-center gap-4">
|
||||
<div className="w-8 h-8 border-4 border-primary border-t-transparent rounded-full animate-spin"></div>
|
||||
<p className="text-muted-foreground animate-pulse">正在检查登录状态...</p>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<Routes>
|
||||
{!isProduction && (
|
||||
<Route path="/" element={<LandingPage onEnter={() => navigate('/admin')} />} />
|
||||
)}
|
||||
<Route path={isProduction ? "/" : "/admin"} element={
|
||||
token ? (
|
||||
<Dashboard
|
||||
token={token}
|
||||
onLogout={handleLogout}
|
||||
config={config}
|
||||
fetchConfig={fetchConfig}
|
||||
showMessage={showMessage}
|
||||
message={message}
|
||||
/>
|
||||
) : (
|
||||
<div className="min-h-screen flex flex-col bg-background relative overflow-hidden">
|
||||
<div className="absolute top-0 left-0 w-full h-full overflow-hidden pointer-events-none z-0">
|
||||
<div className="absolute top-[-10%] right-[-10%] w-[50%] h-[50%] bg-primary/5 rounded-full blur-[120px]"></div>
|
||||
<div className="absolute bottom-[-10%] left-[-10%] w-[50%] h-[50%] bg-accent/5 rounded-full blur-[120px]"></div>
|
||||
</div>
|
||||
|
||||
{message && (
|
||||
<div className={clsx(
|
||||
"fixed top-4 right-4 z-50 px-4 py-3 rounded-lg shadow-lg border animate-in slide-in-from-top-2 fade-in",
|
||||
message.type === 'error' ? "bg-destructive/10 border-destructive/20 text-destructive" :
|
||||
"bg-primary/10 border-primary/20 text-primary"
|
||||
)}>
|
||||
{message.text}
|
||||
</div>
|
||||
)}
|
||||
<Login onLogin={handleLogin} onMessage={showMessage} />
|
||||
</div>
|
||||
)
|
||||
} />
|
||||
<Route path="*" element={<Navigate to="/" replace />} />
|
||||
</Routes>
|
||||
)
|
||||
}
|
||||
|
||||
141
webui/src/components/LandingPage.jsx
Normal file
141
webui/src/components/LandingPage.jsx
Normal file
@@ -0,0 +1,141 @@
|
||||
import React from 'react'
|
||||
|
||||
const LandingPage = ({ onEnter }) => {
|
||||
return (
|
||||
<div className="landing-container min-h-screen relative overflow-hidden flex flex-col items-center justify-center p-6 text-center">
|
||||
{/* Animated Background Elements - using Tailwind with some custom CSS in styles.css if needed,
|
||||
but for simplicity I will use inline styles to match the backend version precisely */}
|
||||
<style dangerouslySetInnerHTML={{
|
||||
__html: `
|
||||
.landing-container {
|
||||
background-color: #030712;
|
||||
color: #f9fafb;
|
||||
font-family: 'Inter', sans-serif;
|
||||
}
|
||||
.bg-glow {
|
||||
position: fixed;
|
||||
top: 0;
|
||||
left: 0;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
z-index: 0;
|
||||
background:
|
||||
radial-gradient(circle at 20% 30%, rgba(245, 158, 11, 0.05) 0%, transparent 40%),
|
||||
radial-gradient(circle at 80% 70%, rgba(239, 68, 68, 0.05) 0%, transparent 40%);
|
||||
}
|
||||
.blob {
|
||||
position: absolute;
|
||||
width: 400px;
|
||||
height: 400px;
|
||||
background: linear-gradient(135deg, #f59e0b, #ef4444);
|
||||
filter: blur(80px);
|
||||
opacity: 0.15;
|
||||
border-radius: 50%;
|
||||
z-index: 0;
|
||||
animation: move 20s infinite alternate;
|
||||
}
|
||||
@keyframes move {
|
||||
from { transform: translate(-10%, -10%) scale(1); }
|
||||
to { transform: translate(10%, 10%) scale(1.1); }
|
||||
}
|
||||
.landing-content {
|
||||
position: relative;
|
||||
z-index: 10;
|
||||
max-width: 900px;
|
||||
animation: fadeInUp 0.8s ease-out;
|
||||
}
|
||||
@keyframes fadeInUp {
|
||||
from { opacity: 0; transform: translateY(20px); }
|
||||
to { opacity: 1; transform: translateY(0); }
|
||||
}
|
||||
.logo-text {
|
||||
font-family: 'Orbitron', sans-serif;
|
||||
font-size: clamp(3rem, 10vw, 5rem);
|
||||
font-weight: 700;
|
||||
background: linear-gradient(135deg, #f59e0b, #ef4444);
|
||||
-webkit-background-clip: text;
|
||||
-webkit-text-fill-color: transparent;
|
||||
background-clip: text;
|
||||
letter-spacing: -2px;
|
||||
margin-bottom: 0.5rem;
|
||||
}
|
||||
.btn-premium {
|
||||
background: linear-gradient(135deg, #f59e0b, #ef4444);
|
||||
box-shadow: 0 4px 15px rgba(245, 158, 11, 0.4);
|
||||
}
|
||||
.btn-premium:hover {
|
||||
box-shadow: 0 8px 25px rgba(245, 158, 11, 0.6);
|
||||
transform: translateY(-3px) scale(1.02);
|
||||
}
|
||||
.glass-card {
|
||||
background: rgba(255, 255, 255, 0.03);
|
||||
border: 1px solid rgba(255, 255, 255, 0.08);
|
||||
backdrop-filter: blur(10px);
|
||||
transition: all 0.3s ease;
|
||||
}
|
||||
.glass-card:hover {
|
||||
border-color: rgba(245, 158, 11, 0.3);
|
||||
background: rgba(255, 255, 255, 0.05);
|
||||
transform: translateY(-5px);
|
||||
}
|
||||
`}} />
|
||||
|
||||
<div className="bg-glow" />
|
||||
<div className="blob" style={{ top: '10%', left: '15%' }} />
|
||||
<div className="blob" style={{ bottom: '10%', right: '15%', animationDelay: '-5s' }} />
|
||||
|
||||
<div className="landing-content">
|
||||
<header className="mb-12">
|
||||
<h1 className="logo-text">DS2API</h1>
|
||||
<p className="text-gray-400 text-xl max-w-2xl mx-auto leading-relaxed">
|
||||
DeepSeek to OpenAI & Claude Compatible API Interface
|
||||
</p>
|
||||
</header>
|
||||
|
||||
<div className="flex flex-wrap gap-4 justify-center mb-16">
|
||||
<button
|
||||
onClick={onEnter}
|
||||
className="btn-premium text-white px-8 py-3 rounded-xl font-bold transition-all flex items-center gap-2"
|
||||
>
|
||||
<span>🎛️</span> 管理面板
|
||||
</button>
|
||||
<a
|
||||
href="/v1/models"
|
||||
target="_blank"
|
||||
className="glass-card text-white px-8 py-3 rounded-xl font-semibold transition-all flex items-center gap-2"
|
||||
>
|
||||
<span>📡</span> API 状态
|
||||
</a>
|
||||
<a
|
||||
href="https://github.com/CJackHwang/ds2api"
|
||||
target="_blank"
|
||||
className="glass-card text-white px-8 py-3 rounded-xl font-semibold transition-all flex items-center gap-2"
|
||||
>
|
||||
<span>📦</span> GitHub
|
||||
</a>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-1 sm:grid-cols-2 md:grid-cols-4 gap-6 text-left">
|
||||
{[
|
||||
{ icon: '🚀', title: '全面兼容', desc: '适配 OpenAI 与 Claude 格式' },
|
||||
{ icon: '⚖️', title: '负载均衡', desc: '智能轮询,稳定高效' },
|
||||
{ icon: '🧠', title: '深度思考', desc: '支持推理过程输出' },
|
||||
{ icon: '🔍', title: '联网搜索', desc: '集成原生网页搜索能力' },
|
||||
].map((feature, idx) => (
|
||||
<div key={idx} className="glass-card p-6 rounded-2xl">
|
||||
<span className="text-2xl mb-4 block">{feature.icon}</span>
|
||||
<h3 className="text-lg font-bold mb-2">{feature.title}</h3>
|
||||
<p className="text-sm text-gray-400 leading-relaxed">{feature.desc}</p>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<footer className="mt-20 opacity-40 text-sm">
|
||||
<p>© 2026 DS2API Project. Designed for flexibility & performance.</p>
|
||||
</footer>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default LandingPage
|
||||
@@ -1,10 +1,15 @@
|
||||
import React from 'react'
|
||||
import ReactDOM from 'react-dom/client'
|
||||
import { BrowserRouter } from 'react-router-dom'
|
||||
import App from './App.jsx'
|
||||
import './styles.css'
|
||||
|
||||
const basename = import.meta.env.MODE === 'production' ? '/admin' : '/'
|
||||
|
||||
ReactDOM.createRoot(document.getElementById('root')).render(
|
||||
<React.StrictMode>
|
||||
<App />
|
||||
<BrowserRouter basename={basename}>
|
||||
<App />
|
||||
</BrowserRouter>
|
||||
</React.StrictMode>,
|
||||
)
|
||||
|
||||
@@ -1,16 +1,29 @@
|
||||
import { defineConfig } from 'vite'
|
||||
import react from '@vitejs/plugin-react'
|
||||
|
||||
export default defineConfig({
|
||||
export default defineConfig(({ mode }) => ({
|
||||
plugins: [
|
||||
react(),
|
||||
],
|
||||
server: {
|
||||
port: 5173,
|
||||
proxy: {
|
||||
// 代理 /admin 下的 API 请求到后端
|
||||
'/admin': {
|
||||
target: 'http://localhost:5001',
|
||||
changeOrigin: true,
|
||||
// 只代理 API 请求,页面请求返回 false 让 Vite 处理
|
||||
bypass(req, res, proxyOptions) {
|
||||
const url = req.url
|
||||
// 精确的 /admin 或 /admin/ 是页面请求,不代理
|
||||
if (url === '/admin' || url === '/admin/' || url === '/admin?') {
|
||||
console.log('[Vite Proxy] Bypass (page):', url)
|
||||
return '/index.html'
|
||||
}
|
||||
// 其他 /admin/* 路径都是 API 请求,代理到后端
|
||||
console.log('[Vite Proxy] Proxy to backend:', url)
|
||||
// 返回 undefined 或 null 表示不跳过代理
|
||||
},
|
||||
},
|
||||
'/v1': {
|
||||
target: 'http://localhost:5001',
|
||||
@@ -22,5 +35,6 @@ export default defineConfig({
|
||||
outDir: '../static/admin',
|
||||
emptyOutDir: true,
|
||||
},
|
||||
base: '/webui/',
|
||||
})
|
||||
// Use / for dev, /admin/ for production build
|
||||
base: mode === 'production' ? '/admin/' : '/',
|
||||
}))
|
||||
|
||||
Reference in New Issue
Block a user