feat: Initialize project with FastAPI backend, React web UI, Vercel sync, and API integrations.

This commit is contained in:
CJACK
2026-02-01 02:17:01 +08:00
parent fc7de77151
commit bc260899c1
35 changed files with 5730 additions and 1954 deletions

106
webui/src/App.jsx Normal file
View File

@@ -0,0 +1,106 @@
import { useState, useEffect } from 'react'
import AccountManager from './components/AccountManager'
import ApiTester from './components/ApiTester'
import BatchImport from './components/BatchImport'
import VercelSync from './components/VercelSync'
const TABS = [
{ id: 'accounts', label: '🔑 账号管理' },
{ id: 'test', label: '🧪 API 测试' },
{ id: 'import', label: '📦 批量导入' },
{ id: 'vercel', label: '☁️ Vercel 同步' },
]
export default function App() {
const [activeTab, setActiveTab] = useState('accounts')
const [config, setConfig] = useState({ keys: [], accounts: [] })
const [loading, setLoading] = useState(true)
const [message, setMessage] = useState(null)
const fetchConfig = async () => {
try {
setLoading(true)
const res = await fetch('/admin/config')
if (res.ok) {
const data = await res.json()
setConfig(data)
}
} catch (e) {
console.error('获取配置失败:', e)
} finally {
setLoading(false)
}
}
useEffect(() => {
fetchConfig()
}, [])
const showMessage = (type, text) => {
setMessage({ type, text })
setTimeout(() => setMessage(null), 5000)
}
const renderTab = () => {
switch (activeTab) {
case 'accounts':
return <AccountManager config={config} onRefresh={fetchConfig} onMessage={showMessage} />
case 'test':
return <ApiTester config={config} onMessage={showMessage} />
case 'import':
return <BatchImport onRefresh={fetchConfig} onMessage={showMessage} />
case 'vercel':
return <VercelSync onMessage={showMessage} />
default:
return null
}
}
return (
<div className="app">
<header className="header">
<h1>DS2API Admin</h1>
<p>账号管理 · API 测试 · Vercel 部署</p>
</header>
{message && (
<div className={`alert alert-${message.type}`}>
{message.text}
</div>
)}
<div className="stats">
<div className="stat">
<div className="stat-value">{config.keys?.length || 0}</div>
<div className="stat-label">API Keys</div>
</div>
<div className="stat">
<div className="stat-value">{config.accounts?.length || 0}</div>
<div className="stat-label">账号</div>
</div>
</div>
<div className="tabs">
{TABS.map(tab => (
<button
key={tab.id}
className={`tab ${activeTab === tab.id ? 'active' : ''}`}
onClick={() => setActiveTab(tab.id)}
>
{tab.label}
</button>
))}
</div>
{loading ? (
<div className="card">
<div className="empty-state">
<span className="loading"></span> 加载中...
</div>
</div>
) : (
renderTab()
)}
</div>
)
}

View File

