mirror of
https://github.com/CJackHwang/ds2api.git
synced 2026-05-09 18:57:43 +08:00
feat: Initialize project with FastAPI backend, React web UI, Vercel sync, and API integrations.
This commit is contained in:
106
webui/src/App.jsx
Normal file
106
webui/src/App.jsx
Normal 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>
|
||||
)
|
||||
}
|
||||
219
webui/src/components/AccountManager.jsx
Normal file
219
webui/src/components/AccountManager.jsx
Normal 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)}>×</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)}>×</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>
|
||||
)
|
||||
}
|
||||
162
webui/src/components/ApiTester.jsx
Normal file
162
webui/src/components/ApiTester.jsx
Normal 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>
|
||||
)
|
||||
}
|
||||
200
webui/src/components/BatchImport.jsx
Normal file
200
webui/src/components/BatchImport.jsx
Normal 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>
|
||||
)
|
||||
}
|
||||
193
webui/src/components/VercelSync.jsx
Normal file
193
webui/src/components/VercelSync.jsx
Normal 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 ID(Settings → 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
10
webui/src/main.jsx
Normal 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
421
webui/src/styles.css
Normal 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;
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user