perf(stability): add request metrics and resilient API retries

This commit is contained in:
2026-02-07 11:58:21 +08:00
parent 04b94d7fb2
commit a50294933b
38 changed files with 447 additions and 97 deletions

View File

@@ -4,6 +4,10 @@ import { ElMessage, ElMessageBox } from 'element-plus'
let lastToastKey = ''
let lastToastAt = 0
const RETRYABLE_STATUS = new Set([408, 425, 429, 500, 502, 503, 504])
const MAX_RETRY_COUNT = 1
const RETRY_BASE_DELAY_MS = 300
function toastErrorOnce(key, message, minIntervalMs = 1500) {
const now = Date.now()
if (key === lastToastKey && now - lastToastAt < minIntervalMs) return
@@ -18,6 +22,41 @@ function getCookie(name) {
return match ? decodeURIComponent(match[1]) : ''
}
function isIdempotentMethod(method) {
return ['GET', 'HEAD', 'OPTIONS'].includes(String(method || 'GET').toUpperCase())
}
function shouldRetryRequest(error) {
const config = error?.config
if (!config || config.__no_retry) return false
if (!isIdempotentMethod(config.method)) return false
const retried = Number(config.__retry_count || 0)
if (retried >= MAX_RETRY_COUNT) return false
const code = String(error?.code || '')
if (code === 'ECONNABORTED' || code === 'ERR_NETWORK') return true
const status = Number(error?.response?.status || 0)
return RETRYABLE_STATUS.has(status)
}
function delay(ms) {
return new Promise((resolve) => {
window.setTimeout(resolve, Math.max(0, Number(ms || 0)))
})
}
async function retryRequestOnce(error, client) {
const config = error?.config || {}
const retried = Number(config.__retry_count || 0)
config.__retry_count = retried + 1
const backoffMs = RETRY_BASE_DELAY_MS * (retried + 1)
await delay(backoffMs)
return client.request(config)
}
export const api = axios.create({
baseURL: '/yuyx/api',
timeout: 30_000,
@@ -76,6 +115,10 @@ api.interceptors.response.use(
}
}
if (shouldRetryRequest(error)) {
return retryRequestOnce(error, api)
}
if (status === 401) {
toastErrorOnce('401', message || '登录已过期,请重新登录', 3000)
const pathname = window.location?.pathname || ''

View File

@@ -34,7 +34,11 @@ async function refreshStats() {
const loadingBadges = ref(false)
const pendingFeedbackCount = ref(0)
let badgeTimer
const BADGE_POLL_ACTIVE_MS = 60_000
const BADGE_POLL_HIDDEN_MS = 180_000
let badgeTimer = null
async function refreshNavBadges(partial = null) {
if (partial && typeof partial === 'object') {
@@ -55,6 +59,34 @@ async function refreshNavBadges(partial = null) {
}
}
function isPageHidden() {
if (typeof document === 'undefined') return false
return document.visibilityState === 'hidden'
}
function currentBadgePollDelay() {
return isPageHidden() ? BADGE_POLL_HIDDEN_MS : BADGE_POLL_ACTIVE_MS
}
function stopBadgePolling() {
if (!badgeTimer) return
window.clearTimeout(badgeTimer)
badgeTimer = null
}
function scheduleBadgePolling() {
stopBadgePolling()
badgeTimer = window.setTimeout(async () => {
badgeTimer = null
await refreshNavBadges().catch(() => {})
scheduleBadgePolling()
}, currentBadgePollDelay())
}
function onVisibilityChange() {
scheduleBadgePolling()
}
provide('refreshStats', refreshStats)
provide('adminStats', stats)
provide('refreshNavBadges', refreshNavBadges)
@@ -75,12 +107,14 @@ onMounted(async () => {
await refreshStats()
await refreshNavBadges()
badgeTimer = window.setInterval(refreshNavBadges, 60_000)
scheduleBadgePolling()
window.addEventListener('visibilitychange', onVisibilityChange)
})
onBeforeUnmount(() => {
mediaQuery?.removeEventListener?.('change', syncIsMobile)
window.clearInterval(badgeTimer)
stopBadgePolling()
window.removeEventListener('visibilitychange', onVisibilityChange)
})
const menuItems = [