@@ -0,0 +1,219 @@
import { useState } from 'react'
export default function AccountManager({ config, onRefresh, onMessage }) {
const [showAddKey, setShowAddKey] = useState(false)
const [showAddAccount, setShowAddAccount] = useState(false)
const [newKey, setNewKey] = useState('')
const [newAccount, setNewAccount] = useState({ email: '', mobile: '', password: '' })
const [loading, setLoading] = useState(false)
const addKey = async () => {
if (!newKey.trim()) return
setLoading(true)
try {
const res = await fetch('/admin/keys', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ key: newKey.trim() }),
})
if (res.ok) {
onMessage('success', 'API Key 添加成功')
setNewKey('')
setShowAddKey(false)
onRefresh()
} else {
const data = await res.json()
onMessage('error', data.detail || '添加失败')
}
} catch (e) {
onMessage('error', '网络错误')
} finally {
setLoading(false)
}
}
const deleteKey = async (key) => {
if (!confirm('确定删除此 API Key')) return
try {
const res = await fetch(`/admin/keys/${encodeURIComponent(key)}`, { method: 'DELETE' })
if (res.ok) {
onMessage('success', '删除成功')
onRefresh()
} else {
onMessage('error', '删除失败')
}
} catch (e) {
onMessage('error', '网络错误')
}
}
const addAccount = async () => {
if (!newAccount.password || (!newAccount.email && !newAccount.mobile)) {
onMessage('error', '请填写密码和邮箱/手机号')
return
}
setLoading(true)
try {
const res = await fetch('/admin/accounts', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(newAccount),
})
if (res.ok) {
onMessage('success', '账号添加成功')
setNewAccount({ email: '', mobile: '', password: '' })
setShowAddAccount(false)
onRefresh()
} else {
const data = await res.json()
onMessage('error', data.detail || '添加失败')
}
} catch (e) {
onMessage('error', '网络错误')
} finally {
setLoading(false)
}
}
const deleteAccount = async (id) => {
if (!confirm('确定删除此账号?')) return
try {
const res = await fetch(`/admin/accounts/${encodeURIComponent(id)}`, { method: 'DELETE' })
if (res.ok) {
onMessage('success', '删除成功')
onRefresh()
} else {
onMessage('error', '删除失败')
}
} catch (e) {
onMessage('error', '网络错误')
}
}
return (
<div className="section">
{/* API Keys */}
<div className="card">
<div className="card-header">
<span className="card-title">🔑 API Keys</span>
<button className="btn btn-primary" onClick={() => setShowAddKey(true)}>+ 添加</button>
</div>
{config.keys?.length > 0 ? (
<div className="list">
{config.keys.map((key, i) => (
<div key={i} className="list-item">
<span className="list-item-text">{key.slice(0, 16)}****</span>
<button className="btn btn-danger" onClick={() => deleteKey(key)}>删除</button>
</div>
))}
</div>
) : (
<div className="empty-state">暂无 API Key</div>
)}
</div>
{/* Accounts */}
<div className="card">
<div className="card-header">
<span className="card-title">👤 DeepSeek 账号</span>
<button className="btn btn-primary" onClick={() => setShowAddAccount(true)}>+ 添加</button>
</div>
{config.accounts?.length > 0 ? (
<div className="list">
{config.accounts.map((acc, i) => (
<div key={i} className="list-item">
<div className="list-item-info">
<span className="list-item-text">{acc.email || acc.mobile}</span>
<span className={`badge ${acc.has_token ? 'badge-success' : 'badge-warning'}`}>
{acc.has_token ? '已登录' : '未登录'}
</span>
</div>
<button className="btn btn-danger" onClick={() => deleteAccount(acc.email || acc.mobile)}>删除</button>
</div>
))}
</div>
) : (
<div className="empty-state">暂无账号</div>
)}
</div>
{/* Add Key Modal */}
{showAddKey && (
<div className="modal-overlay" onClick={() => setShowAddKey(false)}>
<div className="modal" onClick={e => e.stopPropagation()}>
<div className="modal-header">
<span className="modal-title">添加 API Key</span>
<button className="modal-close" onClick={() => setShowAddKey(false)}>&times;</button>
</div>
<div className="form-group">
<label className="form-label">API Key</label>
<input
type="text"
className="form-input"
placeholder="输入你自定义的 API Key"
value={newKey}
onChange={e => setNewKey(e.target.value)}
/>
</div>
<div className="btn-group">
<button className="btn btn-secondary" onClick={() => setShowAddKey(false)}>取消</button>
<button className="btn btn-primary" onClick={addKey} disabled={loading}>
{loading ? <span className="loading"></span> : '添加'}
</button>
</div>
</div>
</div>
)}
{/* Add Account Modal */}
{showAddAccount && (
<div className="modal-overlay" onClick={() => setShowAddAccount(false)}>
<div className="modal" onClick={e => e.stopPropagation()}>
<div className="modal-header">
<span className="modal-title">添加 DeepSeek 账号</span>
<button className="modal-close" onClick={() => setShowAddAccount(false)}>&times;</button>
</div>
<div className="form-group">
<label className="form-label">Email可选</label>
<input
type="email"
className="form-input"
placeholder="user@example.com"
value={newAccount.email}
onChange={e => setNewAccount({ ...newAccount, email: e.target.value })}
/>
</div>
<div className="form-group">
<label className="form-label">手机号可选</label>
<input
type="text"
className="form-input"
placeholder="+86..."
value={newAccount.mobile}
onChange={e => setNewAccount({ ...newAccount, mobile: e.target.value })}
/>
</div>
<div className="form-group">
<label className="form-label">密码必填</label>
<input
type="password"
className="form-input"
placeholder="DeepSeek 账号密码"
value={newAccount.password}
onChange={e => setNewAccount({ ...newAccount, password: e.target.value })}
/>
</div>
<div className="btn-group">
<button className="btn btn-secondary" onClick={() => setShowAddAccount(false)}>取消</button>
<button className="btn btn-primary" onClick={addAccount} disabled={loading}>
{loading ? <span className="loading"></span> : '添加'}
</button>
</div>
</div>
</div>
)}
</div>
)
}

