mirror of
https://github.com/jxxghp/MoviePilot-Frontend.git
synced 2026-09-10 10:16:44 +08:00
优化订阅执行状态轮询与展示 (#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:
+2
-2
@@ -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
|
||||||
|
|||||||
@@ -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(),
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -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',
|
||||||
|
|||||||
@@ -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: '已取消',
|
||||||
|
|||||||
@@ -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: '已取消',
|
||||||
|
|||||||
@@ -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)) ||
|
const execution = item.execution_status
|
||||||
dataList.value.some(item => {
|
return !!execution && (activeExecutionStates.has(execution.state) || activeExecutionStates.has(execution.phase))
|
||||||
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 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')],
|
||||||
|
|||||||
@@ -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)
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user