From 3d4e4d125af6ba74c4391270b2cb7acea64432f3 Mon Sep 17 00:00:00 2001 From: qian Date: Thu, 10 Sep 2026 23:13:22 +0800 Subject: [PATCH] =?UTF-8?q?improve(web):=E4=BC=98=E5=8C=96=E5=89=8D?= =?UTF-8?q?=E7=AB=AF=E5=AE=9A=E6=97=B6=E4=BB=BB=E5=8A=A1=E6=89=A7=E8=A1=8C?= =?UTF-8?q?=E7=AD=96=E7=95=A5=EF=BC=8C=E4=BB=85=E5=9C=A8=E9=A1=B5=E9=9D=A2?= =?UTF-8?q?=E6=BF=80=E6=B4=BB=E6=97=B6=E5=8F=91=E9=80=81=E8=AF=B7=E6=B1=82?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- web/admin/src/composables/usePolling.ts | 105 ++++++++++++++++++ web/admin/src/views/AdminLayout.vue | 30 +++-- web/admin/src/views/WorkbenchPage.vue | 8 +- .../src/views/monitor/RealtimeMonitorPage.vue | 11 +- .../components/common/MaintenanceBanner.vue | 9 +- .../src/components/layout/AuthLayout.vue | 8 +- .../src/composables/useNotificationCount.ts | 13 +-- web/tenant/src/composables/usePolling.ts | 105 ++++++++++++++++++ web/tenant/src/views/Layout.vue | 26 ++--- .../src/views/dashboard/DashboardPage.vue | 8 +- web/tenant/src/views/finance/WalletPage.vue | 15 +-- .../src/views/service/playground/ImageTab.vue | 23 ++-- .../src/views/service/playground/VideoTab.vue | 24 ++-- 13 files changed, 290 insertions(+), 95 deletions(-) create mode 100644 web/admin/src/composables/usePolling.ts create mode 100644 web/tenant/src/composables/usePolling.ts diff --git a/web/admin/src/composables/usePolling.ts b/web/admin/src/composables/usePolling.ts new file mode 100644 index 0000000..c25798d --- /dev/null +++ b/web/admin/src/composables/usePolling.ts @@ -0,0 +1,105 @@ +/** + * 页面可见性感知的轮询工具 + * + * 后台标签页的轮询请求既浪费带宽也无展示意义,浏览器自身的定时器节流 + * 又不可靠(部分平台放宽到每分钟一次,仍会产生请求)。统一改由 + * visibilitychange 显式控制:页面隐藏时停止排队(隐藏期间零请求), + * 恢复可见时若已超过轮询间隔立即补拉一次,未到则按剩余时间续排。 + * + * 内部用递归 setTimeout 而非 setInterval:上一次回调完成后才排下一次, + * 慢响应下不会堆叠请求。每个调用点创建独立实例,与组件生命周期无关, + * 组件外(模块级单例)也可使用。 + */ + +export interface PollerOptions { + /** start() 时是否立即执行一次回调。默认 false,与 setInterval「先等一个完整间隔」的语义一致 */ + immediate?: boolean +} + +export interface Poller { + /** 启动轮询。幂等:已运行时调用无效果 */ + start(): void + /** 停止轮询并移除可见性监听。幂等,可再次 start() 重新开始完整间隔 */ + stop(): void +} + +export function createPoller( + callback: () => void | Promise, + intervalMs: number, + options?: PollerOptions, +): Poller { + /** 是否处于运行中(start 后 stop 前) */ + let active = false + /** 回调是否执行中,防重入 */ + let inFlight = false + /** 上次回调的执行起点,兼作调度锚点(计算剩余等待时间) */ + let lastRunAt = 0 + let timer: ReturnType | null = null + + function clearTimer(): void { + if (timer !== null) { + clearTimeout(timer) + timer = null + } + } + + function run(): void { + // 防重入:可见性事件与到期定时器可能同时触发 + if (inFlight) return + // 隐藏态兜底:定时器到期与 visibilitychange 存在事件顺序竞态,执行前再判一次 + if (document.hidden) return + inFlight = true + lastRunAt = Date.now() + // 回调异常吞掉,保证后续调度不中断(调用点自身已有 try/catch,这里是双保险) + Promise.resolve(callback()) + .catch(() => {}) + .finally(() => { + inFlight = false + if (active) schedule() + }) + } + + /** 唯一的排队入口:所有续排路径都经过这里,保证同一时刻至多一个待触发定时器 */ + function schedule(): void { + if (!active || document.hidden) return + clearTimer() + const remaining = intervalMs - (Date.now() - lastRunAt) + if (remaining <= 0) { + run() + return + } + timer = setTimeout(run, remaining) + } + + function onVisibilityChange(): void { + if (!active) return + if (document.hidden) { + // 隐藏:停止排队。在途请求自然完成,完成后发现隐藏态不会再续排 + clearTimer() + } else { + // 恢复可见:已超间隔则立即补拉(remaining <= 0 走 run),否则按剩余时间续排 + schedule() + } + } + + function start(): void { + if (active) return + active = true + // 锚点重置:stop 后再 start 视为全新周期,首个回调在完整间隔之后 + lastRunAt = Date.now() + document.addEventListener('visibilitychange', onVisibilityChange) + if (options?.immediate) { + run() + } else { + schedule() + } + } + + function stop(): void { + active = false + clearTimer() + document.removeEventListener('visibilitychange', onVisibilityChange) + } + + return { start, stop } +} diff --git a/web/admin/src/views/AdminLayout.vue b/web/admin/src/views/AdminLayout.vue index 6e9ef65..6ebfa50 100644 --- a/web/admin/src/views/AdminLayout.vue +++ b/web/admin/src/views/AdminLayout.vue @@ -8,6 +8,7 @@ import request from '@/utils/request' import RouteErrorBoundary from '@/components/RouteErrorBoundary.vue' import { useWatermark } from '@/composables/useWatermark' import { useSiteName } from '@/composables/useSiteName' +import { createPoller } from '@/composables/usePolling' import { IconDesktop, IconDashboard, @@ -65,7 +66,8 @@ const checking = ref(false) const updateStatus = ref(null) const releaseNotes = ref('') const releaseUrl = ref('') -const updatePollTimer = ref | null>(null) +// 更新进度轮询:页面隐藏时暂停,恢复可见时立即补拉进度 +const updatePoller = createPoller(fetchUpdateStatus, 2000) const releaseNotesHtml = computed(() => { if (!releaseNotes.value) return '' @@ -152,14 +154,11 @@ function closeUpdateModal() { function startPolling() { stopPolling() - updatePollTimer.value = setInterval(fetchUpdateStatus, 2000) + updatePoller.start() } function stopPolling() { - if (updatePollTimer.value) { - clearInterval(updatePollTimer.value) - updatePollTimer.value = null - } + updatePoller.stop() } async function executeUpdate() { @@ -504,7 +503,8 @@ const bellData = ref<{ feedbacks_wait: string total: number } | null>(null) -let bellTimer: ReturnType | null = null +// 待办铃铛轮询:页面隐藏时暂停,恢复可见时按剩余时间续排 +const bellPoller = createPoller(fetchBellSummary, 60000) async function fetchBellSummary() { try { @@ -519,16 +519,11 @@ async function fetchBellSummary() { function startBellPolling() { fetchBellSummary() - if (!bellTimer) { - bellTimer = setInterval(fetchBellSummary, 60000) - } + bellPoller.start() } function stopBellPolling() { - if (bellTimer) { - clearInterval(bellTimer) - bellTimer = null - } + bellPoller.stop() } function toggleBell() { @@ -553,7 +548,8 @@ watch(canSeeBell, (visible) => { // 60s 轮询不会额外压库。 const workbenchUrgent = ref(0) const workbenchTotal = ref(0) -let badgeTimer: ReturnType | null = null +// 工作台红点轮询:页面隐藏时暂停,恢复可见时按剩余时间续排 +const badgePoller = createPoller(fetchWorkbenchBadges, 60000) // 角标只挂工作台菜单:待办的排查线索只在工作台的描述文案里, // 业务菜单里没有对应的定位入口,挂数字只会带来「进去了却找不到问题」的困惑 @@ -587,7 +583,7 @@ onMounted(() => { } fetchWorkbenchBadges() - badgeTimer = setInterval(fetchWorkbenchBadges, 60000) + badgePoller.start() axios.get('/api/settings/public').then((res) => { const settings = res.data?.data?.settings @@ -605,7 +601,7 @@ onUnmounted(() => { document.removeEventListener('click', handleClickOutside) window.removeEventListener('resize', updateMobile) clearTimeout(popupHideTimer!) - if (badgeTimer) clearInterval(badgeTimer) + badgePoller.stop() stopBellPolling() stopPolling() unmountWatermark() diff --git a/web/admin/src/views/WorkbenchPage.vue b/web/admin/src/views/WorkbenchPage.vue index 1430a2d..1b230e9 100644 --- a/web/admin/src/views/WorkbenchPage.vue +++ b/web/admin/src/views/WorkbenchPage.vue @@ -27,6 +27,7 @@ import { } from '@arco-design/web-vue/es/icon' import request from '@/utils/request' import { formatBilling } from '@/composables/useCurrency' +import { createPoller } from '@/composables/usePolling' const router = useRouter() @@ -68,7 +69,8 @@ const onlyUrgent = ref(false) const activeDomain = ref('all') const lastRefresh = ref(null) -let pollTimer: ReturnType | null = null +// 静默轮询:页面隐藏时暂停,恢复可见时按剩余时间续排 +const summaryPoller = createPoller(() => fetchSummary(true), 60000) // ===== 派生 ===== const items = computed(() => summary.value?.items || []) @@ -184,11 +186,11 @@ function availClass(m: any) { onMounted(() => { fetchSummary() // 后端整份结果有 30s Redis 缓存,前端 60s 轮询不会额外压库 - pollTimer = setInterval(() => fetchSummary(true), 60000) + summaryPoller.start() }) onBeforeUnmount(() => { - if (pollTimer) clearInterval(pollTimer) + summaryPoller.stop() }) diff --git a/web/admin/src/views/monitor/RealtimeMonitorPage.vue b/web/admin/src/views/monitor/RealtimeMonitorPage.vue index a699dc4..8465abd 100644 --- a/web/admin/src/views/monitor/RealtimeMonitorPage.vue +++ b/web/admin/src/views/monitor/RealtimeMonitorPage.vue @@ -7,6 +7,7 @@ import { GridComponent, TooltipComponent, LegendComponent } from 'echarts/compon import VChart from 'vue-echarts' import PageHeader from '@/components/PageHeader.vue' import request from '@/utils/request' +import { createPoller } from '@/composables/usePolling' import { IconThunderbolt, IconSwap, IconClockCircle, IconCode, IconBarChart, IconLayers, IconApps, IconUserGroup, IconFire, IconStorage } from '@arco-design/web-vue/es/icon' use([CanvasRenderer, LineChart, BarChart, GridComponent, TooltipComponent, LegendComponent]) @@ -188,8 +189,9 @@ const poolChannelInflight = computed(() => { return entries.map(([k, v]) => ({ channel: k, count: v as number, pct: ((v as number) / max) * 100 })) }) -let pollTimer: ReturnType | null = null let resizeTimer: ReturnType | null = null +// 实时指标轮询:页面隐藏时暂停,恢复可见时立即补拉(隐藏期间的监控数据无展示意义) +const realtimePoller = createPoller(fetchRealtime, 10000) function onWindowResize() { if (resizeTimer) clearTimeout(resizeTimer) @@ -202,16 +204,13 @@ function onWindowResize() { onMounted(() => { fetchRealtime() - pollTimer = setInterval(fetchRealtime, 10000) + realtimePoller.start() elapsedTimer = setInterval(() => { now.value = Date.now() }, 1000) window.addEventListener('resize', onWindowResize) }) onUnmounted(() => { - if (pollTimer) { - clearInterval(pollTimer) - pollTimer = null - } + realtimePoller.stop() if (elapsedTimer) { clearInterval(elapsedTimer) elapsedTimer = null diff --git a/web/tenant/src/components/common/MaintenanceBanner.vue b/web/tenant/src/components/common/MaintenanceBanner.vue index 9732111..c75db1e 100644 --- a/web/tenant/src/components/common/MaintenanceBanner.vue +++ b/web/tenant/src/components/common/MaintenanceBanner.vue @@ -1,20 +1,21 @@ diff --git a/web/tenant/src/composables/useNotificationCount.ts b/web/tenant/src/composables/useNotificationCount.ts index eab98db..bb39996 100644 --- a/web/tenant/src/composables/useNotificationCount.ts +++ b/web/tenant/src/composables/useNotificationCount.ts @@ -1,8 +1,10 @@ import { ref } from 'vue' import request from '@/utils/request' +import { createPoller } from '@/composables/usePolling' const unreadCount = ref(0) -let pollTimer: ReturnType | null = null +// 未读数轮询:页面隐藏时暂停,恢复可见时按剩余时间续排 +const poller = createPoller(fetchCount, 10 * 60 * 1000) let onNewCallback: ((count: number) => void) | null = null async function fetchCount() { @@ -22,16 +24,11 @@ async function fetchCount() { function startPolling() { fetchCount() - if (!pollTimer) { - pollTimer = setInterval(fetchCount, 10 * 60 * 1000) - } + poller.start() } function stopPolling() { - if (pollTimer) { - clearInterval(pollTimer) - pollTimer = null - } + poller.stop() } function decrement() { diff --git a/web/tenant/src/composables/usePolling.ts b/web/tenant/src/composables/usePolling.ts new file mode 100644 index 0000000..c25798d --- /dev/null +++ b/web/tenant/src/composables/usePolling.ts @@ -0,0 +1,105 @@ +/** + * 页面可见性感知的轮询工具 + * + * 后台标签页的轮询请求既浪费带宽也无展示意义,浏览器自身的定时器节流 + * 又不可靠(部分平台放宽到每分钟一次,仍会产生请求)。统一改由 + * visibilitychange 显式控制:页面隐藏时停止排队(隐藏期间零请求), + * 恢复可见时若已超过轮询间隔立即补拉一次,未到则按剩余时间续排。 + * + * 内部用递归 setTimeout 而非 setInterval:上一次回调完成后才排下一次, + * 慢响应下不会堆叠请求。每个调用点创建独立实例,与组件生命周期无关, + * 组件外(模块级单例)也可使用。 + */ + +export interface PollerOptions { + /** start() 时是否立即执行一次回调。默认 false,与 setInterval「先等一个完整间隔」的语义一致 */ + immediate?: boolean +} + +export interface Poller { + /** 启动轮询。幂等:已运行时调用无效果 */ + start(): void + /** 停止轮询并移除可见性监听。幂等,可再次 start() 重新开始完整间隔 */ + stop(): void +} + +export function createPoller( + callback: () => void | Promise, + intervalMs: number, + options?: PollerOptions, +): Poller { + /** 是否处于运行中(start 后 stop 前) */ + let active = false + /** 回调是否执行中,防重入 */ + let inFlight = false + /** 上次回调的执行起点,兼作调度锚点(计算剩余等待时间) */ + let lastRunAt = 0 + let timer: ReturnType | null = null + + function clearTimer(): void { + if (timer !== null) { + clearTimeout(timer) + timer = null + } + } + + function run(): void { + // 防重入:可见性事件与到期定时器可能同时触发 + if (inFlight) return + // 隐藏态兜底:定时器到期与 visibilitychange 存在事件顺序竞态,执行前再判一次 + if (document.hidden) return + inFlight = true + lastRunAt = Date.now() + // 回调异常吞掉,保证后续调度不中断(调用点自身已有 try/catch,这里是双保险) + Promise.resolve(callback()) + .catch(() => {}) + .finally(() => { + inFlight = false + if (active) schedule() + }) + } + + /** 唯一的排队入口:所有续排路径都经过这里,保证同一时刻至多一个待触发定时器 */ + function schedule(): void { + if (!active || document.hidden) return + clearTimer() + const remaining = intervalMs - (Date.now() - lastRunAt) + if (remaining <= 0) { + run() + return + } + timer = setTimeout(run, remaining) + } + + function onVisibilityChange(): void { + if (!active) return + if (document.hidden) { + // 隐藏:停止排队。在途请求自然完成,完成后发现隐藏态不会再续排 + clearTimer() + } else { + // 恢复可见:已超间隔则立即补拉(remaining <= 0 走 run),否则按剩余时间续排 + schedule() + } + } + + function start(): void { + if (active) return + active = true + // 锚点重置:stop 后再 start 视为全新周期,首个回调在完整间隔之后 + lastRunAt = Date.now() + document.addEventListener('visibilitychange', onVisibilityChange) + if (options?.immediate) { + run() + } else { + schedule() + } + } + + function stop(): void { + active = false + clearTimer() + document.removeEventListener('visibilitychange', onVisibilityChange) + } + + return { start, stop } +} diff --git a/web/tenant/src/views/Layout.vue b/web/tenant/src/views/Layout.vue index 0db9aa4..4e4ab0c 100644 --- a/web/tenant/src/views/Layout.vue +++ b/web/tenant/src/views/Layout.vue @@ -6,6 +6,7 @@ import { useNotificationCount } from '@/composables/useNotificationCount' import { useAnnouncementRead } from '@/composables/useAnnouncementRead' import { usePublicSettings } from '@/composables/usePublicSettings' import { useWatermark } from '@/composables/useWatermark' +import { createPoller } from '@/composables/usePolling' import { formatBilling } from '@/composables/useCurrency' import { toast } from '@/utils/toast' import Icon from '@/components/common/Icon.vue' @@ -26,9 +27,11 @@ const announcePanelOpen = ref(false) const announceDetailItem = ref(null) const consoleAnnouncements = ref([]) const { unreadCount: announceUnreadCount, markAsRead: markAnnouncementRead, markAllRead: markAllAnnouncementsRead, isRead: isAnnouncementRead } = useAnnouncementRead(consoleAnnouncements) -let announcementTimer: ReturnType | null = null +// 公告轮询:页面隐藏时暂停,恢复可见时按剩余时间续排 +const announcementPoller = createPoller(fetchAnnouncements, 30 * 60 * 1000) const walletBalance = ref('') -let walletTimer: ReturnType | null = null +// 钱包余额轮询:页面隐藏时暂停,恢复可见时按剩余时间续排 +const walletPoller = createPoller(fetchWalletBalance, 5 * 60 * 1000) const memberQuota = ref<{ used: number; limit: number } | null>(null) const { unreadCount, startPolling: startNotificationPolling, stopPolling: stopNotificationPolling, setOnNewNotification } = useNotificationCount() setOnNewNotification((newCount: number) => { @@ -166,10 +169,7 @@ function renderMarkdown(text: string): string { async function handleLogout() { stopNotificationPolling() - if (announcementTimer) { - clearInterval(announcementTimer) - announcementTimer = null - } + announcementPoller.stop() await authStore.logout() router.push('/tenant/login') } @@ -232,11 +232,11 @@ onMounted(async () => { authStore.loadFromStorage() document.addEventListener('click', handleClickOutside) fetchAnnouncements() - announcementTimer = setInterval(fetchAnnouncements, 30 * 60 * 1000) + announcementPoller.start() startNotificationPolling() if (canViewWallet.value) { fetchWalletBalance() - walletTimer = setInterval(fetchWalletBalance, 5 * 60 * 1000) + walletPoller.start() } else { fetchMemberQuota() } @@ -254,14 +254,8 @@ onMounted(async () => { onBeforeUnmount(() => { document.removeEventListener('click', handleClickOutside) stopNotificationPolling() - if (announcementTimer) { - clearInterval(announcementTimer) - announcementTimer = null - } - if (walletTimer) { - clearInterval(walletTimer) - walletTimer = null - } + announcementPoller.stop() + walletPoller.stop() unmountWatermark() }) diff --git a/web/tenant/src/views/dashboard/DashboardPage.vue b/web/tenant/src/views/dashboard/DashboardPage.vue index 6104937..5a04db0 100644 --- a/web/tenant/src/views/dashboard/DashboardPage.vue +++ b/web/tenant/src/views/dashboard/DashboardPage.vue @@ -7,6 +7,7 @@ import ModelDistChart from '@/components/charts/ModelDistChart.vue' import TokenTrendChart from '@/components/charts/TokenTrendChart.vue' import { useTenantAuthStore } from '@/stores/tenant-auth' import request from '@/utils/request' +import { createPoller } from '@/composables/usePolling' interface DayStats { requests: number @@ -391,7 +392,8 @@ watch(selectedDays, () => { }) // 实时 RPM/TPM 依赖 60 秒滑动窗口,仅静默轮询 /tenant/dashboard 保持指标新鲜(30s 一次) -let dashboardTimer: ReturnType | null = null +// 页面隐藏时暂停轮询,恢复可见时已超间隔则立即补拉 +const dashboardPoller = createPoller(refreshDashboardSilently, 30000) async function refreshDashboardSilently() { try { @@ -404,11 +406,11 @@ async function refreshDashboardSilently() { onMounted(() => { refreshAll() - dashboardTimer = setInterval(refreshDashboardSilently, 30000) + dashboardPoller.start() }) onBeforeUnmount(() => { - if (dashboardTimer) clearInterval(dashboardTimer) + dashboardPoller.stop() }) diff --git a/web/tenant/src/views/finance/WalletPage.vue b/web/tenant/src/views/finance/WalletPage.vue index 09ac02b..cbee49d 100644 --- a/web/tenant/src/views/finance/WalletPage.vue +++ b/web/tenant/src/views/finance/WalletPage.vue @@ -9,6 +9,7 @@ import { renderBadge, tableScrollX } from '@/utils/renderUtils' import request from '@/utils/request' import { dispatchPayment } from '@/utils/payment' import { formatBilling, formatOrder, displayCurrency, currencySymbol, displayToCny, cnyToDisplay } from '@/composables/useCurrency' +import { createPoller } from '@/composables/usePolling' const route = useRoute() const wallet = ref(null) @@ -33,7 +34,8 @@ const payResult = ref<'success' | 'fail' | 'processing' | ''>('') const showFrozenModal = ref(false) const frozenItems = ref([]) const frozenLoading = ref(false) -let frozenTimer: ReturnType | null = null +// 冻结明细轮询:仅弹窗打开期间运行;页面隐藏时暂停,恢复可见时按剩余时间续排 +const frozenPoller = createPoller(fetchFrozenItems, 10000) // Redeem const showRedeemModal = ref(false) @@ -210,15 +212,12 @@ async function fetchFrozenItems() { function openFrozenModal() { showFrozenModal.value = true fetchFrozenItems() - frozenTimer = setInterval(fetchFrozenItems, 10000) + frozenPoller.start() } function closeFrozenModal() { showFrozenModal.value = false - if (frozenTimer) { - clearInterval(frozenTimer) - frozenTimer = null - } + frozenPoller.stop() } async function openRedeemModal() { @@ -357,9 +356,7 @@ onMounted(() => { }) onBeforeUnmount(() => { - if (frozenTimer) { - clearInterval(frozenTimer) - } + frozenPoller.stop() }) diff --git a/web/tenant/src/views/service/playground/ImageTab.vue b/web/tenant/src/views/service/playground/ImageTab.vue index f96aea6..7ef3058 100644 --- a/web/tenant/src/views/service/playground/ImageTab.vue +++ b/web/tenant/src/views/service/playground/ImageTab.vue @@ -2,6 +2,7 @@ import { ref, reactive, computed, watch, onUnmounted } from 'vue' import { NInput } from 'naive-ui' import { createPlaygroundApi } from '@/utils/playgroundApi' +import { createPoller } from '@/composables/usePolling' import { calculateCost } from './calculateCost' import ParamRefModal from './ParamRefModal.vue' import Icon from '@/components/common/Icon.vue' @@ -188,8 +189,9 @@ interface AsyncTask { } const asyncTask = ref(null) const polling = ref(false) -let pollTimer: ReturnType | null = null -// 轮询上限:100 次 × 3s ≈ 5 分钟,超过判定超时,防止无限轮询。 +// 任务状态轮询:页面隐藏时暂停(任务在服务端继续执行),恢复可见时立即补拉 +const taskPoller = createPoller(pollLoop, 3000, { immediate: true }) +// 轮询上限:100 次实际轮询 ≈ 5 分钟(页面隐藏期间不计数),超过判定超时,防止无限轮询。 const MAX_POLL_ATTEMPTS = 100 let pollAttempts = 0 @@ -312,15 +314,12 @@ async function generateSync(api: ReturnType, body: R function startPolling() { polling.value = true pollAttempts = 0 - pollLoop() + taskPoller.start() } function stopPolling() { polling.value = false - if (pollTimer) { - clearTimeout(pollTimer) - pollTimer = null - } + taskPoller.stop() } async function pollLoop() { @@ -329,7 +328,7 @@ async function pollLoop() { // 轮询次数上限保护:超过上限(约 5 分钟)判定为超时,停止轮询并提示, // 避免上游卡死 / 后端漏推终态时前端无限轮询。 if (pollAttempts >= MAX_POLL_ATTEMPTS) { - polling.value = false + stopPolling() asyncTask.value = { ...asyncTask.value, status: 'FAILURE', @@ -352,7 +351,7 @@ async function pollLoop() { } if (data.status === 'SUCCESS') { - polling.value = false + stopPolling() // 多图:优先用 data 数组渲染全部图片;回退到单 url(向后兼容旧后端)。 if (Array.isArray(data.data) && data.data.length > 0) { images.value = data.data @@ -364,16 +363,12 @@ async function pollLoop() { return } if (data.status === 'FAILURE') { - polling.value = false + stopPolling() return } } catch { // 轮询失败不中断,继续尝试 } - - if (polling.value) { - pollTimer = setTimeout(pollLoop, 3000) - } } function imageSrc(img: ImageResult): string { diff --git a/web/tenant/src/views/service/playground/VideoTab.vue b/web/tenant/src/views/service/playground/VideoTab.vue index 74e7594..d657d67 100644 --- a/web/tenant/src/views/service/playground/VideoTab.vue +++ b/web/tenant/src/views/service/playground/VideoTab.vue @@ -1,7 +1,8 @@