View File

@@ -0,0 +1,162 @@
import { useState } from 'react'
const MODELS = [
{ id: 'deepseek-chat', name: 'DeepSeek V3 (Chat)' },
{ id: 'deepseek-reasoner', name: 'DeepSeek R1 (Reasoner)' },
{ id: 'deepseek-chat-search', name: 'DeepSeek V3 + 搜索' },
{ id: 'deepseek-reasoner-search', name: 'DeepSeek R1 + 搜索' },
]
export default function ApiTester({ config, onMessage }) {
const [model, setModel] = useState('deepseek-chat')
const [message, setMessage] = useState('你好,请用一句话介绍你自己。')
const [apiKey, setApiKey] = useState('')
const [response, setResponse] = useState(null)
const [loading, setLoading] = useState(false)
const testApi = async () => {
setLoading(true)
setResponse(null)
try {
const res = await fetch('/admin/test', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
model,
message,
api_key: apiKey || (config.keys?.[0] || ''),
}),
})
const data = await res.json()
setResponse(data)
if (data.success) {
onMessage('success', 'API 调用成功')
} else {
onMessage('error', data.error || 'API 调用失败')
}
} catch (e) {
onMessage('error', '网络错误')
setResponse({ error: e.message })
} finally {
setLoading(false)
}
}
const directTest = async () => {
setLoading(true)
setResponse(null)
try {
const key = apiKey || (config.keys?.[0] || '')
if (!key) {
onMessage('error', '请提供 API Key')
setLoading(false)
return
}
const res = await fetch('/v1/chat/completions', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'Authorization': `Bearer ${key}`,
},
body: JSON.stringify({
model,
messages: [{ role: 'user', content: message }],
stream: false,
}),
})
const data = await res.json()
setResponse({
success: res.ok,
status_code: res.status,
response: data,
})
if (res.ok) {
onMessage('success', 'API 调用成功')
} else {
onMessage('error', data.error || 'API 调用失败')
}
} catch (e) {
onMessage('error', '网络错误')
setResponse({ error: e.message })
} finally {
setLoading(false)
}
}
return (
<div className="section">
<div className="card">
<div className="card-title" style={{ marginBottom: '1rem' }}>🧪 API 测试</div>
<div className="form-group">
<label className="form-label">模型</label>
<select
className="form-input"
value={model}
onChange={e => setModel(e.target.value)}
>
{MODELS.map(m => (
<option key={m.id} value={m.id}>{m.name}</option>
))}
</select>
</div>
<div className="form-group">
<label className="form-label">API Key留空使用第一个配置的 Key</label>
<input
type="text"
className="form-input"
placeholder={config.keys?.[0] ? `默认: ${config.keys[0].slice(0, 8)}...` : '请先添加 API Key'}
value={apiKey}
onChange={e => setApiKey(e.target.value)}
/>
</div>
<div className="form-group">
<label className="form-label">消息内容</label>
<textarea
className="form-input"
value={message}
onChange={e => setMessage(e.target.value)}
placeholder="输入测试消息..."
/>
</div>
<div className="btn-group">
<button className="btn btn-primary" onClick={directTest} disabled={loading}>
{loading ? <span className="loading"></span> : '🚀 发送请求'}
</button>
</div>
</div>
{response && (
<div className="card">
<div className="card-header">
<span className="card-title">响应结果</span>
<span className={`badge ${response.success ? 'badge-success' : 'badge-error'}`}>
{response.success ? '成功' : '失败'} {response.status_code && `(${response.status_code})`}
</span>
</div>
<div className="code-block">
{JSON.stringify(response.response || response.error, null, 2)}
</div>
{response.success && response.response?.choices?.[0]?.message?.content && (
<div style={{ marginTop: '1rem' }}>
<div className="form-label">AI 回复</div>
<div style={{
padding: '1rem',
background: 'var(--bg-tertiary)',
borderRadius: 'var(--radius)',
whiteSpace: 'pre-wrap'
}}>
{response.response.choices[0].message.content}
</div>
</div>
)}
</div>
)}
</div>
)
}

