From e105e40a4150d5f3d052a1b2308aaabf789f143f Mon Sep 17 00:00:00 2001 From: InfinityPacer <160988576+InfinityPacer@users.noreply.github.com> Date: Thu, 3 Sep 2026 10:54:36 +0800 Subject: [PATCH] =?UTF-8?q?=E4=BC=98=E5=8C=96=E8=AE=A2=E9=98=85=E6=89=A7?= =?UTF-8?q?=E8=A1=8C=E7=8A=B6=E6=80=81=E8=BD=AE=E8=AF=A2=E4=B8=8E=E5=B1=95?= =?UTF-8?q?=E7=A4=BA=20(#746)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * feat(subscribe): refine execution status polling * refactor(subscribe): remove unused retry capability * fix(subscribe): serialize execution recovery polling * fix(subscribe): keep lightweight batch polling active * fix(subscribe): resync after batch polling recovery * fix(subscribe): decouple batch and list polling * fix(subscribe): prioritize terminal batch states * fix(subscribe): preserve trailing list refreshes --- src/api/types.ts | 4 +- src/components/cards/SubscribeCard.vue | 6 +- .../cards/__tests__/SubscribeCard.spec.ts | 23 +- src/locales/en-US.ts | 4 +- src/locales/zh-CN.ts | 4 +- src/locales/zh-TW.ts | 4 +- src/views/subscribe/SubscribeListView.vue | 268 +++++++-- .../__tests__/SubscribeListView.spec.ts | 509 +++++++++++++++++- tests/support/msw/handlers/subscribe.ts | 21 +- 9 files changed, 770 insertions(+), 73 deletions(-) diff --git a/src/api/types.ts b/src/api/types.ts index b7a9ba76..49ca1a39 100644 --- a/src/api/types.ts +++ b/src/api/types.ts @@ -35,8 +35,6 @@ export interface SubscriptionExecutionStatus { current_site_id?: number error?: string can_cancel: boolean - can_retry: boolean - requires_reconciliation: boolean } /** 订阅搜索批次的聚合进度与当前工作。 */ @@ -50,6 +48,8 @@ export interface SubscriptionBatchStatus { finished_count: number failed_count: number cancelled_count: number + /** 未执行业务动作而结束的任务数,例如同订阅准入冲突。 */ + skipped_count: number created_at: string updated_at: string current_subscription_id?: number diff --git a/src/components/cards/SubscribeCard.vue b/src/components/cards/SubscribeCard.vue index 0ccd7d09..0c5af261 100644 --- a/src/components/cards/SubscribeCard.vue +++ b/src/components/cards/SubscribeCard.vue @@ -70,7 +70,7 @@ const subscribeState = ref(props.media?.state ?? 'P') // 上一次更新时间 const lastUpdateText = computed(() => (props.media?.last_update ? formatDateDifference(props.media.last_update) : '')) -// 成功终态只承担短暂反馈,持久账本仍由后端保留,卡片随后恢复订阅进度。 +// 成功终态只承担短暂反馈,卡片随后恢复订阅自身的长期进度。 const visibleExecutionStatus = ref(null) let completedExecutionTimer: ReturnType | undefined @@ -113,9 +113,7 @@ const executionStateDisplay = computed(() => { waiting_site_budget: { color: 'warning', icon: 'mdi-timer-sand' }, preparing: { color: 'primary', icon: 'mdi-package-variant-closed' }, submitting: { color: 'primary', icon: 'mdi-download-network-outline' }, - accepted: { color: 'success', icon: 'mdi-download-check-outline' }, - retryable: { color: 'warning', icon: 'mdi-refresh-circle' }, - reconcile_required: { color: 'warning', icon: 'mdi-alert-circle-outline' }, + skipped: { color: 'secondary', icon: 'mdi-skip-next-circle-outline' }, failed: { color: 'error', icon: 'mdi-alert-outline' }, cancelling: { color: 'warning', icon: 'mdi-cancel' }, cancelled: { color: 'secondary', icon: 'mdi-cancel' }, diff --git a/src/components/cards/__tests__/SubscribeCard.spec.ts b/src/components/cards/__tests__/SubscribeCard.spec.ts index 821e2624..e2d31e3c 100644 --- a/src/components/cards/__tests__/SubscribeCard.spec.ts +++ b/src/components/cards/__tests__/SubscribeCard.spec.ts @@ -355,11 +355,9 @@ describe('SubscribeCard display and progress', () => { execution_status: { batch_id: 'batch-1', can_cancel: true, - can_retry: false, current_site_id: 9, error: '站点 9 冷却中', phase: 'waiting_site_budget', - requires_reconciliation: false, state: 'waiting_site_budget', updated_at: '2026-09-01T01:00:00+00:00', }, @@ -369,6 +367,23 @@ describe('SubscribeCard display and progress', () => { expect(screen.getByTitle('站点 9 冷却中')).toBeInTheDocument() }) + it.each([480, 1024])('shows a skipped execution as a non-error terminal state at %ipx', async width => { + setViewport(width) + await renderCard({ + execution_status: { + can_cancel: false, + phase: 'skipped', + state: 'skipped', + updated_at: '2026-09-01T01:00:00+00:00', + }, + }) + + expect(screen.getByText('本轮已跳过')).toBeInTheDocument() + if (width < 600) { + expect(document.querySelector('[data-subscribe-state-icon="mdi-skip-next-circle-outline"]')).toBeInTheDocument() + } + }) + it.each([480, 1024])('briefly shows a fresh completion then restores normal metadata at %ipx', async width => { setViewport(width) const { media, rerender, unmount } = await renderCard({ @@ -386,9 +401,7 @@ describe('SubscribeCard display and progress', () => { ...media, execution_status: { can_cancel: false, - can_retry: false, phase: 'completed', - requires_reconciliation: false, state: 'completed', updated_at: new Date().toISOString(), }, @@ -414,9 +427,7 @@ describe('SubscribeCard display and progress', () => { await renderCard({ execution_status: { can_cancel: false, - can_retry: false, phase: 'completed', - requires_reconciliation: false, state: 'completed', updated_at: new Date(Date.now() - 6_000).toISOString(), }, diff --git a/src/locales/en-US.ts b/src/locales/en-US.ts index 10c6ea3f..5b793907 100644 --- a/src/locales/en-US.ts +++ b/src/locales/en-US.ts @@ -1410,9 +1410,7 @@ export default { waiting_site_budget: 'Waiting for site budget', preparing: 'Preparing download', submitting: 'Submitting download', - accepted: 'Accepted by downloader', - retryable: 'Waiting to retry', - reconcile_required: 'Download result needs review', + skipped: 'Skipped this run', failed: 'Failed', cancelling: 'Cancelling', cancelled: 'Cancelled', diff --git a/src/locales/zh-CN.ts b/src/locales/zh-CN.ts index d7e777c7..afb6fd8b 100644 --- a/src/locales/zh-CN.ts +++ b/src/locales/zh-CN.ts @@ -1392,9 +1392,7 @@ export default { waiting_site_budget: '等待站点额度', preparing: '准备下载', submitting: '提交下载', - accepted: '下载器已接受', - retryable: '等待重试', - reconcile_required: '需要确认下载结果', + skipped: '本轮已跳过', failed: '执行失败', cancelling: '取消中', cancelled: '已取消', diff --git a/src/locales/zh-TW.ts b/src/locales/zh-TW.ts index 494d2fe1..0f39c584 100644 --- a/src/locales/zh-TW.ts +++ b/src/locales/zh-TW.ts @@ -1393,9 +1393,7 @@ export default { waiting_site_budget: '等待站點額度', preparing: '準備下載', submitting: '提交下載', - accepted: '下載器已接受', - retryable: '等待重試', - reconcile_required: '需要確認下載結果', + skipped: '本輪已跳過', failed: '執行失敗', cancelling: '取消中', cancelled: '已取消', diff --git a/src/views/subscribe/SubscribeListView.vue b/src/views/subscribe/SubscribeListView.vue index 5c094962..0898b008 100644 --- a/src/views/subscribe/SubscribeListView.vue +++ b/src/views/subscribe/SubscribeListView.vue @@ -14,6 +14,7 @@ import { openSharedDialog } from '@/composables/useSharedDialog' import { useDisplay } from 'vuetify' const SubscribeHistoryDialog = defineAsyncComponent(() => import('@/components/dialog/SubscribeHistoryDialog.vue')) +const ACTIVE_CARD_REFRESH_INTERVAL_MS = 15_000 // 国际化 const { t } = useI18n() @@ -78,6 +79,10 @@ const loading = ref(false) // 最近一次列表请求是否失败,用于保留旧数据时持续展示错误状态。 const loadError = ref(false) +let initialSubscriptionOpened = false +let lastSubscriptionRequestAt = Number.NEGATIVE_INFINITY +let subscriptionRequest: Promise | undefined +let pendingSubscriptionRefreshContext: KeepAliveRefreshContext | undefined // 数据列表 const dataList = ref([]) @@ -95,32 +100,56 @@ const activeExecutionStates = new Set([ 'waiting_site_budget', 'preparing', 'submitting', - 'accepted', 'cancelling', ]) +const terminalExecutionStates = new Set(['completed', 'failed', 'cancelled', 'skipped']) + +function isActiveExecutionBatch(batch: SubscriptionBatchStatus) { + return ( + !terminalExecutionStates.has(batch.state) && + (activeExecutionStates.has(batch.state) || activeExecutionStates.has(batch.phase)) + ) +} const visibleExecutionBatch = computed(() => { return ( - executionBatches.value.find(batch => activeExecutionStates.has(batch.state) || activeExecutionStates.has(batch.phase)) || - executionBatches.value.find(batch => batch.state === 'failed' || batch.state === 'cancelled') || + executionBatches.value.find(isActiveExecutionBatch) || + executionBatches.value.find(batch => ['failed', 'cancelled', 'skipped'].includes(batch.state)) || null ) }) +const visibleExecutionBatchAppearance = computed(() => { + const batch = visibleExecutionBatch.value + if (!batch || isActiveExecutionBatch(batch)) { + return { color: 'info', icon: 'mdi-progress-clock' } + } + if (batch.state === 'failed') { + return { color: 'error', icon: 'mdi-alert-outline' } + } + if (batch.state === 'skipped') { + return { color: 'secondary', icon: 'mdi-skip-next-circle-outline' } + } + return { color: 'secondary', icon: 'mdi-cancel' } +}) + +const visibleExecutionBatchState = computed(() => { + const batch = visibleExecutionBatch.value + if (!batch) return 'queued' + return terminalExecutionStates.has(batch.state) ? batch.state : batch.phase +}) + const batchProgress = computed(() => { const batch = visibleExecutionBatch.value if (!batch?.total_count) return 0 return Math.min(100, Math.round((batch.processed_count / batch.total_count) * 100)) }) -const hasActiveExecution = computed(() => { - return ( - executionBatches.value.some(batch => activeExecutionStates.has(batch.state) || activeExecutionStates.has(batch.phase)) || - dataList.value.some(item => { - const execution = item.execution_status - return !!execution && (activeExecutionStates.has(execution.state) || activeExecutionStates.has(execution.phase)) - }) - ) +const hasActiveCardExecution = computed(() => { + return dataList.value.some(item => { + const execution = item.execution_status + return !!execution && (activeExecutionStates.has(execution.state) || activeExecutionStates.has(execution.phase)) + }) }) // 订阅顺序配置 @@ -357,21 +386,23 @@ async function saveSubscribeOrder() { } } -// 获取订阅列表数据 -async function fetchData(context: KeepAliveRefreshContext = {}) { +// 获取订阅列表;批次状态使用独立错误边界,不参与列表成功判定。 +async function requestSubscriptions(context: KeepAliveRefreshContext = {}) { const showLoading = !context.silent || !isRefreshed.value const isInitialLoad = !isRefreshed.value + lastSubscriptionRequestAt = Date.now() try { if (showLoading) { loading.value = true } - const [subscribes, batches] = await Promise.all([ - api.get('subscribe/'), - api.get('subscribe/execution/batches?limit=10'), - ]) + const subscribes = await api.get('subscribe/', { feedback: 'silent' }) + if (!initialSubscriptionOpened) { + initialSubscriptionOpened = true + const initialSubscription = subscribes.find(subscribe => subscribe.id.toString() === props.subid?.toString()) + if (initialSubscription) initialSubscription.page_open = true + } dataList.value = subscribes - executionBatches.value = batches loadError.value = false isRefreshed.value = true } catch (error) { @@ -388,22 +419,173 @@ async function fetchData(context: KeepAliveRefreshContext = {}) { if (showLoading) { loading.value = false } - scheduleExecutionPoll() } } -// 仅在页面可见且存在活动执行时轮询,终态后自动停止。 -function scheduleExecutionPoll() { +// 合并在途请求期间的刷新意图,显式刷新优先于静默刷新。 +function mergeSubscriptionRefreshContext(current: KeepAliveRefreshContext | undefined, next: KeepAliveRefreshContext) { + if (!current) return next + + return { + // 只要有一个显式入口,trailing 请求就不能继续静默处理。 + silent: current.silent === true && next.silent === true ? true : undefined, + source: next.source ?? current.source, + } +} + +// 在途请求结束后补一次最新列表;重复静默触发只更新同一个 trailing 槽位。 +async function runSubscriptionRefresh(context: KeepAliveRefreshContext) { + let nextContext = context + + while (true) { + pendingSubscriptionRefreshContext = undefined + await requestSubscriptions(nextContext) + + if (!pendingSubscriptionRefreshContext) return + nextContext = pendingSubscriptionRefreshContext + } +} + +// 所有刷新入口共享列表刷新 worker,避免慢响应期间并发读取和旧快照回写。 +async function fetchSubscriptions(context: KeepAliveRefreshContext = {}) { + if (subscriptionRequest) { + pendingSubscriptionRefreshContext = mergeSubscriptionRefreshContext(pendingSubscriptionRefreshContext, context) + return subscriptionRequest + } + + const request = runSubscriptionRefresh(context) + subscriptionRequest = request + try { + await request + } finally { + if (subscriptionRequest === request) subscriptionRequest = undefined + pendingSubscriptionRefreshContext = undefined + } +} + +function executionBatchSignature(batches: SubscriptionBatchStatus[]) { + return JSON.stringify( + batches.map(batch => [ + batch.batch_id, + batch.source, + batch.state, + batch.phase, + batch.total_count, + batch.processed_count, + batch.finished_count, + batch.failed_count, + batch.cancelled_count, + batch.skipped_count, + batch.created_at, + batch.current_subscription_id, + batch.current_site_id, + batch.updated_at, + batch.error, + batch.can_cancel, + ]), + ) +} + +let lastExecutionBatchSignature: string | undefined +let executionBatchRequest: Promise | undefined +let executionPollRequest: Promise | undefined +let executionBatchRequestFailed = false + +// 批次读取失败时保留上次快照,让订阅列表和后续轮询仍可独立工作。 +async function requestExecutionBatches() { + try { + const batches = await api.get('subscribe/execution/batches?limit=10', { + feedback: 'silent', + }) + if (isUnmounted) return false + const nextSignature = executionBatchSignature(batches) + const changed = + executionBatchRequestFailed || + (lastExecutionBatchSignature !== undefined && nextSignature !== lastExecutionBatchSignature) + executionBatchRequestFailed = false + lastExecutionBatchSignature = nextSignature + executionBatches.value = batches + return changed + } catch (error) { + if (!isUnmounted && !isCancelledRequest(error)) { + console.error(error) + executionBatchRequestFailed = true + } + return false + } +} + +// 可见性、手工刷新和定时轮询共享同一个在途请求,避免旧快照覆盖新状态。 +async function fetchExecutionBatches() { + if (executionBatchRequest) return executionBatchRequest + const request = requestExecutionBatches() + executionBatchRequest = request + try { + return await request + } finally { + if (executionBatchRequest === request) executionBatchRequest = undefined + } +} + +// 列表和批次各自收口请求错误,任一失败都不覆盖另一份成功数据。 +async function fetchData(context: KeepAliveRefreshContext = {}) { + await Promise.all([fetchSubscriptions(context), fetchExecutionBatches()]) + scheduleExecutionPoll() +} + +function clearExecutionPoll() { if (executionPollTimer) { clearTimeout(executionPollTimer) executionPollTimer = undefined } - if (isUnmounted || !props.active || !hasActiveExecution.value) return +} + +// 高频只读取轻量批次;进度变化、活动卡片或列表错误恢复时才刷新完整订阅列表。 +async function runExecutionPoll() { + executionPollTimer = undefined + if (isUnmounted || !props.active || document.hidden) return + const batchChanged = await fetchExecutionBatches() + const cardRefreshDue = + (hasActiveCardExecution.value || loadError.value) && + Date.now() - lastSubscriptionRequestAt >= ACTIVE_CARD_REFRESH_INTERVAL_MS + if (!isUnmounted && props.active && !document.hidden && (batchChanged || cardRefreshDue)) { + void fetchSubscriptions({ silent: true }) + } + scheduleExecutionPoll() +} + +// 可见性和定时器触发的轮询共用完整生命周期,避免同一批次结果触发多次列表刷新。 +async function pollExecutionState() { + if (executionPollRequest) return executionPollRequest + + const request = runExecutionPoll() + executionPollRequest = request + try { + await request + } finally { + if (executionPollRequest === request) { + executionPollRequest = undefined + } + } +} + +// 页面可见时持续读取轻量批次,使外部入口启动的新批次和接口恢复能够被发现。 +function scheduleExecutionPoll() { + clearExecutionPoll() + if (isUnmounted || !props.active || document.hidden) return executionPollTimer = setTimeout(() => { - void fetchData({ silent: true }) + void pollExecutionState() }, 2500) } +function handleExecutionVisibilityChange() { + if (document.hidden) { + clearExecutionPoll() + return + } + if (!isUnmounted && props.active) void pollExecutionState() +} + // 页面切换触发的请求取消是正常生命周期,不应展示为业务失败。 function isCancelledRequest(error: unknown) { return !!error && typeof error === 'object' && 'code' in error && error.code === 'ERR_CANCELED' @@ -637,26 +819,26 @@ const errorTitle = computed(() => { onMounted(async () => { isUnmounted = false + document.addEventListener('visibilitychange', handleExecutionVisibilityChange) await loadSubscribeOrderConfig() await fetchData() - if (props.subid) { - // 找到这个订阅 - const sub = dataList.value.find(sub => sub.id.toString() == props.subid?.toString()) - if (sub) { - // 打开编辑弹窗 - sub.page_open = true - } - } }) watch( () => props.active, - () => scheduleExecutionPoll(), + active => { + if (!active) { + clearExecutionPoll() + return + } + scheduleExecutionPoll() + }, ) onBeforeUnmount(() => { isUnmounted = true - if (executionPollTimer) clearTimeout(executionPollTimer) + clearExecutionPoll() + document.removeEventListener('visibilitychange', handleExecutionVisibilityChange) }) useKeepAliveRefresh(fetchData, { @@ -684,27 +866,31 @@ defineExpose({