mirror of
https://github.com/CJackHwang/ds2api.git
synced 2026-05-05 08:55:28 +08:00
feat: Implement user authentication for the admin web UI, including login, session management, and securing API calls.
This commit is contained in:
@@ -48,10 +48,12 @@ def get_account_identifier(account: dict) -> str:
|
||||
def get_queue_status() -> dict:
|
||||
"""获取账号队列状态(用于监控)"""
|
||||
with _queue_lock:
|
||||
# total 应该是配置中的账号总数,而非队列相加(避免状态不一致导致重复计数)
|
||||
total_accounts = len(CONFIG.get("accounts", []))
|
||||
return {
|
||||
"available": len(account_queue),
|
||||
"in_use": len(in_use_accounts),
|
||||
"total": len(account_queue) + len(in_use_accounts),
|
||||
"total": total_accounts,
|
||||
"available_accounts": [get_account_identifier(a) for a in account_queue],
|
||||
"in_use_accounts": list(in_use_accounts.keys()),
|
||||
}
|
||||
|
||||
162
routes/admin.py
162
routes/admin.py
@@ -5,6 +5,9 @@ import json
|
||||
import os
|
||||
import httpx
|
||||
import asyncio
|
||||
import time
|
||||
import hashlib
|
||||
import hmac
|
||||
|
||||
from fastapi import APIRouter, HTTPException, Request, Depends
|
||||
from fastapi.responses import JSONResponse
|
||||
@@ -20,20 +23,171 @@ security = HTTPBearer(auto_error=False)
|
||||
# Admin Key 验证
|
||||
ADMIN_KEY = os.getenv("DS2API_ADMIN_KEY", "")
|
||||
|
||||
# JWT 配置
|
||||
JWT_SECRET = os.getenv("DS2API_JWT_SECRET", ADMIN_KEY or "ds2api-default-secret")
|
||||
JWT_EXPIRE_HOURS = int(os.getenv("DS2API_JWT_EXPIRE_HOURS", "24"))
|
||||
|
||||
# Vercel 预配置(可通过环境变量设置)
|
||||
VERCEL_TOKEN = os.getenv("VERCEL_TOKEN", "")
|
||||
VERCEL_PROJECT_ID = os.getenv("VERCEL_PROJECT_ID", "")
|
||||
VERCEL_TEAM_ID = os.getenv("VERCEL_TEAM_ID", "")
|
||||
|
||||
|
||||
# ----------------------------------------------------------------------
|
||||
# JWT 工具函数(轻量实现,无需额外依赖)
|
||||
# ----------------------------------------------------------------------
|
||||
def _b64_encode(data: bytes) -> str:
|
||||
"""Base64 URL 安全编码"""
|
||||
return base64.urlsafe_b64encode(data).rstrip(b"=").decode("ascii")
|
||||
|
||||
def _b64_decode(data: str) -> bytes:
|
||||
"""Base64 URL 安全解码"""
|
||||
padding = 4 - len(data) % 4
|
||||
if padding != 4:
|
||||
data += "=" * padding
|
||||
return base64.urlsafe_b64decode(data)
|
||||
|
||||
def create_jwt_token(expire_hours: int = None) -> str:
|
||||
"""创建 JWT Token"""
|
||||
if expire_hours is None:
|
||||
expire_hours = JWT_EXPIRE_HOURS
|
||||
|
||||
now = int(time.time())
|
||||
payload = {
|
||||
"iat": now,
|
||||
"exp": now + expire_hours * 3600,
|
||||
"type": "admin"
|
||||
}
|
||||
|
||||
header = {"alg": "HS256", "typ": "JWT"}
|
||||
header_b64 = _b64_encode(json.dumps(header).encode())
|
||||
payload_b64 = _b64_encode(json.dumps(payload).encode())
|
||||
|
||||
signature = hmac.new(
|
||||
JWT_SECRET.encode(),
|
||||
f"{header_b64}.{payload_b64}".encode(),
|
||||
hashlib.sha256
|
||||
).digest()
|
||||
signature_b64 = _b64_encode(signature)
|
||||
|
||||
return f"{header_b64}.{payload_b64}.{signature_b64}"
|
||||
|
||||
def verify_jwt_token(token: str) -> dict:
|
||||
"""验证 JWT Token,返回 payload 或抛出异常"""
|
||||
try:
|
||||
parts = token.split(".")
|
||||
if len(parts) != 3:
|
||||
raise ValueError("Invalid token format")
|
||||
|
||||
header_b64, payload_b64, signature_b64 = parts
|
||||
|
||||
# 验证签名
|
||||
expected_sig = hmac.new(
|
||||
JWT_SECRET.encode(),
|
||||
f"{header_b64}.{payload_b64}".encode(),
|
||||
hashlib.sha256
|
||||
).digest()
|
||||
|
||||
actual_sig = _b64_decode(signature_b64)
|
||||
if not hmac.compare_digest(expected_sig, actual_sig):
|
||||
raise ValueError("Invalid signature")
|
||||
|
||||
# 解析 payload
|
||||
payload = json.loads(_b64_decode(payload_b64))
|
||||
|
||||
# 检查过期
|
||||
if payload.get("exp", 0) < time.time():
|
||||
raise ValueError("Token expired")
|
||||
|
||||
return payload
|
||||
except Exception as e:
|
||||
raise ValueError(f"Token verification failed: {e}")
|
||||
|
||||
|
||||
# ----------------------------------------------------------------------
|
||||
# 登录端点
|
||||
# ----------------------------------------------------------------------
|
||||
@router.post("/login")
|
||||
async def admin_login(request: Request):
|
||||
"""管理员登录,返回 JWT Token"""
|
||||
try:
|
||||
data = await request.json()
|
||||
except:
|
||||
data = {}
|
||||
|
||||
admin_key = data.get("admin_key", "")
|
||||
|
||||
# 开发模式:未配置 ADMIN_KEY 时允许任意登录
|
||||
if not ADMIN_KEY:
|
||||
token = create_jwt_token()
|
||||
return JSONResponse(content={
|
||||
"success": True,
|
||||
"token": token,
|
||||
"expires_in": JWT_EXPIRE_HOURS * 3600,
|
||||
"message": "开发模式:未配置 ADMIN_KEY"
|
||||
})
|
||||
|
||||
# 验证 admin key
|
||||
if admin_key != ADMIN_KEY:
|
||||
raise HTTPException(status_code=401, detail="管理密钥错误")
|
||||
|
||||
token = create_jwt_token()
|
||||
return JSONResponse(content={
|
||||
"success": True,
|
||||
"token": token,
|
||||
"expires_in": JWT_EXPIRE_HOURS * 3600,
|
||||
})
|
||||
|
||||
|
||||
@router.get("/verify")
|
||||
async def verify_token(credentials: HTTPAuthorizationCredentials = Depends(security)):
|
||||
"""验证当前 Token 是否有效"""
|
||||
if not credentials:
|
||||
raise HTTPException(status_code=401, detail="未提供认证信息")
|
||||
|
||||
token = credentials.credentials
|
||||
|
||||
# 先尝试 JWT 验证
|
||||
try:
|
||||
payload = verify_jwt_token(token)
|
||||
return JSONResponse(content={
|
||||
"valid": True,
|
||||
"expires_at": payload.get("exp"),
|
||||
"remaining": payload.get("exp", 0) - int(time.time())
|
||||
})
|
||||
except:
|
||||
pass
|
||||
|
||||
# 回退到直接 admin key 验证(兼容旧方式)
|
||||
if ADMIN_KEY and token == ADMIN_KEY:
|
||||
return JSONResponse(content={"valid": True, "type": "admin_key"})
|
||||
|
||||
raise HTTPException(status_code=401, detail="Token 无效或已过期")
|
||||
|
||||
|
||||
def verify_admin(credentials: HTTPAuthorizationCredentials = Depends(security)):
|
||||
"""验证 Admin 权限"""
|
||||
"""验证 Admin 权限(支持 JWT 和直接 admin key)"""
|
||||
if not ADMIN_KEY:
|
||||
# 未配置 Admin Key,允许访问(开发模式)
|
||||
return True
|
||||
if not credentials or credentials.credentials != ADMIN_KEY:
|
||||
raise HTTPException(status_code=401, detail="Invalid admin key")
|
||||
return True
|
||||
|
||||
if not credentials:
|
||||
raise HTTPException(status_code=401, detail="未提供认证信息")
|
||||
|
||||
token = credentials.credentials
|
||||
|
||||
# 先尝试 JWT 验证
|
||||
try:
|
||||
verify_jwt_token(token)
|
||||
return True
|
||||
except:
|
||||
pass
|
||||
|
||||
# 回退到直接 admin key 验证
|
||||
if token == ADMIN_KEY:
|
||||
return True
|
||||
|
||||
raise HTTPException(status_code=401, detail="认证失败:Token 无效或已过期")
|
||||
|
||||
|
||||
# ----------------------------------------------------------------------
|
||||
|
||||
@@ -3,6 +3,7 @@ import AccountManager from './components/AccountManager'
|
||||
import ApiTester from './components/ApiTester'
|
||||
import BatchImport from './components/BatchImport'
|
||||
import VercelSync from './components/VercelSync'
|
||||
import Login from './components/Login'
|
||||
|
||||
const TABS = [
|
||||
{ id: 'accounts', label: '🔑 账号管理' },
|
||||
@@ -16,51 +17,154 @@ export default function App() {
|
||||
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)
|
||||
|
||||
// 检查已存储的 Token
|
||||
useEffect(() => {
|
||||
const checkAuth = async () => {
|
||||
// 检查 localStorage 或 sessionStorage
|
||||
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()) {
|
||||
// 验证 token 是否有效
|
||||
try {
|
||||
const res = await fetch('/admin/verify', {
|
||||
headers: { 'Authorization': `Bearer ${storedToken}` }
|
||||
})
|
||||
if (res.ok) {
|
||||
setToken(storedToken)
|
||||
} else {
|
||||
// Token 无效,清除
|
||||
localStorage.removeItem('ds2api_token')
|
||||
localStorage.removeItem('ds2api_token_expires')
|
||||
sessionStorage.removeItem('ds2api_token')
|
||||
sessionStorage.removeItem('ds2api_token_expires')
|
||||
}
|
||||
} catch {
|
||||
// 网络错误,保留 token 重试
|
||||
setToken(storedToken)
|
||||
}
|
||||
}
|
||||
setAuthChecking(false)
|
||||
}
|
||||
checkAuth()
|
||||
}, [])
|
||||
|
||||
// 带认证的 fetch
|
||||
const authFetch = async (url, options = {}) => {
|
||||
const headers = {
|
||||
...options.headers,
|
||||
'Authorization': `Bearer ${token}`
|
||||
}
|
||||
const res = await fetch(url, { ...options, headers })
|
||||
|
||||
// 401 时自动登出
|
||||
if (res.status === 401) {
|
||||
handleLogout()
|
||||
throw new Error('认证已过期,请重新登录')
|
||||
}
|
||||
return res
|
||||
}
|
||||
|
||||
const fetchConfig = async () => {
|
||||
if (!token) return
|
||||
try {
|
||||
setLoading(true)
|
||||
const res = await fetch('/admin/config')
|
||||
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(() => {
|
||||
fetchConfig()
|
||||
}, [])
|
||||
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':
|
||||
return <AccountManager config={config} onRefresh={fetchConfig} onMessage={showMessage} />
|
||||
return <AccountManager config={config} onRefresh={fetchConfig} onMessage={showMessage} authFetch={authFetch} />
|
||||
case 'test':
|
||||
return <ApiTester config={config} onMessage={showMessage} />
|
||||
return <ApiTester config={config} onMessage={showMessage} authFetch={authFetch} />
|
||||
case 'import':
|
||||
return <BatchImport onRefresh={fetchConfig} onMessage={showMessage} />
|
||||
return <BatchImport onRefresh={fetchConfig} onMessage={showMessage} authFetch={authFetch} />
|
||||
case 'vercel':
|
||||
return <VercelSync onMessage={showMessage} />
|
||||
return <VercelSync onMessage={showMessage} authFetch={authFetch} />
|
||||
default:
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
// 认证检查中
|
||||
if (authChecking) {
|
||||
return (
|
||||
<div className="app">
|
||||
<div className="login-container">
|
||||
<div className="login-card">
|
||||
<div className="empty-state">
|
||||
<span className="loading"></span> 检查登录状态...
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
// 未登录
|
||||
if (!token) {
|
||||
return (
|
||||
<div className="app">
|
||||
{message && (
|
||||
<div className={`alert alert-${message.type}`}>
|
||||
{message.text}
|
||||
</div>
|
||||
)}
|
||||
<Login onLogin={handleLogin} onMessage={showMessage} />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
// 已登录
|
||||
return (
|
||||
<div className="app">
|
||||
<header className="header">
|
||||
<h1>DS2API Admin</h1>
|
||||
<p>账号管理 · API 测试 · Vercel 部署</p>
|
||||
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center' }}>
|
||||
<div>
|
||||
<h1>DS2API Admin</h1>
|
||||
<p>账号管理 · API 测试 · Vercel 部署</p>
|
||||
</div>
|
||||
<button className="btn btn-secondary btn-sm" onClick={handleLogout}>
|
||||
🚪 登出
|
||||
</button>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
{message && (
|
||||
@@ -104,3 +208,4 @@ export default function App() {
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { useState, useEffect } from 'react'
|
||||
|
||||
export default function AccountManager({ config, onRefresh, onMessage }) {
|
||||
export default function AccountManager({ config, onRefresh, onMessage, authFetch }) {
|
||||
const [showAddKey, setShowAddKey] = useState(false)
|
||||
const [showAddAccount, setShowAddAccount] = useState(false)
|
||||
const [newKey, setNewKey] = useState('')
|
||||
@@ -13,10 +13,13 @@ export default function AccountManager({ config, onRefresh, onMessage }) {
|
||||
const [batchProgress, setBatchProgress] = useState({ current: 0, total: 0, results: [] })
|
||||
const [queueStatus, setQueueStatus] = useState(null)
|
||||
|
||||
// 使用 authFetch 或回退到普通 fetch
|
||||
const apiFetch = authFetch || fetch
|
||||
|
||||
// 获取队列状态
|
||||
const fetchQueueStatus = async () => {
|
||||
try {
|
||||
const res = await fetch('/admin/queue/status')
|
||||
const res = await apiFetch('/admin/queue/status')
|
||||
if (res.ok) {
|
||||
const data = await res.json()
|
||||
setQueueStatus(data)
|
||||
@@ -36,7 +39,7 @@ export default function AccountManager({ config, onRefresh, onMessage }) {
|
||||
if (!newKey.trim()) return
|
||||
setLoading(true)
|
||||
try {
|
||||
const res = await fetch('/admin/keys', {
|
||||
const res = await apiFetch('/admin/keys', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ key: newKey.trim() }),
|
||||
@@ -60,7 +63,7 @@ export default function AccountManager({ config, onRefresh, onMessage }) {
|
||||
const deleteKey = async (key) => {
|
||||
if (!confirm('确定删除此 API Key?')) return
|
||||
try {
|
||||
const res = await fetch(`/admin/keys/${encodeURIComponent(key)}`, { method: 'DELETE' })
|
||||
const res = await apiFetch(`/admin/keys/${encodeURIComponent(key)}`, { method: 'DELETE' })
|
||||
if (res.ok) {
|
||||
onMessage('success', '删除成功')
|
||||
onRefresh()
|
||||
@@ -79,7 +82,7 @@ export default function AccountManager({ config, onRefresh, onMessage }) {
|
||||
}
|
||||
setLoading(true)
|
||||
try {
|
||||
const res = await fetch('/admin/accounts', {
|
||||
const res = await apiFetch('/admin/accounts', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(newAccount),
|
||||
@@ -103,7 +106,7 @@ export default function AccountManager({ config, onRefresh, onMessage }) {
|
||||
const deleteAccount = async (id) => {
|
||||
if (!confirm('确定删除此账号?')) return
|
||||
try {
|
||||
const res = await fetch(`/admin/accounts/${encodeURIComponent(id)}`, { method: 'DELETE' })
|
||||
const res = await apiFetch(`/admin/accounts/${encodeURIComponent(id)}`, { method: 'DELETE' })
|
||||
if (res.ok) {
|
||||
onMessage('success', '删除成功')
|
||||
onRefresh()
|
||||
@@ -119,7 +122,7 @@ export default function AccountManager({ config, onRefresh, onMessage }) {
|
||||
const validateAccount = async (identifier) => {
|
||||
setValidating(prev => ({ ...prev, [identifier]: true }))
|
||||
try {
|
||||
const res = await fetch('/admin/accounts/validate', {
|
||||
const res = await apiFetch('/admin/accounts/validate', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ identifier }),
|
||||
@@ -155,7 +158,7 @@ export default function AccountManager({ config, onRefresh, onMessage }) {
|
||||
const id = acc.email || acc.mobile
|
||||
|
||||
try {
|
||||
const res = await fetch('/admin/accounts/validate', {
|
||||
const res = await apiFetch('/admin/accounts/validate', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ identifier: id }),
|
||||
@@ -179,7 +182,7 @@ export default function AccountManager({ config, onRefresh, onMessage }) {
|
||||
const testAccount = async (identifier) => {
|
||||
setTesting(prev => ({ ...prev, [identifier]: true }))
|
||||
try {
|
||||
const res = await fetch('/admin/accounts/test', {
|
||||
const res = await apiFetch('/admin/accounts/test', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ identifier }),
|
||||
@@ -215,7 +218,7 @@ export default function AccountManager({ config, onRefresh, onMessage }) {
|
||||
const id = acc.email || acc.mobile
|
||||
|
||||
try {
|
||||
const res = await fetch('/admin/accounts/test', {
|
||||
const res = await apiFetch('/admin/accounts/test', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ identifier: id }),
|
||||
|
||||
@@ -7,7 +7,7 @@ const MODELS = [
|
||||
{ id: 'deepseek-reasoner-search', name: 'deepseek-reasoner-search' },
|
||||
]
|
||||
|
||||
export default function ApiTester({ config, onMessage }) {
|
||||
export default function ApiTester({ config, onMessage, authFetch }) {
|
||||
const [model, setModel] = useState('deepseek-chat')
|
||||
const [message, setMessage] = useState('你好,请用一句话介绍你自己。')
|
||||
const [apiKey, setApiKey] = useState('')
|
||||
@@ -15,6 +15,9 @@ export default function ApiTester({ config, onMessage }) {
|
||||
const [response, setResponse] = useState(null)
|
||||
const [loading, setLoading] = useState(false)
|
||||
|
||||
// 使用 authFetch 或回退到普通 fetch(admin API 用 authFetch,OpenAI 兼容 API 用普通 fetch)
|
||||
const apiFetch = authFetch || fetch
|
||||
|
||||
// 获取账号列表
|
||||
const accounts = config.accounts || []
|
||||
|
||||
@@ -22,7 +25,7 @@ export default function ApiTester({ config, onMessage }) {
|
||||
setLoading(true)
|
||||
setResponse(null)
|
||||
try {
|
||||
const res = await fetch('/admin/test', {
|
||||
const res = await apiFetch('/admin/test', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
@@ -96,7 +99,7 @@ export default function ApiTester({ config, onMessage }) {
|
||||
// 如果选择了指定账号,使用账号测试接口
|
||||
if (selectedAccount) {
|
||||
try {
|
||||
const res = await fetch('/admin/accounts/test', {
|
||||
const res = await apiFetch('/admin/accounts/test', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
|
||||
@@ -51,11 +51,14 @@ const TEMPLATES = {
|
||||
}
|
||||
}
|
||||
|
||||
export default function BatchImport({ onRefresh, onMessage }) {
|
||||
export default function BatchImport({ onRefresh, onMessage, authFetch }) {
|
||||
const [jsonInput, setJsonInput] = useState('')
|
||||
const [loading, setLoading] = useState(false)
|
||||
const [result, setResult] = useState(null)
|
||||
|
||||
// 使用 authFetch 或回退到普通 fetch
|
||||
const apiFetch = authFetch || fetch
|
||||
|
||||
const handleImport = async () => {
|
||||
if (!jsonInput.trim()) {
|
||||
onMessage('error', '请输入 JSON 配置')
|
||||
@@ -73,7 +76,7 @@ export default function BatchImport({ onRefresh, onMessage }) {
|
||||
setLoading(true)
|
||||
setResult(null)
|
||||
try {
|
||||
const res = await fetch('/admin/import', {
|
||||
const res = await apiFetch('/admin/import', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(config),
|
||||
@@ -103,7 +106,7 @@ export default function BatchImport({ onRefresh, onMessage }) {
|
||||
|
||||
const handleExport = async () => {
|
||||
try {
|
||||
const res = await fetch('/admin/export')
|
||||
const res = await apiFetch('/admin/export')
|
||||
if (res.ok) {
|
||||
const data = await res.json()
|
||||
setJsonInput(JSON.stringify(JSON.parse(data.json), null, 2))
|
||||
@@ -116,7 +119,7 @@ export default function BatchImport({ onRefresh, onMessage }) {
|
||||
|
||||
const copyBase64 = async () => {
|
||||
try {
|
||||
const res = await fetch('/admin/export')
|
||||
const res = await apiFetch('/admin/export')
|
||||
if (res.ok) {
|
||||
const data = await res.json()
|
||||
await navigator.clipboard.writeText(data.base64)
|
||||
|
||||
90
webui/src/components/Login.jsx
Normal file
90
webui/src/components/Login.jsx
Normal file
@@ -0,0 +1,90 @@
|
||||
import { useState } from 'react'
|
||||
|
||||
export default function Login({ onLogin, onMessage }) {
|
||||
const [adminKey, setAdminKey] = useState('')
|
||||
const [loading, setLoading] = useState(false)
|
||||
const [remember, setRemember] = useState(true)
|
||||
|
||||
const handleLogin = async (e) => {
|
||||
e.preventDefault()
|
||||
setLoading(true)
|
||||
|
||||
try {
|
||||
const res = await fetch('/admin/login', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ admin_key: adminKey }),
|
||||
})
|
||||
|
||||
const data = await res.json()
|
||||
|
||||
if (res.ok && data.success) {
|
||||
// 存储 token
|
||||
const storage = remember ? localStorage : sessionStorage
|
||||
storage.setItem('ds2api_token', data.token)
|
||||
storage.setItem('ds2api_token_expires', Date.now() + data.expires_in * 1000)
|
||||
|
||||
onLogin(data.token)
|
||||
if (data.message) {
|
||||
onMessage('warning', data.message)
|
||||
}
|
||||
} else {
|
||||
onMessage('error', data.detail || '登录失败')
|
||||
}
|
||||
} catch (e) {
|
||||
onMessage('error', '网络错误: ' + e.message)
|
||||
} finally {
|
||||
setLoading(false)
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="login-container">
|
||||
<div className="login-card">
|
||||
<div className="login-header">
|
||||
<h1>🔐 DS2API Admin</h1>
|
||||
<p>请输入管理密钥登录</p>
|
||||
</div>
|
||||
|
||||
<form onSubmit={handleLogin}>
|
||||
<div className="form-group">
|
||||
<label className="form-label">管理密钥</label>
|
||||
<input
|
||||
type="password"
|
||||
className="form-input"
|
||||
placeholder="输入 DS2API_ADMIN_KEY..."
|
||||
value={adminKey}
|
||||
onChange={e => setAdminKey(e.target.value)}
|
||||
autoFocus
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="form-group" style={{ flexDirection: 'row', alignItems: 'center', gap: '0.5rem' }}>
|
||||
<input
|
||||
type="checkbox"
|
||||
id="remember"
|
||||
checked={remember}
|
||||
onChange={e => setRemember(e.target.checked)}
|
||||
/>
|
||||
<label htmlFor="remember" style={{ cursor: 'pointer' }}>
|
||||
记住登录状态
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<button
|
||||
type="submit"
|
||||
className="btn btn-primary"
|
||||
disabled={loading}
|
||||
style={{ width: '100%', justifyContent: 'center' }}
|
||||
>
|
||||
{loading ? <span className="loading"></span> : '🚀 登录'}
|
||||
</button>
|
||||
</form>
|
||||
|
||||
<div className="login-footer">
|
||||
<p>Session 有效期 24 小时</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -1,6 +1,6 @@
|
||||
import { useState, useEffect } from 'react'
|
||||
|
||||
export default function VercelSync({ onMessage }) {
|
||||
export default function VercelSync({ onMessage, authFetch }) {
|
||||
const [vercelToken, setVercelToken] = useState('')
|
||||
const [projectId, setProjectId] = useState('')
|
||||
const [teamId, setTeamId] = useState('')
|
||||
@@ -8,11 +8,14 @@ export default function VercelSync({ onMessage }) {
|
||||
const [result, setResult] = useState(null)
|
||||
const [preconfig, setPreconfig] = useState(null)
|
||||
|
||||
// 使用 authFetch 或回退到普通 fetch
|
||||
const apiFetch = authFetch || fetch
|
||||
|
||||
// 自动加载预配置的 Vercel 信息
|
||||
useEffect(() => {
|
||||
const loadPreconfig = async () => {
|
||||
try {
|
||||
const res = await fetch('/admin/vercel/config')
|
||||
const res = await apiFetch('/admin/vercel/config')
|
||||
if (res.ok) {
|
||||
const data = await res.json()
|
||||
setPreconfig(data)
|
||||
@@ -42,7 +45,7 @@ export default function VercelSync({ onMessage }) {
|
||||
setLoading(true)
|
||||
setResult(null)
|
||||
try {
|
||||
const res = await fetch('/admin/vercel/sync', {
|
||||
const res = await apiFetch('/admin/vercel/sync', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
|
||||
@@ -505,6 +505,46 @@ textarea.form-input {
|
||||
color: var(--error);
|
||||
}
|
||||
|
||||
/* Login Page */
|
||||
.login-container {
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
min-height: 70vh;
|
||||
}
|
||||
|
||||
.login-card {
|
||||
background: var(--bg-secondary);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: var(--radius);
|
||||
padding: 2.5rem;
|
||||
width: 100%;
|
||||
max-width: 400px;
|
||||
box-shadow: var(--shadow);
|
||||
}
|
||||
|
||||
.login-header {
|
||||
text-align: center;
|
||||
margin-bottom: 2rem;
|
||||
}
|
||||
|
||||
.login-header h1 {
|
||||
font-size: 1.75rem;
|
||||
margin-bottom: 0.5rem;
|
||||
}
|
||||
|
||||
.login-header p {
|
||||
color: var(--text-secondary);
|
||||
font-size: 0.9rem;
|
||||
}
|
||||
|
||||
.login-footer {
|
||||
margin-top: 1.5rem;
|
||||
text-align: center;
|
||||
color: var(--text-secondary);
|
||||
font-size: 0.8rem;
|
||||
}
|
||||
|
||||
@media (max-width: 640px) {
|
||||
.app {
|
||||
padding: 1rem;
|
||||
|
||||
Reference in New Issue
Block a user