mirror of
https://github.com/CJackHwang/ds2api.git
synced 2026-05-08 10:25:28 +08:00
Compare commits
7 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
d496122840 | ||
|
|
35b99cdf4c | ||
|
|
85ac0d95a4 | ||
|
|
648b80bb7b | ||
|
|
ee0b7f08a0 | ||
|
|
d0b159fb8a | ||
|
|
d57f547bdb |
36
core/auth.py
36
core/auth.py
@@ -58,17 +58,35 @@ def get_queue_status() -> dict:
|
|||||||
# ----------------------------------------------------------------------
|
# ----------------------------------------------------------------------
|
||||||
# 账号选择与释放 - 轮询(Round-Robin)策略
|
# 账号选择与释放 - 轮询(Round-Robin)策略
|
||||||
# ----------------------------------------------------------------------
|
# ----------------------------------------------------------------------
|
||||||
def choose_new_account(exclude_ids=None):
|
def choose_new_account(exclude_ids=None, target_id=None):
|
||||||
"""轮询选择策略:
|
"""轮询选择策略:
|
||||||
1. 使用线程锁保证并发安全
|
1. 使用线程锁保证并发安全
|
||||||
2. 优先选择队首的有 token 账号
|
2. 如果指定了 target_id,优先尝试获取该账号
|
||||||
3. 从队列头部取出账号(FIFO)
|
3. 优先选择队首的有 token 账号
|
||||||
4. 请求完成后调用 release_account 将账号放回队尾
|
4. 从队列头部取出账号(FIFO)
|
||||||
|
5. 请求完成后调用 release_account 将账号放回队尾
|
||||||
"""
|
"""
|
||||||
if exclude_ids is None:
|
if exclude_ids is None:
|
||||||
exclude_ids = []
|
exclude_ids = []
|
||||||
|
|
||||||
with _queue_lock:
|
with _queue_lock:
|
||||||
|
# 0. 如果指定了目标账号,优先尝试获取
|
||||||
|
if target_id:
|
||||||
|
for i in range(len(account_queue)):
|
||||||
|
acc = account_queue[i]
|
||||||
|
acc_id = get_account_identifier(acc)
|
||||||
|
if acc_id == target_id:
|
||||||
|
selected = account_queue.pop(i)
|
||||||
|
in_use_accounts[acc_id] = selected
|
||||||
|
logger.info(f"[choose_new_account] 指定选择: {acc_id} | 队列剩余: {len(account_queue)}")
|
||||||
|
return selected
|
||||||
|
# 如果队列中没找到,且不在 in_use 中,说明账号不存在
|
||||||
|
if target_id not in in_use_accounts:
|
||||||
|
logger.warning(f"[choose_new_account] 指定账号不存在: {target_id}")
|
||||||
|
else:
|
||||||
|
logger.warning(f"[choose_new_account] 指定账号正忙: {target_id}")
|
||||||
|
return None
|
||||||
|
|
||||||
# 第一轮:优先选择已有 token 的账号
|
# 第一轮:优先选择已有 token 的账号
|
||||||
for i in range(len(account_queue)):
|
for i in range(len(account_queue)):
|
||||||
acc = account_queue[i]
|
acc = account_queue[i]
|
||||||
@@ -145,11 +163,17 @@ def determine_mode_and_token(request: Request):
|
|||||||
if caller_key in config_keys:
|
if caller_key in config_keys:
|
||||||
request.state.use_config_token = True
|
request.state.use_config_token = True
|
||||||
request.state.tried_accounts = [] # 初始化已尝试账号
|
request.state.tried_accounts = [] # 初始化已尝试账号
|
||||||
selected_account = choose_new_account()
|
|
||||||
|
target_account = request.headers.get("X-Ds2-Target-Account")
|
||||||
|
selected_account = choose_new_account(target_id=target_account)
|
||||||
|
|
||||||
if not selected_account:
|
if not selected_account:
|
||||||
|
detail_msg = "No accounts configured or all accounts are busy."
|
||||||
|
if target_account:
|
||||||
|
detail_msg = f"Target account {target_account} is busy or not found."
|
||||||
raise HTTPException(
|
raise HTTPException(
|
||||||
status_code=429,
|
status_code=429,
|
||||||
detail="No accounts configured or all accounts are busy.",
|
detail=detail_msg,
|
||||||
)
|
)
|
||||||
if not selected_account.get("token", "").strip():
|
if not selected_account.get("token", "").strip():
|
||||||
try:
|
try:
|
||||||
|
|||||||
@@ -32,9 +32,14 @@ logger = logging.getLogger("ds2api")
|
|||||||
|
|
||||||
# -------------------------- 初始化 tokenizer --------------------------
|
# -------------------------- 初始化 tokenizer --------------------------
|
||||||
chat_tokenizer_dir = resolve_path("DS2API_TOKENIZER_DIR", "")
|
chat_tokenizer_dir = resolve_path("DS2API_TOKENIZER_DIR", "")
|
||||||
|
# 抑制 Mistral tokenizer regex 警告(不影响 DeepSeek tokenization)
|
||||||
|
_tf_logger = logging.getLogger("transformers")
|
||||||
|
_tf_log_level = _tf_logger.level
|
||||||
|
_tf_logger.setLevel(logging.ERROR)
|
||||||
tokenizer = transformers.AutoTokenizer.from_pretrained(
|
tokenizer = transformers.AutoTokenizer.from_pretrained(
|
||||||
chat_tokenizer_dir, trust_remote_code=True
|
chat_tokenizer_dir, trust_remote_code=True
|
||||||
)
|
)
|
||||||
|
_tf_logger.setLevel(_tf_log_level)
|
||||||
|
|
||||||
# ----------------------------------------------------------------------
|
# ----------------------------------------------------------------------
|
||||||
# 配置文件的读写函数
|
# 配置文件的读写函数
|
||||||
|
|||||||
@@ -255,6 +255,26 @@ def parse_sse_chunk_for_content(
|
|||||||
return ([], True, new_fragment_type)
|
return ([], True, new_fragment_type)
|
||||||
contents.extend(result)
|
contents.extend(result)
|
||||||
|
|
||||||
|
# 处理字典值(初始响应 chunk,包含 response.fragments)
|
||||||
|
elif isinstance(v_value, dict):
|
||||||
|
response_obj = v_value.get("response", v_value)
|
||||||
|
fragments = response_obj.get("fragments", [])
|
||||||
|
if isinstance(fragments, list):
|
||||||
|
for frag in fragments:
|
||||||
|
if isinstance(frag, dict):
|
||||||
|
frag_type = frag.get("type", "").upper()
|
||||||
|
frag_content = frag.get("content", "")
|
||||||
|
if frag_type == "THINK" or frag_type == "THINKING":
|
||||||
|
new_fragment_type = "thinking"
|
||||||
|
if frag_content:
|
||||||
|
contents.append((frag_content, "thinking"))
|
||||||
|
elif frag_type == "RESPONSE":
|
||||||
|
new_fragment_type = "text"
|
||||||
|
if frag_content:
|
||||||
|
contents.append((frag_content, "text"))
|
||||||
|
elif frag_content:
|
||||||
|
contents.append((frag_content, ptype))
|
||||||
|
|
||||||
return (contents, False, new_fragment_type)
|
return (contents, False, new_fragment_type)
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -122,6 +122,49 @@ async def delete_key(key: str, _: bool = Depends(verify_admin)):
|
|||||||
# ----------------------------------------------------------------------
|
# ----------------------------------------------------------------------
|
||||||
# 账号管理
|
# 账号管理
|
||||||
# ----------------------------------------------------------------------
|
# ----------------------------------------------------------------------
|
||||||
|
@router.get("/accounts")
|
||||||
|
async def list_accounts(
|
||||||
|
page: int = 1,
|
||||||
|
page_size: int = 10,
|
||||||
|
_: bool = Depends(verify_admin)
|
||||||
|
):
|
||||||
|
"""获取账号列表(分页,倒序,密码脱敏)"""
|
||||||
|
accounts = CONFIG.get("accounts", [])
|
||||||
|
total = len(accounts)
|
||||||
|
|
||||||
|
# 倒序排列
|
||||||
|
accounts = list(reversed(accounts))
|
||||||
|
|
||||||
|
# 计算分页
|
||||||
|
page = max(1, page)
|
||||||
|
page_size = max(1, min(100, page_size)) # 限制每页最多 100 条
|
||||||
|
total_pages = (total + page_size - 1) // page_size if total > 0 else 1
|
||||||
|
|
||||||
|
start = (page - 1) * page_size
|
||||||
|
end = start + page_size
|
||||||
|
page_accounts = accounts[start:end]
|
||||||
|
|
||||||
|
# 脱敏处理
|
||||||
|
safe_accounts = []
|
||||||
|
for acc in page_accounts:
|
||||||
|
safe_acc = {
|
||||||
|
"email": acc.get("email", ""),
|
||||||
|
"mobile": acc.get("mobile", ""),
|
||||||
|
"has_password": bool(acc.get("password")),
|
||||||
|
"has_token": bool(acc.get("token")),
|
||||||
|
"token_preview": acc.get("token", "")[:20] + "..." if acc.get("token") else "",
|
||||||
|
}
|
||||||
|
safe_accounts.append(safe_acc)
|
||||||
|
|
||||||
|
return JSONResponse(content={
|
||||||
|
"items": safe_accounts,
|
||||||
|
"total": total,
|
||||||
|
"page": page,
|
||||||
|
"page_size": page_size,
|
||||||
|
"total_pages": total_pages,
|
||||||
|
})
|
||||||
|
|
||||||
|
|
||||||
@router.post("/accounts")
|
@router.post("/accounts")
|
||||||
async def add_account(request: Request, _: bool = Depends(verify_admin)):
|
async def add_account(request: Request, _: bool = Depends(verify_admin)):
|
||||||
"""添加账号"""
|
"""添加账号"""
|
||||||
|
|||||||
@@ -2,8 +2,10 @@
|
|||||||
"""Admin Vercel 模块 - Vercel 同步和部署"""
|
"""Admin Vercel 模块 - Vercel 同步和部署"""
|
||||||
import asyncio
|
import asyncio
|
||||||
import base64
|
import base64
|
||||||
|
import hashlib
|
||||||
import json
|
import json
|
||||||
import os
|
import os
|
||||||
|
import time as _time
|
||||||
|
|
||||||
import httpx
|
import httpx
|
||||||
from fastapi import APIRouter, HTTPException, Request, Depends
|
from fastapi import APIRouter, HTTPException, Request, Depends
|
||||||
@@ -23,6 +25,19 @@ VERCEL_PROJECT_ID = os.getenv("VERCEL_PROJECT_ID", "")
|
|||||||
VERCEL_TEAM_ID = os.getenv("VERCEL_TEAM_ID", "")
|
VERCEL_TEAM_ID = os.getenv("VERCEL_TEAM_ID", "")
|
||||||
|
|
||||||
|
|
||||||
|
def _compute_config_hash() -> str:
|
||||||
|
"""计算可同步配置的指纹哈希(仅包含 keys 和 accounts)"""
|
||||||
|
syncable = {
|
||||||
|
"keys": CONFIG.get("keys", []),
|
||||||
|
"accounts": [
|
||||||
|
{k: v for k, v in acc.items() if k != "token"}
|
||||||
|
for acc in CONFIG.get("accounts", [])
|
||||||
|
],
|
||||||
|
}
|
||||||
|
raw = json.dumps(syncable, sort_keys=True, ensure_ascii=False, separators=(",", ":"))
|
||||||
|
return hashlib.md5(raw.encode("utf-8")).hexdigest()
|
||||||
|
|
||||||
|
|
||||||
# ----------------------------------------------------------------------
|
# ----------------------------------------------------------------------
|
||||||
# API 测试(通过本地 API)
|
# API 测试(通过本地 API)
|
||||||
# ----------------------------------------------------------------------
|
# ----------------------------------------------------------------------
|
||||||
@@ -228,6 +243,10 @@ async def sync_to_vercel(request: Request, _: bool = Depends(verify_admin)):
|
|||||||
|
|
||||||
if deploy_resp.status_code in [200, 201]:
|
if deploy_resp.status_code in [200, 201]:
|
||||||
deploy_data = deploy_resp.json()
|
deploy_data = deploy_resp.json()
|
||||||
|
# 记录同步哈希和时间
|
||||||
|
CONFIG["_vercel_sync_hash"] = _compute_config_hash()
|
||||||
|
CONFIG["_vercel_sync_time"] = int(_time.time())
|
||||||
|
save_config(CONFIG)
|
||||||
result = {
|
result = {
|
||||||
"success": True,
|
"success": True,
|
||||||
"message": "配置已同步,正在重新部署...",
|
"message": "配置已同步,正在重新部署...",
|
||||||
@@ -240,6 +259,10 @@ async def sync_to_vercel(request: Request, _: bool = Depends(verify_admin)):
|
|||||||
result["saved_credentials"] = saved_credentials
|
result["saved_credentials"] = saved_credentials
|
||||||
return JSONResponse(content=result)
|
return JSONResponse(content=result)
|
||||||
|
|
||||||
|
# 环境变量已更新,但无法自动触发重新部署
|
||||||
|
CONFIG["_vercel_sync_hash"] = _compute_config_hash()
|
||||||
|
CONFIG["_vercel_sync_time"] = int(_time.time())
|
||||||
|
save_config(CONFIG)
|
||||||
result = {
|
result = {
|
||||||
"success": True,
|
"success": True,
|
||||||
"message": "配置已同步到 Vercel,请手动触发重新部署",
|
"message": "配置已同步到 Vercel,请手动触发重新部署",
|
||||||
@@ -259,6 +282,25 @@ async def sync_to_vercel(request: Request, _: bool = Depends(verify_admin)):
|
|||||||
raise HTTPException(status_code=500, detail=str(e))
|
raise HTTPException(status_code=500, detail=str(e))
|
||||||
|
|
||||||
|
|
||||||
|
# ----------------------------------------------------------------------
|
||||||
|
# 同步状态查询
|
||||||
|
# ----------------------------------------------------------------------
|
||||||
|
@router.get("/vercel/status")
|
||||||
|
async def get_vercel_sync_status(_: bool = Depends(verify_admin)):
|
||||||
|
"""检查当前配置与上次同步到 Vercel 的配置是否一致"""
|
||||||
|
last_hash = CONFIG.get("_vercel_sync_hash", "")
|
||||||
|
last_time = CONFIG.get("_vercel_sync_time", 0)
|
||||||
|
current_hash = _compute_config_hash()
|
||||||
|
|
||||||
|
synced = bool(last_hash and last_hash == current_hash)
|
||||||
|
|
||||||
|
return JSONResponse(content={
|
||||||
|
"synced": synced,
|
||||||
|
"last_sync_time": last_time if last_time else None,
|
||||||
|
"has_synced_before": bool(last_hash),
|
||||||
|
})
|
||||||
|
|
||||||
|
|
||||||
# ----------------------------------------------------------------------
|
# ----------------------------------------------------------------------
|
||||||
# 导出配置
|
# 导出配置
|
||||||
# ----------------------------------------------------------------------
|
# ----------------------------------------------------------------------
|
||||||
|
|||||||
@@ -314,7 +314,7 @@ Remember: Output ONLY the JSON, no other text. The response must start with {{ a
|
|||||||
deepseek_resp.close()
|
deepseek_resp.close()
|
||||||
except Exception:
|
except Exception:
|
||||||
pass
|
pass
|
||||||
cleanup_account(request)
|
# 注意:不在此处调用 cleanup_account,由外层 finally 统一处理
|
||||||
|
|
||||||
return StreamingResponse(
|
return StreamingResponse(
|
||||||
claude_sse_stream(),
|
claude_sse_stream(),
|
||||||
|
|||||||
@@ -194,6 +194,7 @@ IMPORTANT: If calling tools, output ONLY the JSON. The response must start with
|
|||||||
last_content_time = time.time() # 最后收到有效内容的时间
|
last_content_time = time.time() # 最后收到有效内容的时间
|
||||||
keepalive_count = 0 # 连续 keepalive 计数
|
keepalive_count = 0 # 连续 keepalive 计数
|
||||||
has_content = False # 是否收到过内容
|
has_content = False # 是否收到过内容
|
||||||
|
stream_finished = False # 是否已发送过结束标记
|
||||||
|
|
||||||
def process_data():
|
def process_data():
|
||||||
"""处理 DeepSeek SSE 数据流 - 使用 sse_parser 模块"""
|
"""处理 DeepSeek SSE 数据流 - 使用 sse_parser 模块"""
|
||||||
@@ -343,6 +344,7 @@ IMPORTANT: If calling tools, output ONLY the JSON. The response must start with
|
|||||||
yield f"data: {json.dumps(finish_chunk, ensure_ascii=False)}\n\n"
|
yield f"data: {json.dumps(finish_chunk, ensure_ascii=False)}\n\n"
|
||||||
yield "data: [DONE]\n\n"
|
yield "data: [DONE]\n\n"
|
||||||
last_send_time = current_time
|
last_send_time = current_time
|
||||||
|
stream_finished = True
|
||||||
break
|
break
|
||||||
|
|
||||||
new_choices = []
|
new_choices = []
|
||||||
@@ -391,8 +393,8 @@ IMPORTANT: If calling tools, output ONLY the JSON. The response must start with
|
|||||||
except queue.Empty:
|
except queue.Empty:
|
||||||
continue
|
continue
|
||||||
|
|
||||||
# 如果是超时退出,也发送结束标记
|
# 如果是超时退出且尚未发送结束标记,补发结束标记
|
||||||
if has_content:
|
if has_content and not stream_finished:
|
||||||
prompt_tokens = len(final_prompt) // 4
|
prompt_tokens = len(final_prompt) // 4
|
||||||
thinking_tokens = len(final_thinking) // 4
|
thinking_tokens = len(final_thinking) // 4
|
||||||
completion_tokens = len(final_text) // 4
|
completion_tokens = len(final_text) // 4
|
||||||
@@ -435,8 +437,7 @@ IMPORTANT: If calling tools, output ONLY the JSON. The response must start with
|
|||||||
|
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.error(f"[sse_stream] 异常: {e}")
|
logger.error(f"[sse_stream] 异常: {e}")
|
||||||
finally:
|
# 注意:不在此处调用 cleanup_account,由外层 finally 统一处理
|
||||||
cleanup_account(request)
|
|
||||||
|
|
||||||
return StreamingResponse(
|
return StreamingResponse(
|
||||||
sse_stream(),
|
sse_stream(),
|
||||||
|
|||||||
@@ -1 +0,0 @@
|
|||||||
|
|
||||||
@@ -8,7 +8,10 @@ import {
|
|||||||
Server,
|
Server,
|
||||||
ShieldCheck,
|
ShieldCheck,
|
||||||
Copy,
|
Copy,
|
||||||
Check
|
Check,
|
||||||
|
ChevronLeft,
|
||||||
|
ChevronRight,
|
||||||
|
ChevronDown
|
||||||
} from 'lucide-react'
|
} from 'lucide-react'
|
||||||
import clsx from 'clsx'
|
import clsx from 'clsx'
|
||||||
import { useI18n } from '../i18n'
|
import { useI18n } from '../i18n'
|
||||||
@@ -25,9 +28,36 @@ export default function AccountManager({ config, onRefresh, onMessage, authFetch
|
|||||||
const [testingAll, setTestingAll] = useState(false)
|
const [testingAll, setTestingAll] = useState(false)
|
||||||
const [batchProgress, setBatchProgress] = useState({ current: 0, total: 0, results: [] })
|
const [batchProgress, setBatchProgress] = useState({ current: 0, total: 0, results: [] })
|
||||||
const [queueStatus, setQueueStatus] = useState(null)
|
const [queueStatus, setQueueStatus] = useState(null)
|
||||||
|
const [keysExpanded, setKeysExpanded] = useState(false)
|
||||||
|
|
||||||
|
// 分页状态
|
||||||
|
const [accounts, setAccounts] = useState([])
|
||||||
|
const [page, setPage] = useState(1)
|
||||||
|
const [pageSize] = useState(10)
|
||||||
|
const [totalPages, setTotalPages] = useState(1)
|
||||||
|
const [totalAccounts, setTotalAccounts] = useState(0)
|
||||||
|
const [loadingAccounts, setLoadingAccounts] = useState(false)
|
||||||
|
|
||||||
const apiFetch = authFetch || fetch
|
const apiFetch = authFetch || fetch
|
||||||
|
|
||||||
|
const fetchAccounts = async (targetPage = page) => {
|
||||||
|
setLoadingAccounts(true)
|
||||||
|
try {
|
||||||
|
const res = await apiFetch(`/admin/accounts?page=${targetPage}&page_size=${pageSize}`)
|
||||||
|
if (res.ok) {
|
||||||
|
const data = await res.json()
|
||||||
|
setAccounts(data.items || [])
|
||||||
|
setTotalPages(data.total_pages || 1)
|
||||||
|
setTotalAccounts(data.total || 0)
|
||||||
|
setPage(data.page || 1)
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
console.error('Failed to fetch accounts:', e)
|
||||||
|
} finally {
|
||||||
|
setLoadingAccounts(false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
const fetchQueueStatus = async () => {
|
const fetchQueueStatus = async () => {
|
||||||
try {
|
try {
|
||||||
const res = await apiFetch('/admin/queue/status')
|
const res = await apiFetch('/admin/queue/status')
|
||||||
@@ -41,6 +71,7 @@ export default function AccountManager({ config, onRefresh, onMessage, authFetch
|
|||||||
}
|
}
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
|
fetchAccounts()
|
||||||
fetchQueueStatus()
|
fetchQueueStatus()
|
||||||
const interval = setInterval(fetchQueueStatus, 5000)
|
const interval = setInterval(fetchQueueStatus, 5000)
|
||||||
return () => clearInterval(interval)
|
return () => clearInterval(interval)
|
||||||
@@ -102,6 +133,7 @@ export default function AccountManager({ config, onRefresh, onMessage, authFetch
|
|||||||
onMessage('success', t('accountManager.addAccountSuccess'))
|
onMessage('success', t('accountManager.addAccountSuccess'))
|
||||||
setNewAccount({ email: '', mobile: '', password: '' })
|
setNewAccount({ email: '', mobile: '', password: '' })
|
||||||
setShowAddAccount(false)
|
setShowAddAccount(false)
|
||||||
|
fetchAccounts(1) // 添加后回到第一页
|
||||||
onRefresh()
|
onRefresh()
|
||||||
} else {
|
} else {
|
||||||
const data = await res.json()
|
const data = await res.json()
|
||||||
@@ -120,6 +152,7 @@ export default function AccountManager({ config, onRefresh, onMessage, authFetch
|
|||||||
const res = await apiFetch(`/admin/accounts/${encodeURIComponent(id)}`, { method: 'DELETE' })
|
const res = await apiFetch(`/admin/accounts/${encodeURIComponent(id)}`, { method: 'DELETE' })
|
||||||
if (res.ok) {
|
if (res.ok) {
|
||||||
onMessage('success', t('messages.deleted'))
|
onMessage('success', t('messages.deleted'))
|
||||||
|
fetchAccounts() // 刷新当前页
|
||||||
onRefresh()
|
onRefresh()
|
||||||
} else {
|
} else {
|
||||||
onMessage('error', t('messages.deleteFailed'))
|
onMessage('error', t('messages.deleteFailed'))
|
||||||
@@ -142,6 +175,7 @@ export default function AccountManager({ config, onRefresh, onMessage, authFetch
|
|||||||
? t('apiTester.testSuccess', { account: identifier, time: data.response_time })
|
? t('apiTester.testSuccess', { account: identifier, time: data.response_time })
|
||||||
: `${identifier}: ${data.message}`
|
: `${identifier}: ${data.message}`
|
||||||
onMessage(data.success ? 'success' : 'error', statusMessage)
|
onMessage(data.success ? 'success' : 'error', statusMessage)
|
||||||
|
fetchAccounts() // 刷新当前页
|
||||||
onRefresh()
|
onRefresh()
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
onMessage('error', t('accountManager.testFailed', { error: e.message }))
|
onMessage('error', t('accountManager.testFailed', { error: e.message }))
|
||||||
@@ -152,17 +186,17 @@ export default function AccountManager({ config, onRefresh, onMessage, authFetch
|
|||||||
|
|
||||||
const testAllAccounts = async () => {
|
const testAllAccounts = async () => {
|
||||||
if (!confirm(t('accountManager.testAllConfirm'))) return
|
if (!confirm(t('accountManager.testAllConfirm'))) return
|
||||||
const accounts = config.accounts || []
|
const allAccounts = config.accounts || []
|
||||||
if (accounts.length === 0) return
|
if (allAccounts.length === 0) return
|
||||||
|
|
||||||
setTestingAll(true)
|
setTestingAll(true)
|
||||||
setBatchProgress({ current: 0, total: accounts.length, results: [] })
|
setBatchProgress({ current: 0, total: allAccounts.length, results: [] })
|
||||||
|
|
||||||
let successCount = 0
|
let successCount = 0
|
||||||
const results = []
|
const results = []
|
||||||
|
|
||||||
for (let i = 0; i < accounts.length; i++) {
|
for (let i = 0; i < allAccounts.length; i++) {
|
||||||
const acc = accounts[i]
|
const acc = allAccounts[i]
|
||||||
const id = acc.email || acc.mobile
|
const id = acc.email || acc.mobile
|
||||||
|
|
||||||
try {
|
try {
|
||||||
@@ -178,10 +212,11 @@ export default function AccountManager({ config, onRefresh, onMessage, authFetch
|
|||||||
results.push({ id, success: false, message: e.message })
|
results.push({ id, success: false, message: e.message })
|
||||||
}
|
}
|
||||||
|
|
||||||
setBatchProgress({ current: i + 1, total: accounts.length, results: [...results] })
|
setBatchProgress({ current: i + 1, total: allAccounts.length, results: [...results] })
|
||||||
}
|
}
|
||||||
|
|
||||||
onMessage('success', t('accountManager.testAllCompleted', { success: successCount, total: accounts.length }))
|
onMessage('success', t('accountManager.testAllCompleted', { success: successCount, total: allAccounts.length }))
|
||||||
|
fetchAccounts() // 刷新当前页
|
||||||
onRefresh()
|
onRefresh()
|
||||||
setTestingAll(false)
|
setTestingAll(false)
|
||||||
}
|
}
|
||||||
@@ -228,13 +263,22 @@ export default function AccountManager({ config, onRefresh, onMessage, authFetch
|
|||||||
|
|
||||||
{/* API Keys Section */}
|
{/* API Keys Section */}
|
||||||
<div className="bg-card border border-border rounded-xl overflow-hidden shadow-sm">
|
<div className="bg-card border border-border rounded-xl overflow-hidden shadow-sm">
|
||||||
<div className="p-6 border-b border-border flex flex-col md:flex-row md:items-center justify-between gap-4">
|
<div
|
||||||
<div>
|
className="p-6 flex flex-col md:flex-row md:items-center justify-between gap-4 cursor-pointer select-none hover:bg-muted/30 transition-colors"
|
||||||
<h2 className="text-lg font-semibold">{t('accountManager.apiKeysTitle')}</h2>
|
onClick={() => setKeysExpanded(!keysExpanded)}
|
||||||
<p className="text-sm text-muted-foreground">{t('accountManager.apiKeysDesc')}</p>
|
>
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
<ChevronDown className={clsx(
|
||||||
|
"w-5 h-5 text-muted-foreground transition-transform duration-200",
|
||||||
|
keysExpanded ? "rotate-0" : "-rotate-90"
|
||||||
|
)} />
|
||||||
|
<div>
|
||||||
|
<h2 className="text-lg font-semibold">{t('accountManager.apiKeysTitle')}</h2>
|
||||||
|
<p className="text-sm text-muted-foreground">{t('accountManager.apiKeysDesc')} ({config.keys?.length || 0})</p>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<button
|
<button
|
||||||
onClick={() => setShowAddKey(true)}
|
onClick={(e) => { e.stopPropagation(); setShowAddKey(true) }}
|
||||||
className="flex items-center gap-2 px-4 py-2 bg-primary text-primary-foreground rounded-lg hover:bg-primary/90 transition-colors font-medium text-sm shadow-sm"
|
className="flex items-center gap-2 px-4 py-2 bg-primary text-primary-foreground rounded-lg hover:bg-primary/90 transition-colors font-medium text-sm shadow-sm"
|
||||||
>
|
>
|
||||||
<Plus className="w-4 h-4" />
|
<Plus className="w-4 h-4" />
|
||||||
@@ -242,44 +286,46 @@ export default function AccountManager({ config, onRefresh, onMessage, authFetch
|
|||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="divide-y divide-border">
|
{keysExpanded && (
|
||||||
{config.keys?.length > 0 ? (
|
<div className="divide-y divide-border border-t border-border">
|
||||||
config.keys.map((key, i) => (
|
{config.keys?.length > 0 ? (
|
||||||
<div key={i} className="p-4 flex items-center justify-between hover:bg-muted/50 transition-colors group">
|
config.keys.map((key, i) => (
|
||||||
<div className="flex items-center gap-2">
|
<div key={i} className="p-4 flex items-center justify-between hover:bg-muted/50 transition-colors group">
|
||||||
<div className="font-mono text-sm bg-muted/50 px-3 py-1 rounded inline-block">
|
<div className="flex items-center gap-2">
|
||||||
{key.slice(0, 16)}****
|
<div className="font-mono text-sm bg-muted/50 px-3 py-1 rounded inline-block">
|
||||||
|
{key.slice(0, 16)}****
|
||||||
|
</div>
|
||||||
|
{copiedKey === key && (
|
||||||
|
<span className="text-xs text-green-500 animate-pulse">{t('accountManager.copied')}</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center gap-1">
|
||||||
|
<button
|
||||||
|
onClick={() => {
|
||||||
|
navigator.clipboard.writeText(key)
|
||||||
|
setCopiedKey(key)
|
||||||
|
setTimeout(() => setCopiedKey(null), 2000)
|
||||||
|
}}
|
||||||
|
className="p-2 text-muted-foreground hover:text-primary hover:bg-primary/10 rounded-md transition-colors opacity-0 group-hover:opacity-100"
|
||||||
|
title={t('accountManager.copyKeyTitle')}
|
||||||
|
>
|
||||||
|
{copiedKey === key ? <Check className="w-4 h-4 text-green-500" /> : <Copy className="w-4 h-4" />}
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
onClick={() => deleteKey(key)}
|
||||||
|
className="p-2 text-muted-foreground hover:text-destructive hover:bg-destructive/10 rounded-md transition-colors opacity-0 group-hover:opacity-100"
|
||||||
|
title={t('accountManager.deleteKeyTitle')}
|
||||||
|
>
|
||||||
|
<Trash2 className="w-4 h-4" />
|
||||||
|
</button>
|
||||||
</div>
|
</div>
|
||||||
{copiedKey === key && (
|
|
||||||
<span className="text-xs text-green-500 animate-pulse">{t('accountManager.copied')}</span>
|
|
||||||
)}
|
|
||||||
</div>
|
</div>
|
||||||
<div className="flex items-center gap-1">
|
))
|
||||||
<button
|
) : (
|
||||||
onClick={() => {
|
<div className="p-8 text-center text-muted-foreground">{t('accountManager.noApiKeys')}</div>
|
||||||
navigator.clipboard.writeText(key)
|
)}
|
||||||
setCopiedKey(key)
|
</div>
|
||||||
setTimeout(() => setCopiedKey(null), 2000)
|
)}
|
||||||
}}
|
|
||||||
className="p-2 text-muted-foreground hover:text-primary hover:bg-primary/10 rounded-md transition-colors opacity-0 group-hover:opacity-100"
|
|
||||||
title={t('accountManager.copyKeyTitle')}
|
|
||||||
>
|
|
||||||
{copiedKey === key ? <Check className="w-4 h-4 text-green-500" /> : <Copy className="w-4 h-4" />}
|
|
||||||
</button>
|
|
||||||
<button
|
|
||||||
onClick={() => deleteKey(key)}
|
|
||||||
className="p-2 text-muted-foreground hover:text-destructive hover:bg-destructive/10 rounded-md transition-colors opacity-0 group-hover:opacity-100"
|
|
||||||
title={t('accountManager.deleteKeyTitle')}
|
|
||||||
>
|
|
||||||
<Trash2 className="w-4 h-4" />
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
))
|
|
||||||
) : (
|
|
||||||
<div className="p-8 text-center text-muted-foreground">{t('accountManager.noApiKeys')}</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Accounts Section */}
|
{/* Accounts Section */}
|
||||||
@@ -292,7 +338,7 @@ export default function AccountManager({ config, onRefresh, onMessage, authFetch
|
|||||||
<div className="flex flex-wrap gap-2">
|
<div className="flex flex-wrap gap-2">
|
||||||
<button
|
<button
|
||||||
onClick={testAllAccounts}
|
onClick={testAllAccounts}
|
||||||
disabled={testingAll || !config.accounts?.length}
|
disabled={testingAll || totalAccounts === 0}
|
||||||
className="flex items-center px-3 py-2 bg-secondary text-secondary-foreground rounded-lg hover:bg-secondary/80 transition-colors text-xs font-medium border border-border disabled:opacity-50"
|
className="flex items-center px-3 py-2 bg-secondary text-secondary-foreground rounded-lg hover:bg-secondary/80 transition-colors text-xs font-medium border border-border disabled:opacity-50"
|
||||||
>
|
>
|
||||||
{testingAll ? <span className="animate-spin mr-2">⟳</span> : <Play className="w-3 h-3 mr-2" />}
|
{testingAll ? <span className="animate-spin mr-2">⟳</span> : <Play className="w-3 h-3 mr-2" />}
|
||||||
@@ -337,8 +383,10 @@ export default function AccountManager({ config, onRefresh, onMessage, authFetch
|
|||||||
)}
|
)}
|
||||||
|
|
||||||
<div className="divide-y divide-border">
|
<div className="divide-y divide-border">
|
||||||
{config.accounts?.length > 0 ? (
|
{loadingAccounts ? (
|
||||||
config.accounts.map((acc, i) => {
|
<div className="p-8 text-center text-muted-foreground">{t('actions.loading')}</div>
|
||||||
|
) : accounts.length > 0 ? (
|
||||||
|
accounts.map((acc, i) => {
|
||||||
const id = acc.email || acc.mobile
|
const id = acc.email || acc.mobile
|
||||||
return (
|
return (
|
||||||
<div key={i} className="p-4 flex flex-col md:flex-row md:items-center justify-between gap-4 hover:bg-muted/50 transition-colors">
|
<div key={i} className="p-4 flex flex-col md:flex-row md:items-center justify-between gap-4 hover:bg-muted/50 transition-colors">
|
||||||
@@ -381,6 +429,32 @@ export default function AccountManager({ config, onRefresh, onMessage, authFetch
|
|||||||
<div className="p-8 text-center text-muted-foreground">{t('accountManager.noAccounts')}</div>
|
<div className="p-8 text-center text-muted-foreground">{t('accountManager.noAccounts')}</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{/* 分页控件 */}
|
||||||
|
{totalPages > 1 && (
|
||||||
|
<div className="p-4 border-t border-border flex items-center justify-between">
|
||||||
|
<div className="text-sm text-muted-foreground">
|
||||||
|
{t('accountManager.pageInfo', { current: page, total: totalPages, count: totalAccounts })}
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<button
|
||||||
|
onClick={() => fetchAccounts(page - 1)}
|
||||||
|
disabled={page <= 1 || loadingAccounts}
|
||||||
|
className="p-2 border border-border rounded-md hover:bg-secondary transition-colors disabled:opacity-50 disabled:cursor-not-allowed"
|
||||||
|
>
|
||||||
|
<ChevronLeft className="w-4 h-4" />
|
||||||
|
</button>
|
||||||
|
<span className="text-sm font-medium px-2">{page} / {totalPages}</span>
|
||||||
|
<button
|
||||||
|
onClick={() => fetchAccounts(page + 1)}
|
||||||
|
disabled={page >= totalPages || loadingAccounts}
|
||||||
|
className="p-2 border border-border rounded-md hover:bg-secondary transition-colors disabled:opacity-50 disabled:cursor-not-allowed"
|
||||||
|
>
|
||||||
|
<ChevronRight className="w-4 h-4" />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Modals */}
|
{/* Modals */}
|
||||||
|
|||||||
@@ -14,7 +14,9 @@ import {
|
|||||||
ChevronDown,
|
ChevronDown,
|
||||||
ShieldCheck,
|
ShieldCheck,
|
||||||
Terminal,
|
Terminal,
|
||||||
Zap
|
Zap,
|
||||||
|
ToggleLeft,
|
||||||
|
ToggleRight
|
||||||
} from 'lucide-react'
|
} from 'lucide-react'
|
||||||
import clsx from 'clsx'
|
import clsx from 'clsx'
|
||||||
import { useI18n } from '../i18n'
|
import { useI18n } from '../i18n'
|
||||||
@@ -31,6 +33,7 @@ export default function ApiTester({ config, onMessage, authFetch }) {
|
|||||||
const [streamingContent, setStreamingContent] = useState('')
|
const [streamingContent, setStreamingContent] = useState('')
|
||||||
const [streamingThinking, setStreamingThinking] = useState('')
|
const [streamingThinking, setStreamingThinking] = useState('')
|
||||||
const [isStreaming, setIsStreaming] = useState(false)
|
const [isStreaming, setIsStreaming] = useState(false)
|
||||||
|
const [streamingMode, setStreamingMode] = useState(true)
|
||||||
const abortControllerRef = useRef(null)
|
const abortControllerRef = useRef(null)
|
||||||
const defaultMessageRef = useRef(defaultMessage)
|
const defaultMessageRef = useRef(defaultMessage)
|
||||||
|
|
||||||
@@ -55,7 +58,7 @@ export default function ApiTester({ config, onMessage, authFetch }) {
|
|||||||
setIsStreaming(false)
|
setIsStreaming(false)
|
||||||
}
|
}
|
||||||
|
|
||||||
const directTest = async () => {
|
const runTest = async () => {
|
||||||
if (loading) return
|
if (loading) return
|
||||||
|
|
||||||
setLoading(true)
|
setLoading(true)
|
||||||
@@ -75,69 +78,78 @@ export default function ApiTester({ config, onMessage, authFetch }) {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const headers = {
|
||||||
|
'Content-Type': 'application/json',
|
||||||
|
'Authorization': `Bearer ${key}`,
|
||||||
|
}
|
||||||
|
if (selectedAccount) {
|
||||||
|
headers['X-Ds2-Target-Account'] = selectedAccount
|
||||||
|
}
|
||||||
|
|
||||||
const res = await fetch('/v1/chat/completions', {
|
const res = await fetch('/v1/chat/completions', {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
headers: {
|
headers,
|
||||||
'Content-Type': 'application/json',
|
|
||||||
'Authorization': `Bearer ${key}`,
|
|
||||||
},
|
|
||||||
body: JSON.stringify({
|
body: JSON.stringify({
|
||||||
model,
|
model,
|
||||||
messages: [{ role: 'user', content: message }],
|
messages: [{ role: 'user', content: message }],
|
||||||
stream: true,
|
stream: streamingMode,
|
||||||
}),
|
}),
|
||||||
signal: abortControllerRef.current.signal,
|
signal: abortControllerRef.current.signal,
|
||||||
})
|
})
|
||||||
|
|
||||||
if (!res.ok) {
|
if (!res.ok) {
|
||||||
const data = await res.json()
|
const data = await res.json().catch(() => ({}))
|
||||||
setResponse({ success: false, error: data.error?.message || t('apiTester.requestFailed') })
|
const errorMsg = data.error?.message || t('apiTester.requestFailed')
|
||||||
onMessage('error', data.error?.message || t('apiTester.requestFailed'))
|
setResponse({ success: false, error: errorMsg })
|
||||||
|
onMessage('error', errorMsg)
|
||||||
setLoading(false)
|
setLoading(false)
|
||||||
setIsStreaming(false)
|
setIsStreaming(false)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
setResponse({ success: true, status_code: res.status })
|
if (streamingMode) {
|
||||||
|
setResponse({ success: true, status_code: res.status })
|
||||||
|
|
||||||
const reader = res.body.getReader()
|
const reader = res.body.getReader()
|
||||||
const decoder = new TextDecoder()
|
const decoder = new TextDecoder()
|
||||||
let buffer = ''
|
let buffer = ''
|
||||||
|
|
||||||
while (true) {
|
while (true) {
|
||||||
const { done, value } = await reader.read()
|
const { done, value } = await reader.read()
|
||||||
if (done) break
|
if (done) break
|
||||||
|
|
||||||
buffer += decoder.decode(value, { stream: true })
|
buffer += decoder.decode(value, { stream: true })
|
||||||
const lines = buffer.split('\n')
|
const lines = buffer.split('\n')
|
||||||
buffer = lines.pop() || ''
|
buffer = lines.pop() || ''
|
||||||
|
|
||||||
for (const line of lines) {
|
for (const line of lines) {
|
||||||
const trimmed = line.trim()
|
const trimmed = line.trim()
|
||||||
if (!trimmed || !trimmed.startsWith('data: ')) continue
|
if (!trimmed || !trimmed.startsWith('data: ')) continue
|
||||||
|
|
||||||
const dataStr = trimmed.slice(6)
|
const dataStr = trimmed.slice(6)
|
||||||
if (dataStr === '[DONE]') continue
|
if (dataStr === '[DONE]') continue
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const json = JSON.parse(dataStr)
|
const json = JSON.parse(dataStr)
|
||||||
console.log('[ApiTester] Parsed JSON:', json)
|
const choice = json.choices?.[0]
|
||||||
const choice = json.choices?.[0]
|
if (choice?.delta) {
|
||||||
if (choice?.delta) {
|
const delta = choice.delta
|
||||||
const delta = choice.delta
|
if (delta.reasoning_content) {
|
||||||
console.log('[ApiTester] Delta:', delta)
|
setStreamingThinking(prev => prev + delta.reasoning_content)
|
||||||
if (delta.reasoning_content) {
|
}
|
||||||
setStreamingThinking(prev => prev + delta.reasoning_content)
|
if (delta.content) {
|
||||||
}
|
setStreamingContent(prev => prev + delta.content)
|
||||||
if (delta.content) {
|
}
|
||||||
console.log('[ApiTester] Content:', delta.content)
|
|
||||||
setStreamingContent(prev => prev + delta.content)
|
|
||||||
}
|
}
|
||||||
|
} catch (e) {
|
||||||
|
console.error('Invalid JSON hunk:', dataStr, e)
|
||||||
}
|
}
|
||||||
} catch (e) {
|
|
||||||
console.error('Invalid JSON hunk:', dataStr, e)
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
} else {
|
||||||
|
const data = await res.json()
|
||||||
|
setResponse({ success: true, status_code: res.status, ...data })
|
||||||
|
onMessage('success', t('apiTester.testSuccess', { account: selectedAccount || 'Auto', time: 'N/A' }))
|
||||||
}
|
}
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
if (e.name === 'AbortError') {
|
if (e.name === 'AbortError') {
|
||||||
@@ -153,256 +165,235 @@ export default function ApiTester({ config, onMessage, authFetch }) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const sendTest = async () => {
|
useEffect(() => {
|
||||||
if (selectedAccount) {
|
setMessage((prev) => (prev === defaultMessageRef.current ? defaultMessage : prev))
|
||||||
setLoading(true)
|
defaultMessageRef.current = defaultMessage
|
||||||
setResponse(null)
|
}, [defaultMessage])
|
||||||
try {
|
|
||||||
const res = await apiFetch('/admin/accounts/test', {
|
|
||||||
method: 'POST',
|
|
||||||
headers: { 'Content-Type': 'application/json' },
|
|
||||||
body: JSON.stringify({
|
|
||||||
identifier: selectedAccount,
|
|
||||||
model,
|
|
||||||
message,
|
|
||||||
}),
|
|
||||||
})
|
|
||||||
const data = await res.json()
|
|
||||||
setResponse({
|
|
||||||
success: data.success,
|
|
||||||
status_code: res.status,
|
|
||||||
response: data,
|
|
||||||
account: selectedAccount,
|
|
||||||
})
|
|
||||||
if (data.success) {
|
|
||||||
onMessage('success', t('apiTester.testSuccess', { account: selectedAccount, time: data.response_time }))
|
|
||||||
} else {
|
|
||||||
onMessage('error', `${selectedAccount}: ${data.message}`)
|
|
||||||
}
|
|
||||||
} catch (e) {
|
|
||||||
onMessage('error', t('apiTester.networkError', { error: e.message }))
|
|
||||||
setResponse({ error: e.message })
|
|
||||||
} finally {
|
|
||||||
setLoading(false)
|
|
||||||
}
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
directTest()
|
return (
|
||||||
}
|
<div className="flex flex-col lg:grid lg:grid-cols-12 gap-6 h-[calc(100vh-140px)]">
|
||||||
|
{/* Configuration Panel */}
|
||||||
useEffect(() => {
|
<div className={clsx(
|
||||||
setMessage((prev) => (prev === defaultMessageRef.current ? defaultMessage : prev))
|
"lg:col-span-3 flex flex-col transition-all duration-300 ease-in-out z-20",
|
||||||
defaultMessageRef.current = defaultMessage
|
configExpanded ? "h-auto" : "h-14 lg:h-full"
|
||||||
}, [defaultMessage])
|
)}>
|
||||||
|
<div className="bg-card border border-border rounded-xl flex flex-col h-full shadow-sm">
|
||||||
return (
|
{/* Mobile Toggle Header */}
|
||||||
<div className="flex flex-col lg:grid lg:grid-cols-12 gap-6 h-[calc(100vh-140px)]">
|
<button
|
||||||
{/* Configuration Panel */}
|
onClick={() => setConfigExpanded(!configExpanded)}
|
||||||
<div className={clsx(
|
className="lg:hidden flex items-center justify-between p-4 w-full bg-muted/20 hover:bg-muted/30 transition-colors"
|
||||||
"lg:col-span-3 flex flex-col transition-all duration-300 ease-in-out z-20",
|
>
|
||||||
configExpanded ? "h-auto" : "h-14 lg:h-full"
|
<div className="flex items-center gap-2.5 font-medium text-sm text-foreground">
|
||||||
)}>
|
<div className="p-1.5 rounded-md bg-transparent text-foreground">
|
||||||
<div className="bg-card border border-border rounded-xl flex flex-col h-full shadow-sm">
|
<Terminal className="w-4 h-4" />
|
||||||
{/* Mobile Toggle Header */}
|
|
||||||
<button
|
|
||||||
onClick={() => setConfigExpanded(!configExpanded)}
|
|
||||||
className="lg:hidden flex items-center justify-between p-4 w-full bg-muted/20 hover:bg-muted/30 transition-colors"
|
|
||||||
>
|
|
||||||
<div className="flex items-center gap-2.5 font-medium text-sm text-foreground">
|
|
||||||
<div className="p-1.5 rounded-md bg-transparent text-foreground">
|
|
||||||
<Terminal className="w-4 h-4" />
|
|
||||||
</div>
|
|
||||||
<span>{t('apiTester.config')}</span>
|
|
||||||
</div>
|
|
||||||
<div className={clsx("transition-transform duration-300 text-muted-foreground", configExpanded ? "rotate-180" : "")}>
|
|
||||||
<ChevronDown className="w-4 h-4" />
|
|
||||||
</div>
|
|
||||||
</button>
|
|
||||||
|
|
||||||
<div className={clsx(
|
|
||||||
"p-4 space-y-6 overflow-y-auto custom-scrollbar flex-1",
|
|
||||||
!configExpanded && "hidden lg:block"
|
|
||||||
)}>
|
|
||||||
<div className="space-y-3">
|
|
||||||
<label className="text-[11px] font-semibold text-muted-foreground uppercase tracking-wider ml-0.5">{t('apiTester.modelLabel')}</label>
|
|
||||||
<div className="grid grid-cols-1 gap-2">
|
|
||||||
{models.map(m => {
|
|
||||||
const Icon = m.icon
|
|
||||||
return (
|
|
||||||
<button
|
|
||||||
key={m.id}
|
|
||||||
onClick={() => setModel(m.id)}
|
|
||||||
className={clsx(
|
|
||||||
"group relative flex items-start gap-3 p-3 rounded-lg border text-left transition-all duration-200",
|
|
||||||
model === m.id
|
|
||||||
? "bg-secondary border-primary/50 shadow-sm"
|
|
||||||
: "bg-transparent border-transparent hover:bg-muted"
|
|
||||||
)}
|
|
||||||
>
|
|
||||||
<div className={clsx(
|
|
||||||
"p-1.5 rounded-md shrink-0 transition-colors",
|
|
||||||
model === m.id ? m.color : "text-muted-foreground group-hover:text-foreground"
|
|
||||||
)}>
|
|
||||||
<Icon className="w-4 h-4" />
|
|
||||||
</div>
|
|
||||||
<div className="min-w-0 flex-1">
|
|
||||||
<div className={clsx("font-medium text-sm", model === m.id ? "text-foreground" : "text-foreground/80")}>
|
|
||||||
{m.name}
|
|
||||||
</div>
|
|
||||||
<div className="text-[11px] text-muted-foreground mt-0.5">{m.desc}</div>
|
|
||||||
</div>
|
|
||||||
{model === m.id && (
|
|
||||||
<div className={clsx("absolute top-3 right-3", m.color)}>
|
|
||||||
<div className="w-1.5 h-1.5 rounded-full bg-current" />
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</button>
|
|
||||||
)
|
|
||||||
})}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="space-y-2">
|
|
||||||
<label className="text-[11px] font-semibold text-muted-foreground uppercase tracking-wider ml-0.5">{t('apiTester.accountStrategy')}</label>
|
|
||||||
<div className="relative">
|
|
||||||
<select
|
|
||||||
className="w-full h-10 pl-3 pr-8 bg-secondary border border-border rounded-lg text-sm appearance-none focus:outline-none focus:ring-1 focus:ring-ring focus:border-ring transition-all cursor-pointer hover:bg-muted"
|
|
||||||
value={selectedAccount}
|
|
||||||
onChange={e => setSelectedAccount(e.target.value)}
|
|
||||||
>
|
|
||||||
<option value="" className="bg-popover text-popover-foreground">{t('apiTester.randomRotation')}</option>
|
|
||||||
{accounts.map((acc, i) => (
|
|
||||||
<option key={i} value={acc.email || acc.mobile} className="bg-popover text-popover-foreground">
|
|
||||||
👤 {acc.email || acc.mobile}
|
|
||||||
</option>
|
|
||||||
))}
|
|
||||||
</select>
|
|
||||||
<ChevronDown className="absolute right-2.5 top-3 w-4 h-4 text-muted-foreground pointer-events-none" />
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="space-y-2">
|
|
||||||
<label className="text-[11px] font-semibold text-muted-foreground uppercase tracking-wider ml-0.5">{t('apiTester.apiKeyOptional')}</label>
|
|
||||||
<input
|
|
||||||
type="password"
|
|
||||||
className="w-full h-10 px-3 bg-muted/30 border border-border rounded-lg text-sm font-mono placeholder:text-muted-foreground/40 focus:outline-none focus:ring-1 focus:ring-ring focus:border-ring transition-all"
|
|
||||||
placeholder={config.keys?.[0] ? t('apiTester.apiKeyDefault', { suffix: config.keys[0].slice(-6) }) : t('apiTester.apiKeyPlaceholder')}
|
|
||||||
value={apiKey}
|
|
||||||
onChange={e => setApiKey(e.target.value)}
|
|
||||||
/>
|
|
||||||
</div>
|
</div>
|
||||||
|
<span>{t('apiTester.config')}</span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
<div className={clsx("transition-transform duration-300 text-muted-foreground", configExpanded ? "rotate-180" : "")}>
|
||||||
</div>
|
<ChevronDown className="w-4 h-4" />
|
||||||
|
|
||||||
{/* Chat Interface */}
|
|
||||||
<div className="lg:col-span-9 flex flex-col bg-card border border-border rounded-xl shadow-sm overflow-hidden min-h-0 flex-1 relative">
|
|
||||||
|
|
||||||
{/* Messages Area */}
|
|
||||||
<div className="flex-1 overflow-y-auto p-4 lg:p-6 space-y-8 custom-scrollbar scroll-smooth">
|
|
||||||
{/* User Message */}
|
|
||||||
<div className="flex gap-4 max-w-4xl mx-auto flex-row-reverse group">
|
|
||||||
<div className="w-8 h-8 rounded-lg bg-secondary flex items-center justify-center shrink-0 border border-border">
|
|
||||||
<User className="w-4 h-4 text-muted-foreground" />
|
|
||||||
</div>
|
|
||||||
<div className="space-y-1 max-w-[85%] lg:max-w-[75%]">
|
|
||||||
<div className="bg-primary text-primary-foreground rounded-2xl rounded-tr-sm px-5 py-3 text-sm leading-relaxed shadow-sm">
|
|
||||||
{message}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
|
</button>
|
||||||
|
|
||||||
{/* AI Response */}
|
<div className={clsx(
|
||||||
{(response || isStreaming) && (
|
"p-4 space-y-6 overflow-y-auto custom-scrollbar flex-1",
|
||||||
<div className="flex gap-4 max-w-4xl mx-auto animate-in fade-in slide-in-from-bottom-2 duration-300">
|
!configExpanded && "hidden lg:block"
|
||||||
<div className={clsx(
|
)}>
|
||||||
"w-8 h-8 rounded-lg flex items-center justify-center shrink-0 border border-border",
|
<div className="space-y-3">
|
||||||
response?.success !== false ? "bg-muted" : "bg-destructive/10 border-destructive/20"
|
<label className="text-[11px] font-semibold text-muted-foreground uppercase tracking-wider ml-0.5">{t('apiTester.modelLabel')}</label>
|
||||||
)}>
|
<div className="grid grid-cols-1 gap-2">
|
||||||
<Bot className={clsx("w-4 h-4", response?.success !== false ? "text-foreground" : "text-destructive")} />
|
{models.map(m => {
|
||||||
</div>
|
const Icon = m.icon
|
||||||
<div className="space-y-3 flex-1 min-w-0">
|
return (
|
||||||
<div className="flex items-center gap-2">
|
<button
|
||||||
<span className="font-semibold text-sm text-foreground">
|
key={m.id}
|
||||||
DeepSeek
|
onClick={() => setModel(m.id)}
|
||||||
</span>
|
className={clsx(
|
||||||
{response && (
|
"group relative flex items-start gap-3 p-3 rounded-lg border text-left transition-all duration-200",
|
||||||
<span className={clsx(
|
model === m.id
|
||||||
"text-[10px] px-1.5 py-0.5 rounded-sm border uppercase font-medium tracking-wider",
|
? "bg-secondary border-primary/50 shadow-sm"
|
||||||
response.success ? "border-emerald-500/20 text-emerald-500 bg-emerald-500/10" : "border-destructive/20 text-destructive bg-destructive/10"
|
: "bg-transparent border-transparent hover:bg-muted"
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<div className={clsx(
|
||||||
|
"p-1.5 rounded-md shrink-0 transition-colors",
|
||||||
|
model === m.id ? m.color : "text-muted-foreground group-hover:text-foreground"
|
||||||
)}>
|
)}>
|
||||||
{response.status_code || t('apiTester.statusError')}
|
<Icon className="w-4 h-4" />
|
||||||
</span>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{(streamingThinking || response?.response?.thinking) && (
|
|
||||||
<div className="text-xs bg-secondary/50 border border-border rounded-lg p-3 space-y-1.5">
|
|
||||||
<div className="flex items-center gap-1.5 text-muted-foreground">
|
|
||||||
<Zap className="w-3.5 h-3.5" />
|
|
||||||
<span className="font-medium">{t('apiTester.reasoningTrace')}</span>
|
|
||||||
</div>
|
</div>
|
||||||
<div className="whitespace-pre-wrap leading-relaxed text-muted-foreground font-mono text-[11px] max-h-60 overflow-y-auto custom-scrollbar pl-5 border-l-2 border-border/50">
|
<div className="min-w-0 flex-1">
|
||||||
{streamingThinking || response?.response?.thinking}
|
<div className={clsx("font-medium text-sm", model === m.id ? "text-foreground" : "text-foreground/80")}>
|
||||||
|
{m.name}
|
||||||
|
</div>
|
||||||
|
<div className="text-[11px] text-muted-foreground mt-0.5">{m.desc}</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
{model === m.id && (
|
||||||
)}
|
<div className={clsx("absolute top-3 right-3", m.color)}>
|
||||||
|
<div className="w-1.5 h-1.5 rounded-full bg-current" />
|
||||||
<div className="text-sm leading-7 text-foreground whitespace-pre-wrap">
|
</div>
|
||||||
{!selectedAccount ? (
|
)}
|
||||||
streamingContent || (response?.error && <span className="text-destructive font-medium">{response.error}</span>)
|
</button>
|
||||||
) : (
|
)
|
||||||
response?.response?.message || <span className="text-muted-foreground italic">{t('apiTester.generating')}</span>
|
})}
|
||||||
)}
|
|
||||||
{isStreaming && <span className="inline-block w-1.5 h-4 bg-primary ml-1 align-middle animate-pulse" />}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Input Area */}
|
|
||||||
<div className="p-4 lg:p-6 border-t border-border bg-card">
|
|
||||||
<div className="max-w-4xl mx-auto relative group">
|
|
||||||
<textarea
|
|
||||||
className="w-full bg-[#09090b] border border-border rounded-xl pl-4 pr-12 py-3 text-sm focus:ring-2 focus:ring-primary/20 focus:border-primary transition-all resize-none custom-scrollbar placeholder:text-muted-foreground/50 text-foreground shadow-inner"
|
|
||||||
placeholder={t('apiTester.enterMessage')}
|
|
||||||
rows={1}
|
|
||||||
style={{ minHeight: '52px' }}
|
|
||||||
value={message}
|
|
||||||
onChange={e => setMessage(e.target.value)}
|
|
||||||
onKeyDown={e => {
|
|
||||||
if (e.key === 'Enter' && !e.shiftKey) {
|
|
||||||
e.preventDefault()
|
|
||||||
sendTest()
|
|
||||||
}
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
<div className="absolute right-2 bottom-2">
|
|
||||||
{loading && isStreaming ? (
|
|
||||||
<button
|
|
||||||
onClick={stopGeneration}
|
|
||||||
className="p-2 text-muted-foreground hover:text-destructive transition-colors"
|
|
||||||
>
|
|
||||||
<Square className="w-4 h-4 fill-current" />
|
|
||||||
</button>
|
|
||||||
) : (
|
|
||||||
<button
|
|
||||||
onClick={sendTest}
|
|
||||||
disabled={loading || !message.trim()}
|
|
||||||
className="p-2 text-primary hover:text-primary/80 transition-colors disabled:opacity-50 disabled:cursor-not-allowed"
|
|
||||||
>
|
|
||||||
{loading ? <Loader2 className="w-4 h-4 animate-spin" /> : <Send className="w-4 h-4" />}
|
|
||||||
</button>
|
|
||||||
)}
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div className="max-w-4xl mx-auto mt-3 flex justify-center">
|
|
||||||
<span className="text-[10px] text-muted-foreground/40 font-medium">{t('apiTester.adminConsoleLabel')}</span>
|
<div className="space-y-2">
|
||||||
|
<label className="text-[11px] font-semibold text-muted-foreground uppercase tracking-wider ml-0.5">{t('apiTester.streamMode')}</label>
|
||||||
|
<button
|
||||||
|
onClick={() => setStreamingMode(!streamingMode)}
|
||||||
|
className={clsx(
|
||||||
|
"w-full flex items-center justify-between px-3 py-2 rounded-lg border transition-all duration-200",
|
||||||
|
streamingMode
|
||||||
|
? "bg-primary/10 border-primary/50 text-foreground"
|
||||||
|
: "bg-background border-border text-muted-foreground hover:bg-muted/50"
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<div className={clsx("p-1.5 rounded-md", streamingMode ? "bg-primary text-primary-foreground" : "bg-muted text-muted-foreground")}>
|
||||||
|
<Zap className="w-4 h-4" />
|
||||||
|
</div>
|
||||||
|
<span className="text-sm font-medium">{t('apiTester.streamMode')}</span>
|
||||||
|
</div>
|
||||||
|
{streamingMode ? <ToggleRight className="w-5 h-5 text-primary" /> : <ToggleLeft className="w-5 h-5 text-muted-foreground" />}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="space-y-2">
|
||||||
|
<label className="text-[11px] font-semibold text-muted-foreground uppercase tracking-wider ml-0.5">{t('apiTester.accountSelector')}</label>
|
||||||
|
<div className="relative">
|
||||||
|
<select
|
||||||
|
className="w-full h-10 pl-3 pr-8 bg-secondary border border-border rounded-lg text-sm appearance-none focus:outline-none focus:ring-1 focus:ring-ring focus:border-ring transition-all cursor-pointer hover:bg-muted"
|
||||||
|
value={selectedAccount}
|
||||||
|
onChange={e => setSelectedAccount(e.target.value)}
|
||||||
|
>
|
||||||
|
<option value="" className="bg-popover text-popover-foreground">{t('apiTester.autoRandom')}</option>
|
||||||
|
{accounts.map((acc, i) => (
|
||||||
|
<option key={i} value={acc.email || acc.mobile} className="bg-popover text-popover-foreground">
|
||||||
|
👤 {acc.email || acc.mobile}
|
||||||
|
</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
<ChevronDown className="absolute right-2.5 top-3 w-4 h-4 text-muted-foreground pointer-events-none" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="space-y-2">
|
||||||
|
<label className="text-[11px] font-semibold text-muted-foreground uppercase tracking-wider ml-0.5">{t('apiTester.apiKeyOptional')}</label>
|
||||||
|
<input
|
||||||
|
type="password"
|
||||||
|
className="w-full h-10 px-3 bg-muted/30 border border-border rounded-lg text-sm font-mono placeholder:text-muted-foreground/40 focus:outline-none focus:ring-1 focus:ring-ring focus:border-ring transition-all"
|
||||||
|
placeholder={config.keys?.[0] ? t('apiTester.apiKeyDefault', { suffix: config.keys[0].slice(-6) }) : t('apiTester.apiKeyPlaceholder')}
|
||||||
|
value={apiKey}
|
||||||
|
onChange={e => setApiKey(e.target.value)}
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
)
|
|
||||||
|
{/* Chat Interface */}
|
||||||
|
<div className="lg:col-span-9 flex flex-col bg-card border border-border rounded-xl shadow-sm overflow-hidden min-h-0 flex-1 relative">
|
||||||
|
|
||||||
|
{/* Messages Area */}
|
||||||
|
<div className="flex-1 overflow-y-auto p-4 lg:p-6 space-y-8 custom-scrollbar scroll-smooth">
|
||||||
|
{/* User Message */}
|
||||||
|
<div className="flex gap-4 max-w-4xl mx-auto flex-row-reverse group">
|
||||||
|
<div className="w-8 h-8 rounded-lg bg-secondary flex items-center justify-center shrink-0 border border-border">
|
||||||
|
<User className="w-4 h-4 text-muted-foreground" />
|
||||||
|
</div>
|
||||||
|
<div className="space-y-1 max-w-[85%] lg:max-w-[75%]">
|
||||||
|
<div className="bg-primary text-primary-foreground rounded-2xl rounded-tr-sm px-5 py-3 text-sm leading-relaxed shadow-sm">
|
||||||
|
{message}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* AI Response */}
|
||||||
|
{(response || isStreaming) && (
|
||||||
|
<div className="flex gap-4 max-w-4xl mx-auto animate-in fade-in slide-in-from-bottom-2 duration-300">
|
||||||
|
<div className={clsx(
|
||||||
|
"w-8 h-8 rounded-lg flex items-center justify-center shrink-0 border border-border",
|
||||||
|
response?.success !== false ? "bg-muted" : "bg-destructive/10 border-destructive/20"
|
||||||
|
)}>
|
||||||
|
<Bot className={clsx("w-4 h-4", response?.success !== false ? "text-foreground" : "text-destructive")} />
|
||||||
|
</div>
|
||||||
|
<div className="space-y-3 flex-1 min-w-0">
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<span className="font-semibold text-sm text-foreground">
|
||||||
|
DeepSeek
|
||||||
|
</span>
|
||||||
|
{response && (
|
||||||
|
<span className={clsx(
|
||||||
|
"text-[10px] px-1.5 py-0.5 rounded-sm border uppercase font-medium tracking-wider",
|
||||||
|
response.success ? "border-emerald-500/20 text-emerald-500 bg-emerald-500/10" : "border-destructive/20 text-destructive bg-destructive/10"
|
||||||
|
)}>
|
||||||
|
{response.status_code || t('apiTester.statusError')}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{(streamingThinking || response?.choices?.[0]?.message?.reasoning_content) && (
|
||||||
|
<div className="text-xs bg-secondary/50 border border-border rounded-lg p-3 space-y-1.5">
|
||||||
|
<div className="flex items-center gap-1.5 text-muted-foreground">
|
||||||
|
<Zap className="w-3.5 h-3.5" />
|
||||||
|
<span className="font-medium">{t('apiTester.reasoningTrace')}</span>
|
||||||
|
</div>
|
||||||
|
<div className="whitespace-pre-wrap leading-relaxed text-muted-foreground font-mono text-[11px] max-h-60 overflow-y-auto custom-scrollbar pl-5 border-l-2 border-border/50">
|
||||||
|
{streamingThinking || response?.choices?.[0]?.message?.reasoning_content}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<div className="text-sm leading-7 text-foreground whitespace-pre-wrap">
|
||||||
|
{streamingContent || response?.choices?.[0]?.message?.content || (response?.error && <span className="text-destructive font-medium">{response.error}</span>) || (loading && <span className="text-muted-foreground italic">{t('apiTester.generating')}</span>)}
|
||||||
|
{isStreaming && <span className="inline-block w-1.5 h-4 bg-primary ml-1 align-middle animate-pulse" />}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Input Area */}
|
||||||
|
<div className="p-4 lg:p-6 border-t border-border bg-card">
|
||||||
|
<div className="max-w-4xl mx-auto relative group">
|
||||||
|
<textarea
|
||||||
|
className="w-full bg-[#09090b] border border-border rounded-xl pl-4 pr-12 py-3 text-sm focus:ring-2 focus:ring-primary/20 focus:border-primary transition-all resize-none custom-scrollbar placeholder:text-muted-foreground/50 text-foreground shadow-inner"
|
||||||
|
placeholder={t('apiTester.enterMessage')}
|
||||||
|
rows={1}
|
||||||
|
style={{ minHeight: '52px' }}
|
||||||
|
value={message}
|
||||||
|
onChange={e => setMessage(e.target.value)}
|
||||||
|
onKeyDown={e => {
|
||||||
|
if (e.key === 'Enter' && !e.shiftKey) {
|
||||||
|
e.preventDefault()
|
||||||
|
runTest()
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
<div className="absolute right-2 bottom-2">
|
||||||
|
{loading && isStreaming ? (
|
||||||
|
<button
|
||||||
|
onClick={stopGeneration}
|
||||||
|
className="p-2 text-muted-foreground hover:text-destructive transition-colors"
|
||||||
|
>
|
||||||
|
<Square className="w-4 h-4 fill-current" />
|
||||||
|
</button>
|
||||||
|
) : (
|
||||||
|
<button
|
||||||
|
onClick={runTest}
|
||||||
|
disabled={loading || !message.trim()}
|
||||||
|
className="p-2 text-primary hover:text-primary/80 transition-colors disabled:opacity-50 disabled:cursor-not-allowed"
|
||||||
|
>
|
||||||
|
{loading ? <Loader2 className="w-4 h-4 animate-spin" /> : <Send className="w-4 h-4" />}
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="max-w-4xl mx-auto mt-3 flex justify-center">
|
||||||
|
<span className="text-[10px] text-muted-foreground/40 font-medium">{t('apiTester.adminConsoleLabel')}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -87,8 +87,8 @@ export default function Login({ onLogin, onMessage }) {
|
|||||||
checked={remember}
|
checked={remember}
|
||||||
onChange={e => setRemember(e.target.checked)}
|
onChange={e => setRemember(e.target.checked)}
|
||||||
/>
|
/>
|
||||||
<div className="w-4.5 h-4.5 bg-secondary border border-border rounded-md peer-checked:bg-primary peer-checked:border-primary transition-all shadow-sm"></div>
|
<div className="w-[18px] h-[18px] bg-secondary border border-border rounded-md peer-checked:bg-primary peer-checked:border-primary transition-all shadow-sm"></div>
|
||||||
<Check className="absolute w-3 h-3 text-primary-foreground opacity-0 peer-checked:opacity-100 left-0.5 transition-opacity" />
|
<Check className="absolute inset-0 m-auto w-3 h-3 text-primary-foreground opacity-0 peer-checked:opacity-100 transition-opacity stroke-[3]" />
|
||||||
</div>
|
</div>
|
||||||
<span className="text-xs font-medium text-muted-foreground group-hover:text-foreground transition-colors">{t('login.rememberSession')}</span>
|
<span className="text-xs font-medium text-muted-foreground group-hover:text-foreground transition-colors">{t('login.rememberSession')}</span>
|
||||||
</label>
|
</label>
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
import { useState, useEffect } from 'react'
|
import { useState, useEffect, useCallback } from 'react'
|
||||||
import { Cloud, ArrowRight, ExternalLink, Info, CheckCircle2, XCircle } from 'lucide-react'
|
import { Cloud, ArrowRight, ExternalLink, Info, CheckCircle2, XCircle, RefreshCw } from 'lucide-react'
|
||||||
|
import clsx from 'clsx'
|
||||||
import { useI18n } from '../i18n'
|
import { useI18n } from '../i18n'
|
||||||
|
|
||||||
export default function VercelSync({ onMessage, authFetch }) {
|
export default function VercelSync({ onMessage, authFetch }) {
|
||||||
@@ -10,9 +11,22 @@ export default function VercelSync({ onMessage, authFetch }) {
|
|||||||
const [loading, setLoading] = useState(false)
|
const [loading, setLoading] = useState(false)
|
||||||
const [result, setResult] = useState(null)
|
const [result, setResult] = useState(null)
|
||||||
const [preconfig, setPreconfig] = useState(null)
|
const [preconfig, setPreconfig] = useState(null)
|
||||||
|
const [syncStatus, setSyncStatus] = useState(null)
|
||||||
|
|
||||||
const apiFetch = authFetch || fetch
|
const apiFetch = authFetch || fetch
|
||||||
|
|
||||||
|
const fetchSyncStatus = useCallback(async () => {
|
||||||
|
try {
|
||||||
|
const res = await apiFetch('/admin/vercel/status')
|
||||||
|
if (res.ok) {
|
||||||
|
const data = await res.json()
|
||||||
|
setSyncStatus(data)
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
console.error('Failed to fetch sync status:', e)
|
||||||
|
}
|
||||||
|
}, [apiFetch])
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const loadPreconfig = async () => {
|
const loadPreconfig = async () => {
|
||||||
try {
|
try {
|
||||||
@@ -28,7 +42,11 @@ export default function VercelSync({ onMessage, authFetch }) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
loadPreconfig()
|
loadPreconfig()
|
||||||
}, [])
|
fetchSyncStatus()
|
||||||
|
// Poll every 15s to detect config changes
|
||||||
|
const interval = setInterval(fetchSyncStatus, 15000)
|
||||||
|
return () => clearInterval(interval)
|
||||||
|
}, [fetchSyncStatus])
|
||||||
|
|
||||||
const handleSync = async () => {
|
const handleSync = async () => {
|
||||||
const tokenToUse = preconfig?.has_token && !vercelToken ? '__USE_PRECONFIG__' : vercelToken
|
const tokenToUse = preconfig?.has_token && !vercelToken ? '__USE_PRECONFIG__' : vercelToken
|
||||||
@@ -58,6 +76,7 @@ export default function VercelSync({ onMessage, authFetch }) {
|
|||||||
if (res.ok) {
|
if (res.ok) {
|
||||||
setResult({ ...data, success: true })
|
setResult({ ...data, success: true })
|
||||||
onMessage('success', data.message)
|
onMessage('success', data.message)
|
||||||
|
fetchSyncStatus()
|
||||||
} else {
|
} else {
|
||||||
setResult({ ...data, success: false })
|
setResult({ ...data, success: false })
|
||||||
onMessage('error', data.detail || t('vercel.syncFailed'))
|
onMessage('error', data.detail || t('vercel.syncFailed'))
|
||||||
@@ -74,13 +93,41 @@ export default function VercelSync({ onMessage, authFetch }) {
|
|||||||
{/* Configuration Form */}
|
{/* Configuration Form */}
|
||||||
<div className="bg-card border border-border rounded-xl shadow-sm p-6 space-y-6">
|
<div className="bg-card border border-border rounded-xl shadow-sm p-6 space-y-6">
|
||||||
<div className="border-b border-border pb-6">
|
<div className="border-b border-border pb-6">
|
||||||
<h2 className="text-xl font-semibold flex items-center gap-2">
|
<div className="flex items-center justify-between">
|
||||||
<Cloud className="w-6 h-6 text-primary" />
|
<h2 className="text-xl font-semibold flex items-center gap-2">
|
||||||
{t('vercel.title')}
|
<Cloud className="w-6 h-6 text-primary" />
|
||||||
</h2>
|
{t('vercel.title')}
|
||||||
|
</h2>
|
||||||
|
{syncStatus && (
|
||||||
|
<div className={clsx(
|
||||||
|
"flex items-center gap-1.5 text-xs font-semibold px-2.5 py-1 rounded-full border transition-colors",
|
||||||
|
syncStatus.synced
|
||||||
|
? "text-emerald-500 bg-emerald-500/10 border-emerald-500/20"
|
||||||
|
: syncStatus.has_synced_before
|
||||||
|
? "text-amber-500 bg-amber-500/10 border-amber-500/20"
|
||||||
|
: "text-muted-foreground bg-muted/50 border-border"
|
||||||
|
)}>
|
||||||
|
<span className={clsx(
|
||||||
|
"w-1.5 h-1.5 rounded-full",
|
||||||
|
syncStatus.synced ? "bg-emerald-500" : syncStatus.has_synced_before ? "bg-amber-500 animate-pulse" : "bg-muted-foreground"
|
||||||
|
)} />
|
||||||
|
{syncStatus.synced
|
||||||
|
? t('vercel.statusSynced')
|
||||||
|
: syncStatus.has_synced_before
|
||||||
|
? t('vercel.statusNotSynced')
|
||||||
|
: t('vercel.statusNeverSynced')}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
<p className="text-muted-foreground text-sm mt-1">
|
<p className="text-muted-foreground text-sm mt-1">
|
||||||
{t('vercel.description')}
|
{t('vercel.description')}
|
||||||
</p>
|
</p>
|
||||||
|
{syncStatus?.last_sync_time && (
|
||||||
|
<p className="text-xs text-muted-foreground/60 mt-1.5 flex items-center gap-1">
|
||||||
|
<RefreshCw className="w-3 h-3" />
|
||||||
|
{t('vercel.lastSyncTime', { time: new Date(syncStatus.last_sync_time * 1000).toLocaleString() })}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="space-y-4">
|
<div className="space-y-4">
|
||||||
|
|||||||
@@ -122,7 +122,8 @@
|
|||||||
"passwordLabel": "Password",
|
"passwordLabel": "Password",
|
||||||
"passwordPlaceholder": "Account password",
|
"passwordPlaceholder": "Account password",
|
||||||
"addAccountLoading": "Adding...",
|
"addAccountLoading": "Adding...",
|
||||||
"addAccountAction": "Add account"
|
"addAccountAction": "Add account",
|
||||||
|
"pageInfo": "Page {current}/{total}, {count} accounts total"
|
||||||
},
|
},
|
||||||
"apiTester": {
|
"apiTester": {
|
||||||
"defaultMessage": "Hello, please introduce yourself in one sentence.",
|
"defaultMessage": "Hello, please introduce yourself in one sentence.",
|
||||||
@@ -138,8 +139,9 @@
|
|||||||
"testSuccess": "{account}: Test successful ({time}ms)",
|
"testSuccess": "{account}: Test successful ({time}ms)",
|
||||||
"config": "Configuration",
|
"config": "Configuration",
|
||||||
"modelLabel": "Model",
|
"modelLabel": "Model",
|
||||||
"accountStrategy": "Account Strategy",
|
"streamMode": "Streaming",
|
||||||
"randomRotation": "🎲 Random rotation (streaming preview supported)",
|
"accountSelector": "Account",
|
||||||
|
"autoRandom": "🤖 Auto / Random",
|
||||||
"apiKeyOptional": "API Key (optional)",
|
"apiKeyOptional": "API Key (optional)",
|
||||||
"apiKeyDefault": "Default: ...{suffix}",
|
"apiKeyDefault": "Default: ...{suffix}",
|
||||||
"apiKeyPlaceholder": "Enter a custom key",
|
"apiKeyPlaceholder": "Enter a custom key",
|
||||||
@@ -220,6 +222,10 @@
|
|||||||
"syncSucceeded": "Sync succeeded",
|
"syncSucceeded": "Sync succeeded",
|
||||||
"syncFailedLabel": "Sync failed",
|
"syncFailedLabel": "Sync failed",
|
||||||
"openDeployment": "Open deployment",
|
"openDeployment": "Open deployment",
|
||||||
|
"statusSynced": "Synced",
|
||||||
|
"statusNotSynced": "Not synced",
|
||||||
|
"statusNeverSynced": "Never synced",
|
||||||
|
"lastSyncTime": "Last sync: {time}",
|
||||||
"howItWorks": "How it works",
|
"howItWorks": "How it works",
|
||||||
"steps": {
|
"steps": {
|
||||||
"one": "The current configuration (keys and accounts) is exported as JSON.",
|
"one": "The current configuration (keys and accounts) is exported as JSON.",
|
||||||
@@ -228,4 +234,4 @@
|
|||||||
"four": "Trigger a redeploy to apply the updated environment variables."
|
"four": "Trigger a redeploy to apply the updated environment variables."
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -122,7 +122,8 @@
|
|||||||
"passwordLabel": "密码",
|
"passwordLabel": "密码",
|
||||||
"passwordPlaceholder": "账号密码",
|
"passwordPlaceholder": "账号密码",
|
||||||
"addAccountLoading": "添加中...",
|
"addAccountLoading": "添加中...",
|
||||||
"addAccountAction": "添加账号"
|
"addAccountAction": "添加账号",
|
||||||
|
"pageInfo": "第 {current}/{total} 页,共 {count} 个账号"
|
||||||
},
|
},
|
||||||
"apiTester": {
|
"apiTester": {
|
||||||
"defaultMessage": "你好,请用一句话介绍你自己。",
|
"defaultMessage": "你好,请用一句话介绍你自己。",
|
||||||
@@ -138,8 +139,9 @@
|
|||||||
"testSuccess": "{account}: 测试成功 ({time}ms)",
|
"testSuccess": "{account}: 测试成功 ({time}ms)",
|
||||||
"config": "配置",
|
"config": "配置",
|
||||||
"modelLabel": "模型",
|
"modelLabel": "模型",
|
||||||
"accountStrategy": "账号策略",
|
"streamMode": "流式模式",
|
||||||
"randomRotation": "🎲 随机切换 (支持流式预览)",
|
"accountSelector": "选择账号",
|
||||||
|
"autoRandom": "🤖 自动 / 随机",
|
||||||
"apiKeyOptional": "API 密钥 (可选)",
|
"apiKeyOptional": "API 密钥 (可选)",
|
||||||
"apiKeyDefault": "默认: ...{suffix}",
|
"apiKeyDefault": "默认: ...{suffix}",
|
||||||
"apiKeyPlaceholder": "输入自定义密钥",
|
"apiKeyPlaceholder": "输入自定义密钥",
|
||||||
@@ -220,6 +222,10 @@
|
|||||||
"syncSucceeded": "同步成功",
|
"syncSucceeded": "同步成功",
|
||||||
"syncFailedLabel": "同步失败",
|
"syncFailedLabel": "同步失败",
|
||||||
"openDeployment": "访问部署地址",
|
"openDeployment": "访问部署地址",
|
||||||
|
"statusSynced": "已同步",
|
||||||
|
"statusNotSynced": "未同步",
|
||||||
|
"statusNeverSynced": "从未同步",
|
||||||
|
"lastSyncTime": "上次同步: {time}",
|
||||||
"howItWorks": "工作原理",
|
"howItWorks": "工作原理",
|
||||||
"steps": {
|
"steps": {
|
||||||
"one": "当前配置 (密钥和账号) 被导出为 JSON 字符串。",
|
"one": "当前配置 (密钥和账号) 被导出为 JSON 字符串。",
|
||||||
@@ -228,4 +234,4 @@
|
|||||||
"four": "触发重新部署以应用新的环境变量。"
|
"four": "触发重新部署以应用新的环境变量。"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
Reference in New Issue
Block a user