View File

@@ -0,0 +1,200 @@
import { useState } from 'react'
// 模板配置
const TEMPLATES = {
full: {
name: '完整模板',
desc: '包含所有配置项',
config: {
keys: ["your-api-key-1", "your-api-key-2"],
accounts: [
{ email: "user1@example.com", password: "password1", token: "" },
{ email: "user2@example.com", password: "password2", token: "" },
{ mobile: "+8613800138001", password: "password3", token: "" }
],
claude_model_mapping: {
fast: "deepseek-chat",
slow: "deepseek-reasoner"
}
}
},
email_only: {
name: '邮箱账号模板',
desc: '仅邮箱账号',
config: {
keys: ["your-api-key"],
accounts: [
{ email: "account1@example.com", password: "pass1", token: "" },
{ email: "account2@example.com", password: "pass2", token: "" },
{ email: "account3@example.com", password: "pass3", token: "" }
]
}
},
mobile_only: {
name: '手机号账号模板',
desc: '仅手机号账号',
config: {
keys: ["your-api-key"],
accounts: [
{ mobile: "+8613800000001", password: "pass1", token: "" },
{ mobile: "+8613800000002", password: "pass2", token: "" },
{ mobile: "+8613800000003", password: "pass3", token: "" }
]
}
},
keys_only: {
name: '仅 API Keys',
desc: '只添加 API Keys',
config: {
keys: ["key-1", "key-2", "key-3"]
}
}
}
export default function BatchImport({ onRefresh, onMessage }) {
const [jsonInput, setJsonInput] = useState('')
const [loading, setLoading] = useState(false)
const [result, setResult] = useState(null)
const handleImport = async () => {
if (!jsonInput.trim()) {
onMessage('error', '请输入 JSON 配置')
return
}
let config
try {
config = JSON.parse(jsonInput)
} catch (e) {
onMessage('error', 'JSON 格式无效')
return
}
setLoading(true)
setResult(null)
try {
const res = await fetch('/admin/import', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(config),
})
const data = await res.json()
if (res.ok) {
setResult(data)
onMessage('success', `导入成功: ${data.imported_keys} 个 Key, ${data.imported_accounts} 个账号`)
onRefresh()
} else {
onMessage('error', data.detail || '导入失败')
}
} catch (e) {
onMessage('error', '网络错误')
} finally {
setLoading(false)
}
}
const loadTemplate = (key) => {
const tpl = TEMPLATES[key]
if (tpl) {
setJsonInput(JSON.stringify(tpl.config, null, 2))
onMessage('info', `已加载「${tpl.name}`)
}
}
const handleExport = async () => {
try {
const res = await fetch('/admin/export')
if (res.ok) {
const data = await res.json()
setJsonInput(JSON.stringify(JSON.parse(data.json), null, 2))
onMessage('success', '已加载当前配置')
}
} catch (e) {
onMessage('error', '获取配置失败')
}
}
const copyBase64 = async () => {
try {
const res = await fetch('/admin/export')
if (res.ok) {
const data = await res.json()
await navigator.clipboard.writeText(data.base64)
onMessage('success', 'Base64 已复制到剪贴板')
}
} catch (e) {
onMessage('error', '复制失败')
}
}
return (
<div className="section">
{/* 模板选择 */}
<div className="card">
<div className="card-title" style={{ marginBottom: '1rem' }}>📋 快速模板</div>
<div className="grid grid-2">
{Object.entries(TEMPLATES).map(([key, tpl]) => (
<div
key={key}
style={{
padding: '1rem',
background: 'var(--bg-tertiary)',
borderRadius: 'var(--radius)',
cursor: 'pointer',
transition: 'all 0.2s',
border: '1px solid transparent'
}}
onClick={() => loadTemplate(key)}
onMouseEnter={e => e.currentTarget.style.borderColor = 'var(--accent)'}
onMouseLeave={e => e.currentTarget.style.borderColor = 'transparent'}
>
<div style={{ fontWeight: 600, marginBottom: '0.25rem' }}>{tpl.name}</div>
<div style={{ fontSize: '0.85rem', color: 'var(--text-secondary)' }}>{tpl.desc}</div>
</div>
))}
</div>
</div>
{/* 导入区域 */}
<div className="card">
<div className="card-title" style={{ marginBottom: '1rem' }}>📦 批量导入</div>
<div className="form-group">
<label className="form-label">JSON 配置点击上方模板快速填充</label>
<textarea
className="form-input"
style={{ minHeight: '200px' }}
value={jsonInput}
onChange={e => setJsonInput(e.target.value)}
placeholder='{\n "keys": ["你的API密钥"],\n "accounts": [\n {"email": "邮箱", "password": "密码", "token": ""}\n ]\n}'
/>
</div>
<div className="btn-group" style={{ marginBottom: '1rem' }}>
<button className="btn btn-secondary" onClick={handleExport}>
导出当前
</button>
<button className="btn btn-primary" onClick={handleImport} disabled={loading}>
{loading ? <span className="loading"></span> : '📥 导入配置'}
</button>
</div>
{result && (
<div className="alert alert-success">
导入完成{result.imported_keys} API Key{result.imported_accounts} 个账号
</div>
)}
</div>
<div className="card">
<div className="card-title" style={{ marginBottom: '1rem' }}>📤 导出 Base64</div>
<p style={{ color: 'var(--text-secondary)', marginBottom: '1rem' }}>
导出 Base64 格式配置可直接粘贴到 Vercel 环境变量 <code>DS2API_CONFIG_JSON</code>
</p>
<button className="btn btn-success" onClick={copyBase64}>
📋 复制 Base64 到剪贴板
</button>
</div>
</div>
)
}

