refactor(web): 统一前端轮询组合式函数并发布 v1.5.4

This commit is contained in:
2026-09-28 16:06:52 +08:00
parent 21efc10536
commit 8481bc3ac8
16 changed files with 287 additions and 211 deletions
+9 -59
View File
@@ -353,7 +353,7 @@
</template>
<script setup lang="ts">
import { computed, h, onMounted, onUnmounted, ref } from 'vue'
import { computed, h, onMounted, ref } from 'vue'
import { NButton, NDataTable, NIcon, NSpin, NTag, useMessage, type DataTableColumns } from 'naive-ui'
import {
AlertCircleOutline,
@@ -375,6 +375,7 @@ import {
} from '@vicons/ionicons5'
import bannerImg from '../assets/brand/banner.png'
import { api, asList } from '../api/client'
import { useVisibilityPolling } from '../composables/useVisibilityPolling'
import type {
AccessLogEntry,
CertificateRecord,
@@ -440,8 +441,6 @@ const trafficByRule = ref<Record<number, ProxyTraffic>>({})
type RatePoint = { at: number; upload: number; download: number }
const rateHistory = ref<RatePoint[]>([])
const RATE_HISTORY_MS = 5 * 60 * 1000
let trafficTimer: ReturnType<typeof setInterval> | null = null
let statusTimer: ReturnType<typeof setInterval> | null = null
// 实时流量的数据源:本机(Fonu 管理的本机 Nginx 访问日志)或公网(frps 侧的逐规则计数)
type TrafficSource = 'local' | 'public'
@@ -898,18 +897,6 @@ async function ensureFrpProxyNames() {
}
}
function startTrafficPoll() {
stopTrafficPoll()
trafficTimer = setInterval(refreshTraffic, 2000)
}
function stopTrafficPoll() {
if (trafficTimer) {
clearInterval(trafficTimer)
trafficTimer = null
}
}
// CPU 占用率需要两次采样才有意义,因此 /api/status 每 15 秒刷新一次
async function refreshStatus() {
try {
@@ -919,29 +906,6 @@ async function refreshStatus() {
}
}
function startStatusPoll() {
stopStatusPoll()
statusTimer = setInterval(refreshStatus, 15000)
}
function stopStatusPoll() {
if (statusTimer) {
clearInterval(statusTimer)
statusTimer = null
}
}
// 页面隐藏时暂停轮询,避免后台标签页持续请求
function handleVisibilityChange() {
if (document.hidden) {
stopTrafficPoll()
stopStatusPoll()
return
}
startTrafficPoll()
startStatusPoll()
}
// 系统资源:/api/status 返回的宿主机 CPU / 内存 / 磁盘(容器内为宿主机视角)
const resourcesAvailable = computed(
() => (status.value?.mem_total_bytes ?? 0) > 0 || (status.value?.disk_total_bytes ?? 0) > 0,
@@ -1033,27 +997,20 @@ async function loadChinaCIDR() {
}
// 中国 IP 段更新是后端后台任务:刷新后按 2 秒轮询直到就绪或结束
let cidrPollTimer: ReturnType<typeof setInterval> | null = null
function stopCIDRPoll() {
if (cidrPollTimer) {
clearInterval(cidrPollTimer)
cidrPollTimer = null
}
}
const cidrPolling = ref(false)
async function pollCIDR() {
await loadChinaCIDR()
const data = chinaCIDR.value
if (!data || data.ready || !data.updating) {
stopCIDRPoll()
cidrPolling.value = false
if (data?.ready) message.success('中国 IP 段已更新')
}
}
async function refreshCIDR() {
refreshingCIDR.value = true
stopCIDRPoll()
cidrPolling.value = false
try {
chinaCIDR.value = await api.refreshChinaCIDR()
if (chinaCIDR.value.ready) {
@@ -1061,7 +1018,7 @@ async function refreshCIDR() {
`中国 IP 段已更新:IPv4 ${chinaCIDR.value.entry_count_v4} 条,IPv6 ${chinaCIDR.value.entry_count_v6} 条`,
)
} else if (chinaCIDR.value.updating) {
cidrPollTimer = setInterval(() => void pollCIDR(), 2000)
cidrPolling.value = true
} else {
message.warning('更新完成,但数据尚未就绪,请查看错误信息')
}
@@ -1188,18 +1145,11 @@ async function load() {
onMounted(() => {
load()
refreshTraffic()
startTrafficPoll()
startStatusPoll()
document.addEventListener('visibilitychange', handleVisibilityChange)
})
onUnmounted(() => {
stopTrafficPoll()
stopStatusPoll()
stopCIDRPoll()
document.removeEventListener('visibilitychange', handleVisibilityChange)
})
useVisibilityPolling(refreshTraffic, 2000, { immediate: true })
useVisibilityPolling(refreshStatus, 15000, { immediate: true })
useVisibilityPolling(pollCIDR, 2000, { enabled: cidrPolling })
</script>
<style scoped>
+8 -8
View File
@@ -8,7 +8,7 @@
</template>
</PageHeader>
<n-alert type="success" :bordered="false" class="example-alert" title="v1.5.3 示例页面验证标识已生效">
<n-alert type="success" :bordered="false" class="example-alert" title="v1.5.4 示例页面验证标识已生效">
当前页面由 v{{ version }} 提供,验证标识为 <span class="mono">{{ verificationMarker }}</span>。
如果你是在升级后首次看到这行内容,说明前端构建、镜像重建和服务重启均已成功。
</n-alert>
@@ -19,7 +19,7 @@
<strong class="example-proof__value">{{ verificationRound }}</strong>
</div>
<div class="example-proof__hint">
看到这个新区块,说明前端资源已经从旧版本切换到 v1.5.3。
看到这个新区块,说明前端资源已经从旧版本切换到 v1.5.4。
</div>
</div>
@@ -33,16 +33,16 @@
<StatCard label="本次验证标识" :value="verificationMarker" sub="用于区分升级前后的前端资源" tone="blue" value-small>
<template #icon><n-icon :component="FlashOutline" /></template>
</StatCard>
<StatCard label="页面状态" value="已更新" sub="v1.5.3 前端资源已加载" tone="teal">
<StatCard label="页面状态" value="已更新" sub="v1.5.4 前端资源已加载" tone="teal">
<template #icon><n-icon :component="CheckmarkCircleOutline" /></template>
</StatCard>
</div>
<FonuCard title="本次示例页变化" subtitle="用于确认升级后的页面内容确实发生变化">
<ul class="example-changes">
<li>页面版本提示更新为 v1.5.3。</li>
<li>「本次升级验证」区块和页面状态同步更新为 v1.5.3。</li>
<li>验证标识更新为 <span class="mono">v1.5.3-example-page</span>。</li>
<li>页面版本提示更新为 v1.5.4。</li>
<li>「本次升级验证」区块和页面状态同步更新为 v1.5.4。</li>
<li>验证标识更新为 <span class="mono">v1.5.4-example-page</span>。</li>
</ul>
</FonuCard>
@@ -87,8 +87,8 @@ const message = useMessage()
const version = ref(__APP_VERSION__)
const checkedAt = ref('')
const refreshing = ref(false)
const verificationRound = 'v1.5.3'
const verificationMarker = 'v1.5.3-example-page'
const verificationRound = 'v1.5.4'
const verificationMarker = 'v1.5.4-example-page'
async function refreshVersion() {
refreshing.value = true
+11 -24
View File
@@ -642,7 +642,7 @@
</template>
<script setup lang="ts">
import { computed, h, onMounted, onUnmounted, reactive, ref, type VNode } from 'vue'
import { computed, h, onMounted, reactive, ref, type VNode } from 'vue'
import type { DataTableColumns } from 'naive-ui'
import type { FrpProxyTraffic, FrpProxiesTraffic } from '../api/types'
import { NButton, NIcon, NSpace, NSwitch, NTag, useDialog, useMessage } from 'naive-ui'
@@ -652,12 +652,12 @@ import {
} from '@vicons/ionicons5'
import { RouterLink } from 'vue-router'
import { api, asList } from '../api/client'
import { useVisibilityPolling } from '../composables/useVisibilityPolling'
import { formatBytes, formatDate, formatRate } from '../utils/format'
import type { FrpBinary, FrpDownloadTask, FrpProxy, FrpProxyOptions, FrpProxyPayload, FrpRelease, FrpRuntimeInfo, FrpServer, FrpStatus } from '../api/types'
import FonuCard from '../components/FonuCard.vue'
import EmptyState from '../components/EmptyState.vue'
import { useVisibilityPolling } from '../composables/useVisibilityPolling'
import LoadError from '../components/LoadError.vue'
import PageHeader from '../components/PageHeader.vue'
import StatusBadge from '../components/StatusBadge.vue'
@@ -699,7 +699,7 @@ const downloadProxyOptions = [
{ label: 'cdn.gh-proxy.org', value: 'https://cdn.gh-proxy.org/' },
]
const editingProxy = ref<FrpProxy | null>(null)
let downloadTimer: number | undefined
const downloadPolling = ref(false)
function defaultProxyOptions(): FrpProxyOptions & { allow_ips_text?: string } {
return { transport: {}, health_check: {}, load_balancer: {}, nat_traversal: { disable_assisted_addrs: false }, allow_ips_text: '' }
@@ -865,7 +865,7 @@ const proxyLogsLoading = ref(false)
// 详情弹窗的实时速率采样,复用列表页 5 秒轮询更新的 proxyTraffic 快照
const proxyRateHistory = ref<Array<{ at: number; upload: number; download: number }>>([])
let proxyRateTimer: ReturnType<typeof setInterval> | null = null
const proxyRatePolling = computed(() => showProxyDetail.value)
const proxyRateChart = computed(() => {
const now = Date.now()
@@ -909,14 +909,10 @@ function openProxyDetail(proxy: FrpProxy, tab: 'overview' | 'logs' = 'overview')
proxyRateHistory.value = []
recordProxyRateSample()
if (tab === 'logs') void loadProxyLogs(proxy.id)
// 弹窗打开期间每 5 秒补一个速率采样点,支撑最近 5 分钟实时流量图
if (proxyRateTimer) window.clearInterval(proxyRateTimer)
proxyRateTimer = window.setInterval(recordProxyRateSample, 5_000)
}
function closeProxyDetail() {
showProxyDetail.value = false
if (proxyRateTimer) { window.clearInterval(proxyRateTimer); proxyRateTimer = null }
}
async function loadProxyLogs(proxyID: number) {
@@ -1045,7 +1041,7 @@ async function loadRuntimeConfig() {
binaries.value = asList(nextBinaries)
releases.value = asList(nextReleases)
downloadTask.value = nextDownloadTask.status === 'completed' ? emptyDownloadTask() : nextDownloadTask
ensureDownloadPolling()
downloadPolling.value = downloadInProgress.value
} catch (error) { message.error(error instanceof Error ? error.message : '读取 FRP 应用配置失败') }
finally { runtimeLoading.value = false }
}
@@ -1078,21 +1074,16 @@ async function downloadRelease(version: string) {
downloadingVersion.value = version
try {
downloadTask.value = await api.downloadFrpRelease(version)
ensureDownloadPolling()
downloadPolling.value = downloadInProgress.value
} catch (error) { message.error(error instanceof Error ? error.message : '下载 FRP 客户端失败') }
finally { downloadingVersion.value = null }
}
function ensureDownloadPolling() {
if (!downloadInProgress.value || downloadTimer) return
downloadTimer = window.setInterval(refreshDownloadTask, 500)
}
async function refreshDownloadTask() {
try {
downloadTask.value = await api.getFrpDownloadStatus()
if (downloadInProgress.value) return
stopDownloadPolling()
downloadPolling.value = false
if (downloadTask.value.status === 'completed') {
message.success(`${downloadTask.value.version} 已下载并完成校验`)
await loadRuntimeConfig()
@@ -1101,17 +1092,11 @@ async function refreshDownloadTask() {
}
if (downloadTask.value.status === 'failed') message.error(downloadTask.value.error || '下载 FRP 客户端失败')
} catch (error) {
stopDownloadPolling()
downloadPolling.value = false
message.error(error instanceof Error ? error.message : '读取下载进度失败')
}
}
function stopDownloadPolling() {
if (!downloadTimer) return
window.clearInterval(downloadTimer)
downloadTimer = undefined
}
function emptyDownloadTask(): FrpDownloadTask {
return { status: 'idle', message: '', downloaded: 0, total: 0 }
}
@@ -1211,7 +1196,9 @@ function proxyTransportTags(proxy: FrpProxy) {
useVisibilityPolling(() => load(), 5_000)
onMounted(async () => { await load() })
onUnmounted(() => { if (proxyRateTimer) { window.clearInterval(proxyRateTimer); proxyRateTimer = null } stopDownloadPolling() })
useVisibilityPolling(recordProxyRateSample, 5000, { enabled: proxyRatePolling })
useVisibilityPolling(refreshDownloadTask, 500, { enabled: downloadPolling })
</script>
<style scoped>
+3 -9
View File
@@ -162,7 +162,7 @@
</template>
<script setup lang="ts">
import { computed, defineComponent, h, onMounted, onUnmounted, ref, watch } from 'vue'
import { computed, defineComponent, h, onMounted, ref, watch } from 'vue'
import { useRoute } from 'vue-router'
import {
NButton,
@@ -177,6 +177,7 @@ import {
type DataTableColumns,
} from 'naive-ui'
import { api, asList } from '../api/client'
import { useVisibilityPolling } from '../composables/useVisibilityPolling'
import type { AccessLogEntry, ProxyRule, SystemLogEntry } from '../api/types'
import EmptyState from '../components/EmptyState.vue'
import FonuCard from '../components/FonuCard.vue'
@@ -296,7 +297,6 @@ function resolveTab(queryTab: unknown): LogTab {
const tab = ref(resolveTab(route.query.tab))
const pageError = ref('')
const autoRefresh = ref(false)
let refreshTimer: ReturnType<typeof setInterval> | null = null
const accessLogs = ref<AccessLogEntry[]>([])
const proxyRules = ref<ProxyRule[]>([])
@@ -802,10 +802,7 @@ watch([frpKeyword, frpLevel], () => {
frpPage.value = 1
})
watch(autoRefresh, (on) => {
if (refreshTimer) clearInterval(refreshTimer)
if (on) refreshTimer = setInterval(() => loadAccess(), 10000)
})
useVisibilityPolling(() => loadAccess(), 10000, { enabled: autoRefresh })
watch(
() => route.query.tab,
@@ -824,9 +821,6 @@ onMounted(() => {
tab.value = resolveTab(route.query.tab)
loadAll()
})
onUnmounted(() => {
if (refreshTimer) clearInterval(refreshTimer)
})
</script>
<style scoped>
+6 -36
View File
@@ -860,6 +860,7 @@ import {
} from '@vicons/ionicons5'
import { api, asList } from '../api/client'
import type { ProxyClientConn, ProxyRule, ProxySavePayload, ProxyTraffic } from '../api/types'
import { useVisibilityPolling } from '../composables/useVisibilityPolling'
import EmptyState from '../components/EmptyState.vue'
import FonuCard from '../components/FonuCard.vue'
import ProxyAccessLogBox from '../components/ProxyAccessLogBox.vue'
@@ -912,8 +913,6 @@ const clientsLoading = ref(false)
type RatePoint = { at: number; upload: number; download: number }
const rateHistory = ref<RatePoint[]>([])
const RATE_HISTORY_MS = 5 * 60 * 1000
let trafficTimer: ReturnType<typeof setInterval> | null = null
let clientsTimer: ReturnType<typeof setInterval> | null = null
const nginxHttpPort = ref(80)
const nginxHttpsPort = ref(443)
@@ -1794,18 +1793,6 @@ async function refreshAll() {
await Promise.all([load(), refreshTraffic()])
}
function startTrafficPoll() {
stopTrafficPoll()
trafficTimer = setInterval(refreshTraffic, 2000)
}
function stopTrafficPoll() {
if (trafficTimer) {
clearInterval(trafficTimer)
trafficTimer = null
}
}
async function loadClients() {
const rule = selectedRule.value
if (!rule) return
@@ -1819,25 +1806,8 @@ async function loadClients() {
}
}
function startClientsPoll() {
stopClientsPoll()
void loadClients()
clientsTimer = setInterval(loadClients, 5000)
}
function stopClientsPoll() {
if (clientsTimer) {
clearInterval(clientsTimer)
clientsTimer = null
}
}
watch([showDetailPanel, selectedRuleId, detailTab], ([visible, id, tab]) => {
if (!visible || !id || tab !== 'overview') {
stopClientsPoll()
return
}
startClientsPoll()
if (visible && id && tab === 'overview') void loadClients()
})
async function loadDefaults() {
@@ -2191,17 +2161,17 @@ watch(
onMounted(async () => {
await loadDefaults()
await load()
await refreshTraffic()
startTrafficPoll()
})
onUnmounted(() => {
destroyRowSortable()
stopLogStream()
stopTrafficPoll()
stopClientsPoll()
document.removeEventListener('keydown', onLogFullscreenKeydown)
document.body.style.overflow = ''
})
const clientsPolling = computed(() => showDetailPanel.value && !!selectedRuleId.value && detailTab.value === 'overview')
useVisibilityPolling(refreshTraffic, 2000, { immediate: true })
useVisibilityPolling(loadClients, 5000, { enabled: clientsPolling })
</script>
<style scoped>
+7 -24
View File
@@ -799,7 +799,7 @@
</template>
<script setup lang="ts">
import { computed, onBeforeUnmount, onMounted, reactive, ref } from 'vue'
import { computed, onMounted, reactive, ref } from 'vue'
import {
NButton,
NIcon,
@@ -825,6 +825,7 @@ import {
SunnyOutline,
} from '@vicons/ionicons5'
import { api } from '../api/client'
import { useVisibilityPolling } from '../composables/useVisibilityPolling'
import type {
ApiToken,
BackupArchive,
@@ -1055,7 +1056,7 @@ const globalIPWhitelistText = ref('')
const chinaCIDRHours = ref(24)
const refreshingCIDR = ref(false)
const chinaCIDR = ref<ChinaCIDRStatus>({ entry_count_v4: 0, entry_count_v6: 0 })
let chinaCIDRPollTimer: ReturnType<typeof setInterval> | undefined
const chinaCIDRPolling = ref(false)
const globalNginxFramework = ref('')
const globalNginxGeneratedSnippet = ref('')
@@ -1335,31 +1336,15 @@ async function resetGlobalNginxAuto() {
async function loadChinaCIDRStatus() {
try {
chinaCIDR.value = await api.getChinaCIDRStatus()
if (chinaCIDR.value.updating) {
startChinaCIDRPoll()
} else {
stopChinaCIDRPoll()
}
chinaCIDRPolling.value = Boolean(chinaCIDR.value.updating)
} catch {
// optional on load
}
}
function stopChinaCIDRPoll() {
if (chinaCIDRPollTimer) {
clearInterval(chinaCIDRPollTimer)
chinaCIDRPollTimer = undefined
}
}
function startChinaCIDRPoll() {
if (chinaCIDRPollTimer) return
chinaCIDRPollTimer = setInterval(() => void loadChinaCIDRStatus(), 2000)
}
async function refreshChinaCIDR() {
refreshingCIDR.value = true
stopChinaCIDRPoll()
chinaCIDRPolling.value = false
try {
chinaCIDR.value = await api.refreshChinaCIDR()
if (chinaCIDR.value.ready) {
@@ -1377,10 +1362,6 @@ async function refreshChinaCIDR() {
}
}
onBeforeUnmount(() => {
stopChinaCIDRPoll()
})
function applyNotifySettings(settings: Record<string, string>) {
notifyType.value = (settings.notify_type as NotifyType) || ''
notifySMTPPassword.value = ''
@@ -1770,6 +1751,8 @@ onMounted(() => {
void loadApiTokens()
void loadBackupArchives()
})
useVisibilityPolling(loadChinaCIDRStatus, 2000, { enabled: chinaCIDRPolling })
</script>
<style scoped>