mirror of
https://github.com/CJackHwang/ds2api.git
synced 2026-05-05 00:45:29 +08:00
feat: Display Vercel configuration sync status and last sync time, and clear API tester streaming output before new requests.
This commit is contained in:
@@ -2,8 +2,10 @@
|
||||
"""Admin Vercel 模块 - Vercel 同步和部署"""
|
||||
import asyncio
|
||||
import base64
|
||||
import hashlib
|
||||
import json
|
||||
import os
|
||||
import time as _time
|
||||
|
||||
import httpx
|
||||
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", "")
|
||||
|
||||
|
||||
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)
|
||||
# ----------------------------------------------------------------------
|
||||
@@ -228,6 +243,10 @@ async def sync_to_vercel(request: Request, _: bool = Depends(verify_admin)):
|
||||
|
||||
if deploy_resp.status_code in [200, 201]:
|
||||
deploy_data = deploy_resp.json()
|
||||
# 记录同步哈希和时间
|
||||
CONFIG["_vercel_sync_hash"] = _compute_config_hash()
|
||||
CONFIG["_vercel_sync_time"] = int(_time.time())
|
||||
save_config(CONFIG)
|
||||
result = {
|
||||
"success": True,
|
||||
"message": "配置已同步,正在重新部署...",
|
||||
@@ -240,6 +259,10 @@ async def sync_to_vercel(request: Request, _: bool = Depends(verify_admin)):
|
||||
result["saved_credentials"] = saved_credentials
|
||||
return JSONResponse(content=result)
|
||||
|
||||
# 环境变量已更新,但无法自动触发重新部署
|
||||
CONFIG["_vercel_sync_hash"] = _compute_config_hash()
|
||||
CONFIG["_vercel_sync_time"] = int(_time.time())
|
||||
save_config(CONFIG)
|
||||
result = {
|
||||
"success": True,
|
||||
"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))
|
||||
|
||||
|
||||
# ----------------------------------------------------------------------
|
||||
# 同步状态查询
|
||||
# ----------------------------------------------------------------------
|
||||
@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),
|
||||
})
|
||||
|
||||
|
||||
# ----------------------------------------------------------------------
|
||||
# 导出配置
|
||||
# ----------------------------------------------------------------------
|
||||
|
||||
@@ -1 +0,0 @@
|
||||
|
||||
@@ -154,6 +154,10 @@ export default function ApiTester({ config, onMessage, authFetch }) {
|
||||
}
|
||||
|
||||
const sendTest = async () => {
|
||||
// 清除上次的流式/思考内容,防止残留
|
||||
setStreamingContent('')
|
||||
setStreamingThinking('')
|
||||
|
||||
if (selectedAccount) {
|
||||
setLoading(true)
|
||||
setResponse(null)
|
||||
@@ -209,12 +213,12 @@ export default function ApiTester({ config, onMessage, authFetch }) {
|
||||
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 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>
|
||||
@@ -365,9 +369,9 @@ export default function ApiTester({ config, onMessage, authFetch }) {
|
||||
{/* 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')}
|
||||
<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}
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { useState, useEffect } from 'react'
|
||||
import { Cloud, ArrowRight, ExternalLink, Info, CheckCircle2, XCircle } from 'lucide-react'
|
||||
import { useState, useEffect, useCallback } from 'react'
|
||||
import { Cloud, ArrowRight, ExternalLink, Info, CheckCircle2, XCircle, RefreshCw } from 'lucide-react'
|
||||
import clsx from 'clsx'
|
||||
import { useI18n } from '../i18n'
|
||||
|
||||
export default function VercelSync({ onMessage, authFetch }) {
|
||||
@@ -10,9 +11,22 @@ export default function VercelSync({ onMessage, authFetch }) {
|
||||
const [loading, setLoading] = useState(false)
|
||||
const [result, setResult] = useState(null)
|
||||
const [preconfig, setPreconfig] = useState(null)
|
||||
const [syncStatus, setSyncStatus] = useState(null)
|
||||
|
||||
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(() => {
|
||||
const loadPreconfig = async () => {
|
||||
try {
|
||||
@@ -28,7 +42,11 @@ export default function VercelSync({ onMessage, authFetch }) {
|
||||
}
|
||||
}
|
||||
loadPreconfig()
|
||||
}, [])
|
||||
fetchSyncStatus()
|
||||
// Poll every 15s to detect config changes
|
||||
const interval = setInterval(fetchSyncStatus, 15000)
|
||||
return () => clearInterval(interval)
|
||||
}, [fetchSyncStatus])
|
||||
|
||||
const handleSync = async () => {
|
||||
const tokenToUse = preconfig?.has_token && !vercelToken ? '__USE_PRECONFIG__' : vercelToken
|
||||
@@ -58,6 +76,7 @@ export default function VercelSync({ onMessage, authFetch }) {
|
||||
if (res.ok) {
|
||||
setResult({ ...data, success: true })
|
||||
onMessage('success', data.message)
|
||||
fetchSyncStatus()
|
||||
} else {
|
||||
setResult({ ...data, success: false })
|
||||
onMessage('error', data.detail || t('vercel.syncFailed'))
|
||||
@@ -74,13 +93,41 @@ export default function VercelSync({ onMessage, authFetch }) {
|
||||
{/* Configuration Form */}
|
||||
<div className="bg-card border border-border rounded-xl shadow-sm p-6 space-y-6">
|
||||
<div className="border-b border-border pb-6">
|
||||
<h2 className="text-xl font-semibold flex items-center gap-2">
|
||||
<Cloud className="w-6 h-6 text-primary" />
|
||||
{t('vercel.title')}
|
||||
</h2>
|
||||
<div className="flex items-center justify-between">
|
||||
<h2 className="text-xl font-semibold flex items-center gap-2">
|
||||
<Cloud className="w-6 h-6 text-primary" />
|
||||
{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">
|
||||
{t('vercel.description')}
|
||||
</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 className="space-y-4">
|
||||
|
||||
@@ -221,6 +221,10 @@
|
||||
"syncSucceeded": "Sync succeeded",
|
||||
"syncFailedLabel": "Sync failed",
|
||||
"openDeployment": "Open deployment",
|
||||
"statusSynced": "Synced",
|
||||
"statusNotSynced": "Not synced",
|
||||
"statusNeverSynced": "Never synced",
|
||||
"lastSyncTime": "Last sync: {time}",
|
||||
"howItWorks": "How it works",
|
||||
"steps": {
|
||||
"one": "The current configuration (keys and accounts) is exported as JSON.",
|
||||
@@ -229,4 +233,4 @@
|
||||
"four": "Trigger a redeploy to apply the updated environment variables."
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -221,6 +221,10 @@
|
||||
"syncSucceeded": "同步成功",
|
||||
"syncFailedLabel": "同步失败",
|
||||
"openDeployment": "访问部署地址",
|
||||
"statusSynced": "已同步",
|
||||
"statusNotSynced": "未同步",
|
||||
"statusNeverSynced": "从未同步",
|
||||
"lastSyncTime": "上次同步: {time}",
|
||||
"howItWorks": "工作原理",
|
||||
"steps": {
|
||||
"one": "当前配置 (密钥和账号) 被导出为 JSON 字符串。",
|
||||
@@ -229,4 +233,4 @@
|
||||
"four": "触发重新部署以应用新的环境变量。"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user