View File

@@ -0,0 +1,193 @@
import { useState, useEffect } from 'react'
export default function VercelSync({ onMessage }) {
const [vercelToken, setVercelToken] = useState('')
const [projectId, setProjectId] = useState('')
const [teamId, setTeamId] = useState('')
const [loading, setLoading] = useState(false)
const [result, setResult] = useState(null)
const [preconfig, setPreconfig] = useState(null)
// 自动加载预配置的 Vercel 信息
useEffect(() => {
const loadPreconfig = async () => {
try {
const res = await fetch('/admin/vercel/config')
if (res.ok) {
const data = await res.json()
setPreconfig(data)
if (data.project_id) setProjectId(data.project_id)
if (data.team_id) setTeamId(data.team_id)
}
} catch (e) {
console.error('加载 Vercel 预配置失败:', e)
}
}
loadPreconfig()
}, [])
const handleSync = async () => {
// 如果预配置了 token使用特殊标记让后端使用预配置的 token
const tokenToUse = preconfig?.has_token && !vercelToken ? '__USE_PRECONFIG__' : vercelToken
if (!tokenToUse && !preconfig?.has_token) {
onMessage('error', '请填写 Vercel Token')
return
}
if (!projectId) {
onMessage('error', '请填写 Project ID')
return
}
setLoading(true)
setResult(null)
try {
const res = await fetch('/admin/vercel/sync', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
vercel_token: vercelToken,
project_id: projectId,
team_id: teamId || undefined,
}),
})
const data = await res.json()
if (res.ok) {
setResult(data)
onMessage('success', data.message)
} else {
onMessage('error', data.detail || '同步失败')
}
} catch (e) {
onMessage('error', '网络错误')
} finally {
setLoading(false)
}
}
return (
<div className="section">
<div className="card">
<div className="card-title" style={{ marginBottom: '1rem' }}> Vercel 同步</div>
<div className="alert alert-info" style={{ marginBottom: '1rem' }}>
<strong>说明</strong>同步配置到 Vercel 后会自动触发重新部署约需 30-60 秒生效
</div>
<div className="form-group">
<label className="form-label">
Vercel Token
<a
href="https://vercel.com/account/tokens"
target="_blank"
rel="noopener noreferrer"
style={{ marginLeft: '0.5rem', fontSize: '0.8rem' }}
>
获取 Token
</a>
</label>
<input
type="password"
className="form-input"
placeholder="输入 Vercel API Token"
value={vercelToken}
onChange={e => setVercelToken(e.target.value)}
/>
</div>
<div className="form-group">
<label className="form-label">
Project ID
<span style={{ marginLeft: '0.5rem', fontSize: '0.8rem', color: 'var(--text-secondary)' }}>
(可在 Vercel 项目设置中找到)
</span>
</label>
<input
type="text"
className="form-input"
placeholder="prj_xxxxxxxxxxxx 或项目名称"
value={projectId}
onChange={e => setProjectId(e.target.value)}
/>
</div>
<div className="form-group">
<label className="form-label">
Team ID可选
<span style={{ marginLeft: '0.5rem', fontSize: '0.8rem', color: 'var(--text-secondary)' }}>
(个人项目无需填写)
</span>
</label>
<input
type="text"
className="form-input"
placeholder="team_xxxxxxxxxxxx"
value={teamId}
onChange={e => setTeamId(e.target.value)}
/>
</div>
<button
className="btn btn-primary"
onClick={handleSync}
disabled={loading}
style={{ width: '100%' }}
>
{loading ? (
<>
<span className="loading"></span>
同步中...
</>
) : (
'🚀 同步到 Vercel 并重新部署'
)}
</button>
</div>
{result && (
<div className="card">
<div className="card-title" style={{ marginBottom: '1rem' }}>同步结果</div>
<div className={`alert ${result.success ? 'alert-success' : 'alert-error'}`}>
{result.message}
</div>
{result.deployment_url && (
<p>
部署地址
<a
href={`https://${result.deployment_url}`}
target="_blank"
rel="noopener noreferrer"
style={{ color: 'var(--accent)' }}
>
{result.deployment_url}
</a>
</p>
)}
{result.manual_deploy_required && (
<p style={{ color: 'var(--warning)' }}>
需要手动在 Vercel 控制台触发重新部署
</p>
)}
</div>
)}
<div className="card">
<div className="card-title" style={{ marginBottom: '1rem' }}>📖 使用说明</div>
<ol style={{ paddingLeft: '1.5rem', color: 'var(--text-secondary)' }}>
<li style={{ marginBottom: '0.5rem' }}>
前往 <a href="https://vercel.com/account/tokens" target="_blank" rel="noopener noreferrer" style={{ color: 'var(--accent)' }}>Vercel Token 页面</a> 创建一个新 Token
</li>
<li style={{ marginBottom: '0.5rem' }}>
Vercel 项目设置中找到 Project IDSettings General Project ID
</li>
<li style={{ marginBottom: '0.5rem' }}>
如果是团队项目还需要填写 Team ID
</li>
<li style={{ marginBottom: '0.5rem' }}>
点击同步按钮配置将自动更新到 Vercel 环境变量并触发重新部署
</li>
</ol>
</div>
</div>
)
}

