diff --git a/vercel.json b/vercel.json index 2e68a94..bad49e0 100644 --- a/vercel.json +++ b/vercel.json @@ -38,6 +38,18 @@ "source": "/admin/config", "destination": "/api/index" }, + { + "source": "/admin/config/(.*)", + "destination": "/api/index" + }, + { + "source": "/admin/settings", + "destination": "/api/index" + }, + { + "source": "/admin/settings/(.*)", + "destination": "/api/index" + }, { "source": "/admin/keys(.*)", "destination": "/api/index" diff --git a/webui/src/App.jsx b/webui/src/App.jsx index 3f6ad27..2c3f099 100644 --- a/webui/src/App.jsx +++ b/webui/src/App.jsx @@ -1,4 +1,4 @@ -import { useState, useEffect } from 'react' +import { useState, useEffect, useCallback, useMemo } from 'react' import { Routes, Route, @@ -29,12 +29,12 @@ import Login from './components/Login' import LandingPage from './components/LandingPage' import LanguageToggle from './components/LanguageToggle' import { useI18n } from './i18n' +import { detectRuntimeEnv } from './utils/runtimeEnv' -function Dashboard({ token, onLogout, config, fetchConfig, showMessage, message, onForceLogout }) { +function Dashboard({ token, onLogout, config, fetchConfig, showMessage, message, onForceLogout, isVercel }) { const { t } = useI18n() const [activeTab, setActiveTab] = useState('accounts') const [sidebarOpen, setSidebarOpen] = useState(false) - const [loading, setLoading] = useState(false) const navItems = [ { id: 'accounts', label: t('nav.accounts.label'), icon: Users, description: t('nav.accounts.desc') }, @@ -44,7 +44,7 @@ function Dashboard({ token, onLogout, config, fetchConfig, showMessage, message, { id: 'settings', label: t('nav.settings.label'), icon: SettingsIcon, description: t('nav.settings.desc') }, ] - const authFetch = async (url, options = {}) => { + const authFetch = useCallback(async (url, options = {}) => { const headers = { ...options.headers, 'Authorization': `Bearer ${token}` @@ -56,7 +56,7 @@ function Dashboard({ token, onLogout, config, fetchConfig, showMessage, message, throw new Error(t('auth.expired')) } return res - } + }, [onLogout, t, token]) const renderTab = () => { switch (activeTab) { @@ -67,9 +67,9 @@ function Dashboard({ token, onLogout, config, fetchConfig, showMessage, message, case 'import': return case 'vercel': - return + return case 'settings': - return + return default: return null } @@ -213,13 +213,27 @@ export default function App() { const navigate = useNavigate() const location = useLocation() const [config, setConfig] = useState({ keys: [], accounts: [] }) - const [loading, setLoading] = useState(true) const [message, setMessage] = useState(null) const [token, setToken] = useState(null) const [authChecking, setAuthChecking] = useState(true) const isProduction = import.meta.env.MODE === 'production' const isAdminRoute = location.pathname.startsWith('/admin') || isProduction + const runtimeEnv = useMemo(() => detectRuntimeEnv(), []) + const isVercel = runtimeEnv.isVercel + + const showMessage = useCallback((type, text) => { + setMessage({ type, text }) + setTimeout(() => setMessage(null), 5000) + }, []) + + const handleLogout = useCallback(() => { + setToken(null) + localStorage.removeItem('ds2api_token') + localStorage.removeItem('ds2api_token_expires') + sessionStorage.removeItem('ds2api_token') + sessionStorage.removeItem('ds2api_token_expires') + }, []) useEffect(() => { // Only check auth status on admin routes. @@ -249,12 +263,11 @@ export default function App() { setAuthChecking(false) } checkAuth() - }, [isAdminRoute]) + }, [handleLogout, isAdminRoute]) - const fetchConfig = async () => { + const fetchConfig = useCallback(async () => { if (!token) return try { - setLoading(true) const res = await fetch('/admin/config', { headers: { 'Authorization': `Bearer ${token}` } }) @@ -265,34 +278,19 @@ export default function App() { } catch (e) { console.error('Failed to fetch config:', e) showMessage('error', t('errors.fetchConfig', { error: e.message })) - } finally { - setLoading(false) } - } + }, [showMessage, t, token]) useEffect(() => { if (token) { fetchConfig() } - }, [token]) - - const showMessage = (type, text) => { - setMessage({ type, text }) - setTimeout(() => setMessage(null), 5000) - } + }, [fetchConfig, token]) const handleLogin = (newToken) => { setToken(newToken) } - const handleLogout = () => { - setToken(null) - localStorage.removeItem('ds2api_token') - localStorage.removeItem('ds2api_token_expires') - sessionStorage.removeItem('ds2api_token') - sessionStorage.removeItem('ds2api_token_expires') - } - // Wait for auth checks on admin routes. if (isAdminRoute && authChecking) { return ( @@ -320,6 +318,7 @@ export default function App() { showMessage={showMessage} message={message} onForceLogout={handleLogout} + isVercel={isVercel} /> ) : (
diff --git a/webui/src/components/Settings.jsx b/webui/src/components/Settings.jsx index b257ed5..927804e 100644 --- a/webui/src/components/Settings.jsx +++ b/webui/src/components/Settings.jsx @@ -2,7 +2,9 @@ import { useCallback, useEffect, useMemo, useState } from 'react' import { AlertTriangle, Download, Lock, Save, Upload } from 'lucide-react' import { useI18n } from '../i18n' -export default function Settings({ onRefresh, onMessage, authFetch, onForceLogout }) { +const MAX_AUTO_FETCH_FAILURES = 3 + +export default function Settings({ onRefresh, onMessage, authFetch, onForceLogout, isVercel = false }) { const { t } = useI18n() const apiFetch = authFetch || fetch @@ -14,6 +16,9 @@ export default function Settings({ onRefresh, onMessage, authFetch, onForceLogou const [importMode, setImportMode] = useState('merge') const [importText, setImportText] = useState('') const [newPassword, setNewPassword] = useState('') + const [consecutiveFailures, setConsecutiveFailures] = useState(0) + const [autoFetchPaused, setAutoFetchPaused] = useState(false) + const [lastError, setLastError] = useState('') const [settingsMeta, setSettingsMeta] = useState({ default_password_warning: false, env_backed: false, needs_vercel_sync: false }) const [form, setForm] = useState({ @@ -43,15 +48,38 @@ export default function Settings({ onRefresh, onMessage, authFetch, onForceLogou return parsed } - const loadSettings = useCallback(async () => { + const parseJSONResponse = useCallback(async (res) => { + const contentType = String(res.headers.get('content-type') || '').toLowerCase() + if (!contentType.includes('application/json')) { + throw new Error(t('settings.nonJsonResponse', { status: res.status })) + } + return res.json() + }, [t]) + + const loadSettings = useCallback(async ({ manual = false } = {}) => { + if (isVercel && autoFetchPaused && !manual) { + return + } setLoading(true) try { const res = await apiFetch('/admin/settings') - const data = await res.json() + const data = await parseJSONResponse(res) if (!res.ok) { - onMessage('error', data.detail || t('settings.loadFailed')) + const detail = data.detail || t('settings.loadFailed') + setLastError(detail) + onMessage('error', detail) + setConsecutiveFailures((prev) => { + const next = prev + 1 + if (isVercel && next >= MAX_AUTO_FETCH_FAILURES) { + setAutoFetchPaused(true) + } + return next + }) return } + setConsecutiveFailures(0) + setAutoFetchPaused(false) + setLastError('') setSettingsMeta({ default_password_warning: Boolean(data.admin?.default_password_warning), env_backed: Boolean(data.env_backed), @@ -78,18 +106,32 @@ export default function Settings({ onRefresh, onMessage, authFetch, onForceLogou model_aliases_text: JSON.stringify(data.model_aliases || {}, null, 2), }) } catch (e) { - onMessage('error', t('settings.loadFailed')) + const detail = e?.message || t('settings.loadFailed') + setLastError(detail) + onMessage('error', detail) + setConsecutiveFailures((prev) => { + const next = prev + 1 + if (isVercel && next >= MAX_AUTO_FETCH_FAILURES) { + setAutoFetchPaused(true) + } + return next + }) // eslint-disable-next-line no-console console.error(e) } finally { setLoading(false) } - }, [apiFetch, onMessage, t]) + }, [apiFetch, autoFetchPaused, isVercel, onMessage, parseJSONResponse, t]) useEffect(() => { loadSettings() }, [loadSettings]) + const retryLoadSettings = () => { + setAutoFetchPaused(false) + loadSettings({ manual: true }) + } + const saveSettings = async () => { let claudeMapping = {} let modelAliases = {} @@ -228,6 +270,23 @@ export default function Settings({ onRefresh, onMessage, authFetch, onForceLogou return (
+ {autoFetchPaused && ( +
+
+ + + {t('settings.autoFetchPaused', { count: consecutiveFailures, error: lastError || t('settings.loadFailed') })} + +
+ +
+ )} {settingsMeta.default_password_warning && (
diff --git a/webui/src/components/VercelSync.jsx b/webui/src/components/VercelSync.jsx index 2f8a548..a50714e 100644 --- a/webui/src/components/VercelSync.jsx +++ b/webui/src/components/VercelSync.jsx @@ -3,7 +3,15 @@ import { Cloud, ArrowRight, ExternalLink, Info, CheckCircle2, XCircle, RefreshCw import clsx from 'clsx' import { useI18n } from '../i18n' -export default function VercelSync({ onMessage, authFetch }) { +const MAX_POLL_FAILURES = 3 + +function pollDelayMs(attempt) { + if (attempt <= 0) return 15000 + if (attempt === 1) return 30000 + return 60000 +} + +export default function VercelSync({ onMessage, authFetch, isVercel = false }) { const { t } = useI18n() const [vercelToken, setVercelToken] = useState('') const [projectId, setProjectId] = useState('') @@ -12,20 +20,42 @@ export default function VercelSync({ onMessage, authFetch }) { const [result, setResult] = useState(null) const [preconfig, setPreconfig] = useState(null) const [syncStatus, setSyncStatus] = useState(null) + const [pollPaused, setPollPaused] = useState(false) + const [pollFailures, setPollFailures] = useState(0) + const [nextRetryAt, setNextRetryAt] = useState(null) const apiFetch = authFetch || fetch - const fetchSyncStatus = useCallback(async () => { + const fetchSyncStatus = useCallback(async ({ manual = false } = {}) => { try { const res = await apiFetch('/admin/vercel/status') - if (res.ok) { - const data = await res.json() - setSyncStatus(data) + if (!res.ok) { + throw new Error(`status ${res.status}`) } + const data = await res.json() + setSyncStatus(data) + setPollFailures(0) + setPollPaused(false) + setNextRetryAt(null) } catch (e) { + setPollFailures((prev) => { + const next = prev + 1 + if (isVercel) { + if (next >= MAX_POLL_FAILURES) { + setPollPaused(true) + setNextRetryAt(null) + } else { + setNextRetryAt(Date.now() + pollDelayMs(next)) + } + } + return next + }) + if (manual) { + onMessage('error', t('vercel.networkError')) + } console.error('Failed to fetch sync status:', e) } - }, [apiFetch]) + }, [apiFetch, isVercel, onMessage, t]) useEffect(() => { const loadPreconfig = async () => { @@ -43,11 +73,32 @@ export default function VercelSync({ onMessage, authFetch }) { } loadPreconfig() fetchSyncStatus() - // Poll every 15s to detect config changes - const interval = setInterval(fetchSyncStatus, 15000) - return () => clearInterval(interval) }, [fetchSyncStatus]) + useEffect(() => { + if (!isVercel) { + const interval = setInterval(() => { + fetchSyncStatus() + }, 15000) + return () => clearInterval(interval) + } + if (pollPaused) { + return undefined + } + const delay = nextRetryAt && nextRetryAt > Date.now() ? nextRetryAt - Date.now() : pollDelayMs(pollFailures) + const timer = setTimeout(() => { + fetchSyncStatus() + }, Math.max(1000, delay)) + return () => clearTimeout(timer) + }, [fetchSyncStatus, isVercel, nextRetryAt, pollFailures, pollPaused]) + + const handleManualRefresh = () => { + setPollPaused(false) + setPollFailures(0) + setNextRetryAt(null) + fetchSyncStatus({ manual: true }) + } + const handleSync = async () => { const tokenToUse = preconfig?.has_token && !vercelToken ? '__USE_PRECONFIG__' : vercelToken @@ -122,6 +173,20 @@ export default function VercelSync({ onMessage, authFetch }) {

{t('vercel.description')}

+ {pollPaused && ( +
+

+ {t('vercel.pollPaused', { count: pollFailures })} +

+ +
+ )} {syncStatus?.last_sync_time && (

diff --git a/webui/src/locales/en.json b/webui/src/locales/en.json index d5be108..c06a86d 100644 --- a/webui/src/locales/en.json +++ b/webui/src/locales/en.json @@ -198,6 +198,7 @@ }, "settings": { "loadFailed": "Failed to load settings.", + "nonJsonResponse": "Unexpected non-JSON response from server (status: {status}).", "save": "Save settings", "saving": "Saving...", "saveSuccess": "Settings saved and hot reloaded.", @@ -239,7 +240,9 @@ "exportJson": "Export JSON", "invalidJsonField": "{field} is not a valid JSON object.", "defaultPasswordWarning": "You are using the default admin password \"admin\". Please change it.", - "vercelSyncHint": "Configuration changed. For Vercel deployments, sync manually in Vercel Sync and redeploy." + "vercelSyncHint": "Configuration changed. For Vercel deployments, sync manually in Vercel Sync and redeploy.", + "autoFetchPaused": "Auto loading paused after {count} failures: {error}", + "retryLoad": "Retry now" }, "login": { "welcome": "Welcome back", @@ -278,6 +281,8 @@ "statusNotSynced": "Not synced", "statusNeverSynced": "Never synced", "lastSyncTime": "Last sync: {time}", + "pollPaused": "Status polling paused after {count} failures.", + "manualRefresh": "Refresh manually", "howItWorks": "How it works", "steps": { "one": "The current configuration (keys and accounts) is exported as JSON.", diff --git a/webui/src/locales/zh.json b/webui/src/locales/zh.json index fb31188..7d587d7 100644 --- a/webui/src/locales/zh.json +++ b/webui/src/locales/zh.json @@ -198,6 +198,7 @@ }, "settings": { "loadFailed": "加载设置失败", + "nonJsonResponse": "服务端返回了非 JSON 响应(状态码:{status})", "save": "保存设置", "saving": "保存中...", "saveSuccess": "设置已保存并热更新生效", @@ -239,7 +240,9 @@ "exportJson": "导出 JSON", "invalidJsonField": "{field} 不是有效 JSON 对象", "defaultPasswordWarning": "当前使用默认密码 admin,请尽快在此修改。", - "vercelSyncHint": "当前配置已更新。Vercel 部署请到 Vercel 同步页面手动同步并重部署。" + "vercelSyncHint": "当前配置已更新。Vercel 部署请到 Vercel 同步页面手动同步并重部署。", + "autoFetchPaused": "自动加载已暂停:连续失败 {count} 次({error})", + "retryLoad": "立即重试" }, "login": { "welcome": "欢迎回来", @@ -278,6 +281,8 @@ "statusNotSynced": "未同步", "statusNeverSynced": "从未同步", "lastSyncTime": "上次同步: {time}", + "pollPaused": "状态轮询已暂停:连续失败 {count} 次。", + "manualRefresh": "手动刷新", "howItWorks": "工作原理", "steps": { "one": "当前配置 (密钥和账号) 被导出为 JSON 字符串。", diff --git a/webui/src/utils/runtimeEnv.js b/webui/src/utils/runtimeEnv.js new file mode 100644 index 0000000..d7b7889 --- /dev/null +++ b/webui/src/utils/runtimeEnv.js @@ -0,0 +1,13 @@ +export function detectRuntimeEnv() { + const deployTarget = String(import.meta.env.VITE_DEPLOY_TARGET || '').trim().toLowerCase() + if (deployTarget === 'vercel') { + return { isVercel: true, source: 'vite_env' } + } + + const host = typeof window !== 'undefined' ? String(window.location.hostname || '').toLowerCase() : '' + if (host.includes('vercel.app')) { + return { isVercel: true, source: 'hostname' } + } + + return { isVercel: false, source: 'default' } +}