优化订阅执行状态轮询与展示 (#746)

* 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
This commit is contained in:
InfinityPacer
2026-09-03 10:54:36 +08:00
committed by GitHub
parent 880ac5c20d
commit e105e40a41
9 changed files with 770 additions and 73 deletions
+2 -2
View File
@@ -35,8 +35,6 @@ export interface SubscriptionExecutionStatus {
current_site_id?: number current_site_id?: number
error?: string error?: string
can_cancel: boolean can_cancel: boolean
can_retry: boolean
requires_reconciliation: boolean
} }
/** 订阅搜索批次的聚合进度与当前工作。 */ /** 订阅搜索批次的聚合进度与当前工作。 */
@@ -50,6 +48,8 @@ export interface SubscriptionBatchStatus {
finished_count: number finished_count: number
failed_count: number failed_count: number
cancelled_count: number cancelled_count: number
/** 未执行业务动作而结束的任务数,例如同订阅准入冲突。 */
skipped_count: number
created_at: string created_at: string
updated_at: string updated_at: string
current_subscription_id?: number current_subscription_id?: number
+2 -4
View File
@@ -70,7 +70,7 @@ const subscribeState = ref<string>(props.media?.state ?? 'P')
// 上一次更新时间 // 上一次更新时间
const lastUpdateText = computed(() => (props.media?.last_update ? formatDateDifference(props.media.last_update) : '')) const lastUpdateText = computed(() => (props.media?.last_update ? formatDateDifference(props.media.last_update) : ''))
// 成功终态只承担短暂反馈,持久账本仍由后端保留,卡片随后恢复订阅进度。 // 成功终态只承担短暂反馈,卡片随后恢复订阅自身的长期进度。
const visibleExecutionStatus = ref<Subscribe['execution_status'] | null>(null) const visibleExecutionStatus = ref<Subscribe['execution_status'] | null>(null)
let completedExecutionTimer: ReturnType<typeof setTimeout> | undefined let completedExecutionTimer: ReturnType<typeof setTimeout> | undefined
@@ -113,9 +113,7 @@ const executionStateDisplay = computed(() => {
waiting_site_budget: { color: 'warning', icon: 'mdi-timer-sand' }, waiting_site_budget: { color: 'warning', icon: 'mdi-timer-sand' },
preparing: { color: 'primary', icon: 'mdi-package-variant-closed' }, preparing: { color: 'primary', icon: 'mdi-package-variant-closed' },
submitting: { color: 'primary', icon: 'mdi-download-network-outline' }, submitting: { color: 'primary', icon: 'mdi-download-network-outline' },
accepted: { color: 'success', icon: 'mdi-download-check-outline' }, skipped: { color: 'secondary', icon: 'mdi-skip-next-circle-outline' },
retryable: { color: 'warning', icon: 'mdi-refresh-circle' },
reconcile_required: { color: 'warning', icon: 'mdi-alert-circle-outline' },
failed: { color: 'error', icon: 'mdi-alert-outline' }, failed: { color: 'error', icon: 'mdi-alert-outline' },
cancelling: { color: 'warning', icon: 'mdi-cancel' }, cancelling: { color: 'warning', icon: 'mdi-cancel' },
cancelled: { color: 'secondary', icon: 'mdi-cancel' }, cancelled: { color: 'secondary', icon: 'mdi-cancel' },
@@ -355,11 +355,9 @@ describe('SubscribeCard display and progress', () => {
execution_status: { execution_status: {
batch_id: 'batch-1', batch_id: 'batch-1',
can_cancel: true, can_cancel: true,
can_retry: false,
current_site_id: 9, current_site_id: 9,
error: '站点 9 冷却中', error: '站点 9 冷却中',
phase: 'waiting_site_budget', phase: 'waiting_site_budget',
requires_reconciliation: false,
state: 'waiting_site_budget', state: 'waiting_site_budget',
updated_at: '2026-09-01T01:00:00+00:00', updated_at: '2026-09-01T01:00:00+00:00',
}, },
@@ -369,6 +367,23 @@ describe('SubscribeCard display and progress', () => {
expect(screen.getByTitle('站点 9 冷却中')).toBeInTheDocument() 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 => { it.each([480, 1024])('briefly shows a fresh completion then restores normal metadata at %ipx', async width => {
setViewport(width) setViewport(width)
const { media, rerender, unmount } = await renderCard({ const { media, rerender, unmount } = await renderCard({
@@ -386,9 +401,7 @@ describe('SubscribeCard display and progress', () => {
...media, ...media,
execution_status: { execution_status: {
can_cancel: false, can_cancel: false,
can_retry: false,
phase: 'completed', phase: 'completed',
requires_reconciliation: false,
state: 'completed', state: 'completed',
updated_at: new Date().toISOString(), updated_at: new Date().toISOString(),
}, },
@@ -414,9 +427,7 @@ describe('SubscribeCard display and progress', () => {
await renderCard({ await renderCard({
execution_status: { execution_status: {
can_cancel: false, can_cancel: false,
can_retry: false,
phase: 'completed', phase: 'completed',
requires_reconciliation: false,
state: 'completed', state: 'completed',
updated_at: new Date(Date.now() - 6_000).toISOString(), updated_at: new Date(Date.now() - 6_000).toISOString(),
}, },
+1 -3
View File
@@ -1410,9 +1410,7 @@ export default {
waiting_site_budget: 'Waiting for site budget', waiting_site_budget: 'Waiting for site budget',
preparing: 'Preparing download', preparing: 'Preparing download',
submitting: 'Submitting download', submitting: 'Submitting download',
accepted: 'Accepted by downloader', skipped: 'Skipped this run',
retryable: 'Waiting to retry',
reconcile_required: 'Download result needs review',
failed: 'Failed', failed: 'Failed',
cancelling: 'Cancelling', cancelling: 'Cancelling',
cancelled: 'Cancelled', cancelled: 'Cancelled',
+1 -3
View File
@@ -1392,9 +1392,7 @@ export default {
waiting_site_budget: '等待站点额度', waiting_site_budget: '等待站点额度',
preparing: '准备下载', preparing: '准备下载',
submitting: '提交下载', submitting: '提交下载',
accepted: '下载器已接受', skipped: '本轮已跳过',
retryable: '等待重试',
reconcile_required: '需要确认下载结果',
failed: '执行失败', failed: '执行失败',
cancelling: '取消中', cancelling: '取消中',
cancelled: '已取消', cancelled: '已取消',
+1 -3
View File
@@ -1393,9 +1393,7 @@ export default {
waiting_site_budget: '等待站點額度', waiting_site_budget: '等待站點額度',
preparing: '準備下載', preparing: '準備下載',
submitting: '提交下載', submitting: '提交下載',
accepted: '下載器已接受', skipped: '本輪已跳過',
retryable: '等待重試',
reconcile_required: '需要確認下載結果',
failed: '執行失敗', failed: '執行失敗',
cancelling: '取消中', cancelling: '取消中',
cancelled: '已取消', cancelled: '已取消',
+224 -38
View File
@@ -14,6 +14,7 @@ import { openSharedDialog } from '@/composables/useSharedDialog'
import { useDisplay } from 'vuetify' import { useDisplay } from 'vuetify'
const SubscribeHistoryDialog = defineAsyncComponent(() => import('@/components/dialog/SubscribeHistoryDialog.vue')) const SubscribeHistoryDialog = defineAsyncComponent(() => import('@/components/dialog/SubscribeHistoryDialog.vue'))
const ACTIVE_CARD_REFRESH_INTERVAL_MS = 15_000
// 国际化 // 国际化
const { t } = useI18n() const { t } = useI18n()
@@ -78,6 +79,10 @@ const loading = ref(false)
// 最近一次列表请求是否失败,用于保留旧数据时持续展示错误状态。 // 最近一次列表请求是否失败,用于保留旧数据时持续展示错误状态。
const loadError = ref(false) const loadError = ref(false)
let initialSubscriptionOpened = false
let lastSubscriptionRequestAt = Number.NEGATIVE_INFINITY
let subscriptionRequest: Promise<void> | undefined
let pendingSubscriptionRefreshContext: KeepAliveRefreshContext | undefined
// 数据列表 // 数据列表
const dataList = ref<Subscribe[]>([]) const dataList = ref<Subscribe[]>([])
@@ -95,32 +100,56 @@ const activeExecutionStates = new Set([
'waiting_site_budget', 'waiting_site_budget',
'preparing', 'preparing',
'submitting', 'submitting',
'accepted',
'cancelling', '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(() => { const visibleExecutionBatch = computed(() => {
return ( return (
executionBatches.value.find(batch => activeExecutionStates.has(batch.state) || activeExecutionStates.has(batch.phase)) || executionBatches.value.find(isActiveExecutionBatch) ||
executionBatches.value.find(batch => batch.state === 'failed' || batch.state === 'cancelled') || executionBatches.value.find(batch => ['failed', 'cancelled', 'skipped'].includes(batch.state)) ||
null 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 batchProgress = computed(() => {
const batch = visibleExecutionBatch.value const batch = visibleExecutionBatch.value
if (!batch?.total_count) return 0 if (!batch?.total_count) return 0
return Math.min(100, Math.round((batch.processed_count / batch.total_count) * 100)) return Math.min(100, Math.round((batch.processed_count / batch.total_count) * 100))
}) })
const hasActiveExecution = computed(() => { const hasActiveCardExecution = computed(() => {
return ( return dataList.value.some(item => {
executionBatches.value.some(batch => activeExecutionStates.has(batch.state) || activeExecutionStates.has(batch.phase)) ||
dataList.value.some(item => {
const execution = item.execution_status const execution = item.execution_status
return !!execution && (activeExecutionStates.has(execution.state) || activeExecutionStates.has(execution.phase)) 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 showLoading = !context.silent || !isRefreshed.value
const isInitialLoad = !isRefreshed.value const isInitialLoad = !isRefreshed.value
lastSubscriptionRequestAt = Date.now()
try { try {
if (showLoading) { if (showLoading) {
loading.value = true loading.value = true
} }
const [subscribes, batches] = await Promise.all([ const subscribes = await api.get<Subscribe[]>('subscribe/', { feedback: 'silent' })
api.get<Subscribe[]>('subscribe/'), if (!initialSubscriptionOpened) {
api.get<SubscriptionBatchStatus[]>('subscribe/execution/batches?limit=10'), initialSubscriptionOpened = true
]) const initialSubscription = subscribes.find(subscribe => subscribe.id.toString() === props.subid?.toString())
if (initialSubscription) initialSubscription.page_open = true
}
dataList.value = subscribes dataList.value = subscribes
executionBatches.value = batches
loadError.value = false loadError.value = false
isRefreshed.value = true isRefreshed.value = true
} catch (error) { } catch (error) {
@@ -388,22 +419,173 @@ async function fetchData(context: KeepAliveRefreshContext = {}) {
if (showLoading) { if (showLoading) {
loading.value = false 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<boolean> | undefined
let executionPollRequest: Promise<void> | undefined
let executionBatchRequestFailed = false
// 批次读取失败时保留上次快照,让订阅列表和后续轮询仍可独立工作。
async function requestExecutionBatches() {
try {
const batches = await api.get<SubscriptionBatchStatus[]>('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) { if (executionPollTimer) {
clearTimeout(executionPollTimer) clearTimeout(executionPollTimer)
executionPollTimer = undefined 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(() => { executionPollTimer = setTimeout(() => {
void fetchData({ silent: true }) void pollExecutionState()
}, 2500) }, 2500)
} }
function handleExecutionVisibilityChange() {
if (document.hidden) {
clearExecutionPoll()
return
}
if (!isUnmounted && props.active) void pollExecutionState()
}
// 页面切换触发的请求取消是正常生命周期,不应展示为业务失败。 // 页面切换触发的请求取消是正常生命周期,不应展示为业务失败。
function isCancelledRequest(error: unknown) { function isCancelledRequest(error: unknown) {
return !!error && typeof error === 'object' && 'code' in error && error.code === 'ERR_CANCELED' return !!error && typeof error === 'object' && 'code' in error && error.code === 'ERR_CANCELED'
@@ -637,26 +819,26 @@ const errorTitle = computed(() => {
onMounted(async () => { onMounted(async () => {
isUnmounted = false isUnmounted = false
document.addEventListener('visibilitychange', handleExecutionVisibilityChange)
await loadSubscribeOrderConfig() await loadSubscribeOrderConfig()
await fetchData() await fetchData()
if (props.subid) {
// 找到这个订阅
const sub = dataList.value.find(sub => sub.id.toString() == props.subid?.toString())
if (sub) {
// 打开编辑弹窗
sub.page_open = true
}
}
}) })
watch( watch(
() => props.active, () => props.active,
() => scheduleExecutionPoll(), active => {
if (!active) {
clearExecutionPoll()
return
}
scheduleExecutionPoll()
},
) )
onBeforeUnmount(() => { onBeforeUnmount(() => {
isUnmounted = true isUnmounted = true
if (executionPollTimer) clearTimeout(executionPollTimer) clearExecutionPoll()
document.removeEventListener('visibilitychange', handleExecutionVisibilityChange)
}) })
useKeepAliveRefresh(fetchData, { useKeepAliveRefresh(fetchData, {
@@ -684,27 +866,31 @@ defineExpose({
<VAlert <VAlert
v-if="visibleExecutionBatch" v-if="visibleExecutionBatch"
:color="visibleExecutionBatch.state === 'failed' ? 'error' : visibleExecutionBatch.state === 'cancelled' ? 'secondary' : 'info'" :color="visibleExecutionBatchAppearance.color"
variant="tonal" variant="tonal"
class="subscribe-execution-banner mb-4 mx-2 py-2" class="subscribe-execution-banner mb-4 mx-2 py-2"
> >
<div class="d-flex min-w-0 align-center gap-3"> <div class="d-flex min-w-0 align-center gap-3">
<VIcon <VIcon :icon="visibleExecutionBatchAppearance.icon" size="20" />
:icon="activeExecutionStates.has(visibleExecutionBatch.state) ? 'mdi-progress-clock' : visibleExecutionBatch.state === 'failed' ? 'mdi-alert-outline' : 'mdi-cancel'"
size="20"
/>
<div class="min-w-0 flex-grow-1"> <div class="min-w-0 flex-grow-1">
<div class="d-flex min-w-0 align-center justify-space-between gap-2 text-body-2 font-weight-medium"> <div class="d-flex min-w-0 align-center justify-space-between gap-2 text-body-2 font-weight-medium">
<span class="text-truncate"> <span class="text-truncate">
{{ t(`subscribe.execution.state.${visibleExecutionBatch.phase}`) }} {{ t(`subscribe.execution.state.${visibleExecutionBatchState}`) }}
</span> </span>
<span class="flex-shrink-0"> <span class="flex-shrink-0">
{{ t('subscribe.execution.batchProgress', { processed: visibleExecutionBatch.processed_count, total: visibleExecutionBatch.total_count }) }} {{
t('subscribe.execution.batchProgress', {
processed: visibleExecutionBatch.processed_count,
total: visibleExecutionBatch.total_count,
})
}}
</span> </span>
</div> </div>
<VProgressLinear <VProgressLinear
:model-value="batchProgress" :model-value="batchProgress"
:indeterminate="visibleExecutionBatch.total_count === 0 && activeExecutionStates.has(visibleExecutionBatch.state)" :indeterminate="
visibleExecutionBatch.total_count === 0 && activeExecutionStates.has(visibleExecutionBatch.state)
"
height="3" height="3"
class="mt-2" class="mt-2"
/> />
@@ -15,6 +15,7 @@ import {
} from '@tests/support/msw/handlers/subscribe' } from '@tests/support/msw/handlers/subscribe'
import { server } from '@tests/support/msw/server' import { server } from '@tests/support/msw/server'
import { renderWithProviders } from '@tests/support/render' import { renderWithProviders } from '@tests/support/render'
import { flushPromises } from '@vue/test-utils'
import { defineComponent, h, nextTick, ref, watch, type PropType } from 'vue' import { defineComponent, h, nextTick, ref, watch, type PropType } from 'vue'
import { beforeEach, describe, expect, it, vi } from 'vitest' import { beforeEach, describe, expect, it, vi } from 'vitest'
@@ -219,10 +220,12 @@ const SubscribeListHost = defineComponent({
interface RenderListOptions { interface RenderListOptions {
active?: boolean active?: boolean
batchResponse?: SubscriptionBatchStatus[] batchResponse?:
SubscriptionBatchStatus[] | ((url: URL) => SubscriptionBatchStatus[] | Promise<SubscriptionBatchStatus[]>)
batchStatus?: number
onBatchRequest?: (url: URL) => void onBatchRequest?: (url: URL) => void
keyword?: string keyword?: string
listResponse?: Subscribe[] listResponse?: Subscribe[] | ((url: URL) => Subscribe[] | Promise<Subscribe[]>)
listStatus?: number listStatus?: number
onListRequest?: (url: URL) => void onListRequest?: (url: URL) => void
onOrderRequest?: (url: URL) => void onOrderRequest?: (url: URL) => void
@@ -242,7 +245,11 @@ async function renderList(options: RenderListOptions = {}) {
server.use( server.use(
subscribeOrderConfigHandler(type, options.orderValue, options.orderStatus ?? 200, options.onOrderRequest), subscribeOrderConfigHandler(type, options.orderValue, options.orderStatus ?? 200, options.onOrderRequest),
subscribeListHandler(options.listResponse ?? [], options.listStatus ?? 200, options.onListRequest), subscribeListHandler(options.listResponse ?? [], options.listStatus ?? 200, options.onListRequest),
subscriptionExecutionBatchesHandler(options.batchResponse ?? [], 200, options.onBatchRequest), subscriptionExecutionBatchesHandler(
options.batchResponse ?? [],
options.batchStatus ?? 200,
options.onBatchRequest,
),
) )
return renderWithProviders(SubscribeListHost, { return renderWithProviders(SubscribeListHost, {
@@ -282,6 +289,46 @@ function tv(id: number, name: string, overrides: Partial<Subscribe> = {}) {
return createSubscribe({ id, name, type: '电视剧', username: 'tester', ...overrides }) return createSubscribe({ id, name, type: '电视剧', username: 'tester', ...overrides })
} }
function executionBatch(overrides: Partial<SubscriptionBatchStatus> = {}): SubscriptionBatchStatus {
return {
batch_id: 'batch-1',
can_cancel: false,
cancelled_count: 0,
created_at: '2026-09-01T00:00:00+00:00',
failed_count: 0,
finished_count: 0,
phase: 'searching',
processed_count: 0,
skipped_count: 0,
source: 'manual',
state: 'running',
total_count: 3,
updated_at: '2026-09-01T00:01:00+00:00',
...overrides,
}
}
function sequenceResponse<T>(responses: T[]) {
let index = 0
return () => responses[Math.min(index++, responses.length - 1)]
}
function mockDocumentHidden(initialValue = false) {
let hidden = initialValue
vi.spyOn(document, 'hidden', 'get').mockImplementation(() => hidden)
return {
set(value: boolean) {
hidden = value
},
}
}
async function flushAsync() {
await flushPromises()
await nextTick()
await flushPromises()
}
function card(id: number) { function card(id: number) {
return screen.getByTestId(`subscribe-card-${id}`) return screen.getByTestId(`subscribe-card-${id}`)
} }
@@ -329,6 +376,7 @@ describe('SubscribeListView loading and filtering', () => {
finished_count: 1, finished_count: 1,
phase: 'searching', phase: 'searching',
processed_count: 1, processed_count: 1,
skipped_count: 0,
source: 'manual', source: 'manual',
state: 'running', state: 'running',
total_count: 3, total_count: 3,
@@ -345,6 +393,461 @@ describe('SubscribeListView loading and filtering', () => {
expect(mocks.toastSuccess).toHaveBeenCalledWith('已请求取消搜索批次') expect(mocks.toastSuccess).toHaveBeenCalledWith('已请求取消搜索批次')
}) })
it('keeps a successful subscription list visible when the batch endpoint fails', async () => {
vi.spyOn(console, 'error').mockImplementation(() => {})
const listRequested = vi.fn()
const batchRequested = vi.fn()
await renderList({
batchStatus: 500,
listResponse: [movie(1, '列表仍可见')],
onBatchRequest: batchRequested,
onListRequest: listRequested,
})
expect(await screen.findByText('列表仍可见')).toBeInTheDocument()
await waitFor(() => expect(batchRequested).toHaveBeenCalledOnce())
await flushAsync()
expect(listRequested).toHaveBeenCalledOnce()
expect(screen.queryByRole('alert')).not.toBeInTheDocument()
expect(screen.queryByTestId('no-data')).not.toBeInTheDocument()
expect(mocks.toastError).not.toHaveBeenCalled()
})
it('synchronizes subscriptions after the batch endpoint recovers', async () => {
vi.spyOn(console, 'error').mockImplementation(() => {})
vi.useFakeTimers()
const listRequested = vi.fn()
const batchRequested = vi.fn()
await renderList({
batchStatus: 500,
listResponse: sequenceResponse([[movie(1, '批次接口恢复前')], [movie(1, '批次接口恢复后')]]),
onBatchRequest: batchRequested,
onListRequest: listRequested,
})
await flushAsync()
expect(batchRequested).toHaveBeenCalledOnce()
expect(listRequested).toHaveBeenCalledOnce()
server.use(
subscriptionExecutionBatchesHandler(
[executionBatch({ phase: 'completed', state: 'skipped' })],
200,
batchRequested,
),
)
await vi.advanceTimersByTimeAsync(2500)
await flushAsync()
expect(batchRequested).toHaveBeenCalledTimes(2)
expect(listRequested).toHaveBeenCalledTimes(2)
expect(await screen.findByText('批次接口恢复后')).toBeInTheDocument()
})
it('shows only one notification while silent list recovery keeps failing', async () => {
vi.spyOn(console, 'error').mockImplementation(() => {})
vi.useFakeTimers()
await renderList({ listStatus: 500 })
await flushAsync()
expect(mocks.toastError).toHaveBeenCalledOnce()
await vi.advanceTimersByTimeAsync(30_000)
await flushAsync()
expect(mocks.toastError).toHaveBeenCalledOnce()
})
it('keeps polling lightweight batches after an idle initial response', async () => {
vi.useFakeTimers()
const listRequested = vi.fn()
const batchRequested = vi.fn()
await renderList({
batchResponse: sequenceResponse([[], [executionBatch()]]),
listResponse: [movie(1, '空闲后发现新批次')],
onBatchRequest: batchRequested,
onListRequest: listRequested,
})
await flushAsync()
expect(batchRequested).toHaveBeenCalledOnce()
expect(listRequested).toHaveBeenCalledOnce()
await vi.advanceTimersByTimeAsync(2500)
await flushAsync()
expect(batchRequested).toHaveBeenCalledTimes(2)
expect(listRequested).toHaveBeenCalledTimes(2)
})
it('retries a failed subscription list while an active batch remains unchanged', async () => {
vi.spyOn(console, 'error').mockImplementation(() => {})
const hidden = mockDocumentHidden(true)
const initialListRequested = vi.fn()
const recoveredListRequested = vi.fn()
const stableBatch = executionBatch()
await renderList({
batchResponse: [stableBatch],
listStatus: 500,
onListRequest: initialListRequested,
})
await waitFor(() => expect(initialListRequested).toHaveBeenCalledOnce())
expect(await screen.findByText('请求失败,请稍后重试')).toBeInTheDocument()
server.use(subscribeListHandler([movie(1, '列表已恢复')], 200, recoveredListRequested))
vi.useFakeTimers()
await vi.advanceTimersByTimeAsync(15_000)
hidden.set(false)
document.dispatchEvent(new Event('visibilitychange'))
await flushAsync()
expect(recoveredListRequested).toHaveBeenCalledOnce()
expect(await screen.findByText('列表已恢复')).toBeInTheDocument()
expect(screen.queryByText('请求失败,请稍后重试')).not.toBeInTheDocument()
})
it('keeps a failed subscription list on a recovery poll without an active execution', async () => {
vi.spyOn(console, 'error').mockImplementation(() => {})
vi.useFakeTimers()
const listRequested = vi.fn()
const recoveredListRequested = vi.fn()
await renderList({ listStatus: 500, onListRequest: listRequested })
await flushAsync()
expect(listRequested).toHaveBeenCalledOnce()
expect(screen.getByText('请求失败,请稍后重试')).toBeInTheDocument()
server.use(subscribeListHandler([movie(1, '无活动任务时已恢复')], 200, recoveredListRequested))
await vi.advanceTimersByTimeAsync(15_000)
await flushAsync()
expect(recoveredListRequested).toHaveBeenCalledOnce()
expect(await screen.findByText('无活动任务时已恢复')).toBeInTheDocument()
expect(screen.queryByText('请求失败,请稍后重试')).not.toBeInTheDocument()
})
it('polls an unchanged active batch without reloading a large subscription list', async () => {
const hidden = mockDocumentHidden(true)
const listRequested = vi.fn()
const batchRequested = vi.fn()
const activeExecution = {
batch_id: 'batch-1',
can_cancel: true,
phase: 'queued',
source: 'scheduler',
state: 'queued',
task_id: 'task-1',
updated_at: '2026-09-01T00:01:00+00:00',
}
const subscriptions = Array.from({ length: 120 }, (_, index) =>
movie(index + 1, `批量订阅 ${index + 1}`, { execution_status: activeExecution }),
)
const stableBatch = executionBatch({ source: 'scheduler', total_count: subscriptions.length })
await renderList({
batchResponse: sequenceResponse([[stableBatch], [stableBatch], [stableBatch]]),
listResponse: subscriptions,
onBatchRequest: batchRequested,
onListRequest: listRequested,
})
expect(await screen.findByText('批量订阅 1')).toBeInTheDocument()
expect(listRequested).toHaveBeenCalledOnce()
expect(batchRequested).toHaveBeenCalledOnce()
await flushAsync()
vi.useFakeTimers()
hidden.set(false)
document.dispatchEvent(new Event('visibilitychange'))
await flushAsync()
expect(batchRequested).toHaveBeenCalledTimes(2)
expect(listRequested).toHaveBeenCalledOnce()
for (let index = 0; index < 4; index += 1) {
await vi.advanceTimersByTimeAsync(2500)
await flushAsync()
}
expect(batchRequested).toHaveBeenCalledTimes(6)
expect(listRequested).toHaveBeenCalledOnce()
})
it('reloads subscriptions when batch progress changes during polling', async () => {
const hidden = mockDocumentHidden(true)
const listRequested = vi.fn()
const batchRequested = vi.fn()
const initial = movie(1, '初始订阅')
const refreshed = movie(1, '进度变化后的订阅')
await renderList({
batchResponse: sequenceResponse([
[executionBatch({ processed_count: 0 })],
[executionBatch({ processed_count: 1, updated_at: '2026-09-01T00:02:00+00:00' })],
]),
listResponse: sequenceResponse([[initial], [refreshed]]),
onBatchRequest: batchRequested,
onListRequest: listRequested,
})
expect(await screen.findByText('初始订阅')).toBeInTheDocument()
expect(listRequested).toHaveBeenCalledOnce()
await waitFor(() => expect(batchRequested).toHaveBeenCalledOnce())
await flushAsync()
vi.useFakeTimers()
await vi.advanceTimersByTimeAsync(15_000)
hidden.set(false)
document.dispatchEvent(new Event('visibilitychange'))
await flushAsync()
expect(batchRequested).toHaveBeenCalledTimes(2)
expect(listRequested).toHaveBeenCalledTimes(2)
expect(await screen.findByText('进度变化后的订阅')).toBeInTheDocument()
})
it('keeps lightweight batch polling active while a subscription refresh is pending', async () => {
vi.useFakeTimers()
const batchRequested = vi.fn()
const listRequested = vi.fn()
let resolveList!: (subscribes: Subscribe[]) => void
let listCallCount = 0
const pendingList = new Promise<Subscribe[]>(resolve => {
resolveList = resolve
})
await renderList({
batchResponse: sequenceResponse([
[],
[executionBatch()],
[executionBatch({ processed_count: 1, updated_at: '2026-09-01T00:02:00+00:00' })],
]),
listResponse: () => {
listCallCount += 1
return listCallCount === 1 ? [movie(1, '慢列表刷新订阅')] : pendingList
},
onBatchRequest: batchRequested,
onListRequest: listRequested,
})
await flushAsync()
await vi.advanceTimersByTimeAsync(2500)
await flushAsync()
expect(batchRequested).toHaveBeenCalledTimes(2)
expect(listRequested).toHaveBeenCalledTimes(2)
await vi.advanceTimersByTimeAsync(2500)
await flushAsync()
expect(batchRequested).toHaveBeenCalledTimes(3)
expect(listRequested).toHaveBeenCalledTimes(2)
resolveList([movie(1, '慢列表刷新完成')])
await flushAsync()
expect(await screen.findByText('慢列表刷新完成')).toBeInTheDocument()
})
it('runs one trailing list refresh when a card action overlaps a poll refresh', async () => {
vi.useFakeTimers()
const listRequested = vi.fn()
const batchRequested = vi.fn()
let resolvePollingList!: (subscribes: Subscribe[]) => void
let listCallCount = 0
const pollingList = new Promise<Subscribe[]>(resolve => {
resolvePollingList = resolve
})
const initial = movie(1, '交错刷新前')
const refreshed = movie(1, '交错刷新后')
await renderList({
batchResponse: sequenceResponse([[], [executionBatch({ processed_count: 1 })]]),
listResponse: () => {
listCallCount += 1
if (listCallCount === 1) return [initial]
if (listCallCount === 2) return pollingList
return [refreshed]
},
onBatchRequest: batchRequested,
onListRequest: listRequested,
})
await flushAsync()
expect(await screen.findByText('交错刷新前')).toBeInTheDocument()
expect(listRequested).toHaveBeenCalledOnce()
await vi.advanceTimersByTimeAsync(2500)
await flushAsync()
expect(batchRequested).toHaveBeenCalledTimes(2)
expect(listRequested).toHaveBeenCalledTimes(2)
await fireEvent.click(screen.getByRole('button', { name: 'save-1' }))
await flushAsync()
expect(listRequested).toHaveBeenCalledTimes(2)
resolvePollingList([initial])
await flushAsync()
expect(listRequested).toHaveBeenCalledTimes(3)
expect(await screen.findByText('交错刷新后')).toBeInTheDocument()
})
it('reloads subscriptions after the active-card refresh interval', async () => {
const hidden = mockDocumentHidden(true)
const listRequested = vi.fn()
const batchRequested = vi.fn()
const activeExecution = {
can_cancel: false,
phase: 'searching',
state: 'searching',
updated_at: '2026-09-01T00:01:00+00:00',
}
const initial = movie(1, '卡片执行中')
const refreshed = movie(1, '卡片状态已更新')
await renderList({
batchResponse: sequenceResponse([[], []]),
listResponse: sequenceResponse([[{ ...initial, execution_status: activeExecution }], [refreshed]]),
onBatchRequest: batchRequested,
onListRequest: listRequested,
})
expect(await screen.findByText('卡片执行中')).toBeInTheDocument()
expect(listRequested).toHaveBeenCalledOnce()
expect(batchRequested).toHaveBeenCalledOnce()
await flushAsync()
vi.useFakeTimers()
await vi.advanceTimersByTimeAsync(15_000)
hidden.set(false)
document.dispatchEvent(new Event('visibilitychange'))
await flushAsync()
expect(batchRequested).toHaveBeenCalledTimes(2)
expect(listRequested).toHaveBeenCalledTimes(2)
expect(await screen.findByText('卡片状态已更新')).toBeInTheDocument()
})
it('stops polling while hidden and resumes with an immediate batch request when visible', async () => {
const hidden = mockDocumentHidden(false)
const listRequested = vi.fn()
const batchRequested = vi.fn()
const stableBatch = executionBatch()
await renderList({
batchResponse: sequenceResponse([[stableBatch], [stableBatch]]),
listResponse: [movie(1, '可见性订阅')],
onBatchRequest: batchRequested,
onListRequest: listRequested,
})
expect(await screen.findByText('可见性订阅')).toBeInTheDocument()
expect(batchRequested).toHaveBeenCalledOnce()
expect(listRequested).toHaveBeenCalledOnce()
hidden.set(true)
document.dispatchEvent(new Event('visibilitychange'))
vi.useFakeTimers()
await vi.advanceTimersByTimeAsync(5000)
await flushAsync()
expect(batchRequested).toHaveBeenCalledOnce()
expect(listRequested).toHaveBeenCalledOnce()
hidden.set(false)
document.dispatchEvent(new Event('visibilitychange'))
await flushAsync()
expect(batchRequested).toHaveBeenCalledTimes(2)
expect(listRequested).toHaveBeenCalledOnce()
})
it('reuses an in-flight batch request when visibility changes', async () => {
const hidden = mockDocumentHidden(false)
const batchRequested = vi.fn()
let resolveBatch!: (batches: SubscriptionBatchStatus[]) => void
const pendingBatch = new Promise<SubscriptionBatchStatus[]>(resolve => {
resolveBatch = resolve
})
const { rerender } = await renderList({
batchResponse: () => pendingBatch,
listResponse: [movie(1, '并发读取订阅')],
onBatchRequest: batchRequested,
})
await waitFor(() => expect(batchRequested).toHaveBeenCalledOnce())
hidden.set(true)
document.dispatchEvent(new Event('visibilitychange'))
await rerender({ active: false })
await rerender({ active: true })
hidden.set(false)
document.dispatchEvent(new Event('visibilitychange'))
await flushAsync()
expect(batchRequested).toHaveBeenCalledOnce()
resolveBatch([executionBatch()])
await flushAsync()
expect(batchRequested).toHaveBeenCalledOnce()
expect(await screen.findByText('并发读取订阅')).toBeInTheDocument()
})
it('serializes the complete poll when visibility changes during a batch request', async () => {
const hidden = mockDocumentHidden(true)
const listRequested = vi.fn()
const batchRequested = vi.fn()
let resolveBatch!: (batches: SubscriptionBatchStatus[]) => void
let batchCallCount = 0
const pendingBatch = new Promise<SubscriptionBatchStatus[]>(resolve => {
resolveBatch = resolve
})
const activeExecution = {
can_cancel: false,
phase: 'searching',
state: 'searching',
updated_at: '2026-09-01T00:01:00+00:00',
}
await renderList({
batchResponse: () => {
batchCallCount += 1
return batchCallCount === 1 ? [] : pendingBatch
},
listResponse: [movie(1, '轮询串行订阅', { execution_status: activeExecution })],
onBatchRequest: batchRequested,
onListRequest: listRequested,
})
expect(await screen.findByText('轮询串行订阅')).toBeInTheDocument()
await waitFor(() => expect(batchRequested).toHaveBeenCalledOnce())
vi.useFakeTimers()
await vi.advanceTimersByTimeAsync(15_000)
hidden.set(false)
document.dispatchEvent(new Event('visibilitychange'))
hidden.set(true)
document.dispatchEvent(new Event('visibilitychange'))
hidden.set(false)
document.dispatchEvent(new Event('visibilitychange'))
await flushAsync()
expect(batchRequested).toHaveBeenCalledTimes(2)
resolveBatch([executionBatch()])
await flushAsync()
expect(listRequested).toHaveBeenCalledTimes(2)
})
it('shows a skipped batch and uses its processed count in the progress label', async () => {
await renderList({
batchResponse: [
executionBatch({
batch_id: 'batch-skipped',
failed_count: 0,
finished_count: 1,
phase: 'searching',
processed_count: 2,
skipped_count: 1,
state: 'skipped',
total_count: 3,
}),
],
listResponse: [movie(1, '跳过批次订阅')],
})
expect(await screen.findByText('跳过批次订阅')).toBeInTheDocument()
expect(await screen.findByText('本轮已跳过')).toBeInTheDocument()
expect(await screen.findByText('2/3')).toBeInTheDocument()
})
it('lets a superuser see subscriptions from every owner while retaining type defense', async () => { it('lets a superuser see subscriptions from every owner while retaining type defense', async () => {
await renderList({ await renderList({
listResponse: [movie(1, 'Own movie'), movie(2, 'Other movie', { username: 'other' }), tv(3, 'Other TV')], listResponse: [movie(1, 'Own movie'), movie(2, 'Other movie', { username: 'other' }), tv(3, 'Other TV')],
+13 -8
View File
@@ -82,24 +82,29 @@ function mutationResponse(response: SubscribeMutationResponse, status: number) {
} }
export function subscribeListHandler( export function subscribeListHandler(
response: JsonBodyType = [], response: JsonBodyType | ((url: URL) => JsonBodyType | Promise<JsonBodyType>) = [],
status = 200, status = 200,
onRequest: (url: URL) => void = () => {}, onRequest: (url: URL) => void = () => {},
) { ) {
return http.get(subscribeApiUrls.list, ({ request }) => { return http.get(subscribeApiUrls.list, async ({ request }) => {
onRequest(new URL(request.url)) const url = new URL(request.url)
return dataResponse(response, status) onRequest(url)
const body = typeof response === 'function' ? await response(url) : response
return dataResponse(body, status)
}) })
} }
export function subscriptionExecutionBatchesHandler( export function subscriptionExecutionBatchesHandler(
response: SubscriptionBatchStatus[] = [], response:
SubscriptionBatchStatus[] | ((url: URL) => SubscriptionBatchStatus[] | Promise<SubscriptionBatchStatus[]>) = [],
status = 200, status = 200,
onRequest: (url: URL) => void = () => {}, onRequest: (url: URL) => void = () => {},
) { ) {
return http.get(subscribeApiUrls.executionBatches, ({ request }) => { return http.get(subscribeApiUrls.executionBatches, async ({ request }) => {
onRequest(new URL(request.url)) const url = new URL(request.url)
return dataResponse(response as unknown as JsonBodyType, status) onRequest(url)
const body = typeof response === 'function' ? await response(url) : response
return dataResponse(body as unknown as JsonBodyType, status)
}) })
} }