10
webui/src/main.jsx Normal file
View File

@@ -0,0 +1,10 @@
import React from 'react'
import ReactDOM from 'react-dom/client'
import App from './App.jsx'
import './styles.css'
ReactDOM.createRoot(document.getElementById('root')).render(
<React.StrictMode>
<App />
</React.StrictMode>,
)

421
webui/src/styles.css Normal file
View File

@@ -0,0 +1,421 @@
:root {
--bg-primary: #0f0f0f;
--bg-secondary: #1a1a1a;
--bg-tertiary: #242424;
--text-primary: #e5e5e5;
--text-secondary: #a0a0a0;
--accent: #3b82f6;
--accent-hover: #2563eb;
--success: #22c55e;
--error: #ef4444;
--warning: #f59e0b;
--border: #333;
--radius: 8px;
--shadow: 0 4px 20px rgba(0, 0, 0, 0.4);
}
* {
margin: 0;
padding: 0;
box-sizing: border-box;
}
body {
font-family: 'Inter', -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif;
background: var(--bg-primary);
color: var(--text-primary);
line-height: 1.6;
min-height: 100vh;
}
.app {
max-width: 1200px;
margin: 0 auto;
padding: 2rem;
}
.header {
text-align: center;
margin-bottom: 2rem;
padding-bottom: 1.5rem;
border-bottom: 1px solid var(--border);
}
.header h1 {
font-size: 2rem;
background: linear-gradient(135deg, #3b82f6, #8b5cf6);
-webkit-background-clip: text;
-webkit-text-fill-color: transparent;
background-clip: text;
}
.header p {
color: var(--text-secondary);
margin-top: 0.5rem;
}
.tabs {
display: flex;
gap: 0.5rem;
margin-bottom: 1.5rem;
flex-wrap: wrap;
}
.tab {
padding: 0.75rem 1.5rem;
border: none;
background: var(--bg-secondary);
color: var(--text-secondary);
border-radius: var(--radius);
cursor: pointer;
transition: all 0.2s;
font-size: 0.9rem;
}
.tab:hover {
background: var(--bg-tertiary);
color: var(--text-primary);
}
.tab.active {
background: var(--accent);
color: white;
}
.card {
background: var(--bg-secondary);
border-radius: var(--radius);
padding: 1.5rem;
margin-bottom: 1rem;
border: 1px solid var(--border);
}
.card-header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 1rem;
}
.card-title {
font-size: 1.1rem;
font-weight: 600;
}
.form-group {
margin-bottom: 1rem;
}
.form-label {
display: block;
margin-bottom: 0.5rem;
color: var(--text-secondary);
font-size: 0.9rem;
}
.form-input {
width: 100%;
padding: 0.75rem 1rem;
background: var(--bg-tertiary);
border: 1px solid var(--border);
border-radius: var(--radius);
color: var(--text-primary);
font-size: 0.95rem;
transition: border-color 0.2s;
}
.form-input:focus {
outline: none;
border-color: var(--accent);
}
.form-input::placeholder {
color: var(--text-secondary);
opacity: 0.6;
}
textarea.form-input {
min-height: 120px;
resize: vertical;
font-family: 'Monaco', 'Menlo', monospace;
font-size: 0.85rem;
}
.btn {
padding: 0.75rem 1.5rem;
border: none;
border-radius: var(--radius);
cursor: pointer;
font-size: 0.9rem;
font-weight: 500;
transition: all 0.2s;
display: inline-flex;
align-items: center;
gap: 0.5rem;
}
.btn-primary {
background: var(--accent);
color: white;
}
.btn-primary:hover {
background: var(--accent-hover);
}
.btn-secondary {
background: var(--bg-tertiary);
color: var(--text-primary);
}
.btn-secondary:hover {
background: #333;
}
.btn-danger {
background: var(--error);
color: white;
}
.btn-danger:hover {
background: #dc2626;
}
.btn-success {
background: var(--success);
color: white;
}
.btn-success:hover {
background: #16a34a;
}
.btn:disabled {
opacity: 0.5;
cursor: not-allowed;
}
.btn-group {
display: flex;
gap: 0.5rem;
flex-wrap: wrap;
}
.list {
display: flex;
flex-direction: column;
gap: 0.5rem;
}
.list-item {
display: flex;
justify-content: space-between;
align-items: center;
padding: 0.75rem 1rem;
background: var(--bg-tertiary);
border-radius: var(--radius);
}
.list-item-text {
font-family: 'Monaco', 'Menlo', monospace;
font-size: 0.9rem;
}
.list-item-info {
display: flex;
gap: 0.75rem;
align-items: center;
}
.badge {
padding: 0.25rem 0.5rem;
border-radius: 4px;
font-size: 0.75rem;
font-weight: 500;
}
.badge-success {
background: rgba(34, 197, 94, 0.2);
color: var(--success);
}
.badge-warning {
background: rgba(245, 158, 11, 0.2);
color: var(--warning);
}
.badge-error {
background: rgba(239, 68, 68, 0.2);
color: var(--error);
}
.alert {
padding: 1rem;
border-radius: var(--radius);
margin-bottom: 1rem;
}
.alert-success {
background: rgba(34, 197, 94, 0.1);
border: 1px solid var(--success);
color: var(--success);
}
.alert-error {
background: rgba(239, 68, 68, 0.1);
border: 1px solid var(--error);
color: var(--error);
}
.alert-info {
background: rgba(59, 130, 246, 0.1);
border: 1px solid var(--accent);
color: var(--accent);
}
.empty-state {
text-align: center;
padding: 2rem;
color: var(--text-secondary);
}
.code-block {
background: var(--bg-tertiary);
border-radius: var(--radius);
padding: 1rem;
overflow-x: auto;
font-family: 'Monaco', 'Menlo', monospace;
font-size: 0.85rem;
white-space: pre-wrap;
word-break: break-all;
}
.loading {
display: inline-block;
width: 1rem;
height: 1rem;
border: 2px solid rgba(255, 255, 255, 0.3);
border-radius: 50%;
border-top-color: white;
animation: spin 0.8s linear infinite;
}
@keyframes spin {
to { transform: rotate(360deg); }
}
.modal-overlay {
position: fixed;
top: 0;
left: 0;
right: 0;
bottom: 0;
background: rgba(0, 0, 0, 0.7);
display: flex;
align-items: center;
justify-content: center;
z-index: 1000;
}
.modal {
background: var(--bg-secondary);
border-radius: var(--radius);
padding: 1.5rem;
max-width: 500px;
width: 90%;
max-height: 80vh;
overflow-y: auto;
box-shadow: var(--shadow);
}
.modal-header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 1rem;
padding-bottom: 1rem;
border-bottom: 1px solid var(--border);
}
.modal-title {
font-size: 1.1rem;
font-weight: 600;
}
.modal-close {
background: none;
border: none;
color: var(--text-secondary);
font-size: 1.5rem;
cursor: pointer;
line-height: 1;
}
.modal-close:hover {
color: var(--text-primary);
}
.section {
margin-bottom: 2rem;
}
.section-title {
font-size: 1rem;
margin-bottom: 1rem;
color: var(--text-secondary);
text-transform: uppercase;
letter-spacing: 0.05em;
}
.grid {
display: grid;
gap: 1rem;
}
.grid-2 {
grid-template-columns: repeat(auto-fit, minmax(280px, 1fr));
}
.stats {
display: flex;
gap: 1.5rem;
margin-bottom: 1rem;
}
.stat {
text-align: center;
}
.stat-value {
font-size: 2rem;
font-weight: 700;
color: var(--accent);
}
.stat-label {
font-size: 0.85rem;
color: var(--text-secondary);
}
@media (max-width: 640px) {
.app {
padding: 1rem;
}
.tabs {
flex-direction: column;
}
.tab {
text-align: center;
}
.btn-group {
flex-direction: column;
}
.btn {
width: 100%;
justify-content: center;
}
}