mirror of
https://github.com/jxxghp/MoviePilot-Frontend.git
synced 2026-09-10 18:26:45 +08:00
Codex/subscription governance (#744)
* feat(subscribe): show execution governance status * ci(frontend): allow manual test validation
This commit is contained in:
@@ -1,6 +1,7 @@
|
|||||||
name: Frontend Tests
|
name: Frontend Tests
|
||||||
|
|
||||||
on:
|
on:
|
||||||
|
workflow_dispatch:
|
||||||
pull_request:
|
pull_request:
|
||||||
branches:
|
branches:
|
||||||
- v3
|
- v3
|
||||||
|
|||||||
@@ -24,6 +24,40 @@ export interface MediaSourceInfo {
|
|||||||
media_types: string[]
|
media_types: string[]
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** 订阅跨搜索与下载链路的当前业务状态。 */
|
||||||
|
export interface SubscriptionExecutionStatus {
|
||||||
|
state: string
|
||||||
|
phase: string
|
||||||
|
updated_at: string
|
||||||
|
source?: string
|
||||||
|
batch_id?: string
|
||||||
|
task_id?: string
|
||||||
|
current_site_id?: number
|
||||||
|
error?: string
|
||||||
|
can_cancel: boolean
|
||||||
|
can_retry: boolean
|
||||||
|
requires_reconciliation: boolean
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 订阅搜索批次的聚合进度与当前工作。 */
|
||||||
|
export interface SubscriptionBatchStatus {
|
||||||
|
batch_id: string
|
||||||
|
source: string
|
||||||
|
state: string
|
||||||
|
phase: string
|
||||||
|
total_count: number
|
||||||
|
processed_count: number
|
||||||
|
finished_count: number
|
||||||
|
failed_count: number
|
||||||
|
cancelled_count: number
|
||||||
|
created_at: string
|
||||||
|
updated_at: string
|
||||||
|
current_subscription_id?: number
|
||||||
|
current_site_id?: number
|
||||||
|
error?: string
|
||||||
|
can_cancel: boolean
|
||||||
|
}
|
||||||
|
|
||||||
// 手动刮削选项
|
// 手动刮削选项
|
||||||
export interface ManualScrapeOptions {
|
export interface ManualScrapeOptions {
|
||||||
// 媒体数据源
|
// 媒体数据源
|
||||||
@@ -142,6 +176,8 @@ export interface Subscribe {
|
|||||||
downloader?: string
|
downloader?: string
|
||||||
// 自定义剧集组
|
// 自定义剧集组
|
||||||
episode_group?: string
|
episode_group?: string
|
||||||
|
// 当前搜索或下载执行状态
|
||||||
|
execution_status?: SubscriptionExecutionStatus
|
||||||
}
|
}
|
||||||
|
|
||||||
/** 订阅删除成功后的机器可判断结果。 */
|
/** 订阅删除成功后的机器可判断结果。 */
|
||||||
|
|||||||
@@ -68,6 +68,34 @@ 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 executionStateDisplay = computed(() => {
|
||||||
|
const execution = props.media?.execution_status
|
||||||
|
if (!execution) return null
|
||||||
|
const displays: Record<string, { color: string; icon: string }> = {
|
||||||
|
queued: { color: 'info', icon: 'mdi-clock-outline' },
|
||||||
|
running: { color: 'info', icon: 'mdi-progress-clock' },
|
||||||
|
matching: { color: 'info', icon: 'mdi-filter-search-outline' },
|
||||||
|
searching: { color: 'primary', icon: 'mdi-magnify-scan' },
|
||||||
|
waiting_site_budget: { color: 'warning', icon: 'mdi-timer-sand' },
|
||||||
|
preparing: { color: 'primary', icon: 'mdi-package-variant-closed' },
|
||||||
|
submitting: { color: 'primary', icon: 'mdi-download-network-outline' },
|
||||||
|
accepted: { color: 'success', icon: 'mdi-download-check-outline' },
|
||||||
|
retryable: { color: 'warning', icon: 'mdi-refresh-circle' },
|
||||||
|
reconcile_required: { color: 'warning', icon: 'mdi-alert-circle-outline' },
|
||||||
|
failed: { color: 'error', icon: 'mdi-alert-outline' },
|
||||||
|
cancelling: { color: 'warning', icon: 'mdi-cancel' },
|
||||||
|
cancelled: { color: 'secondary', icon: 'mdi-cancel' },
|
||||||
|
completed: { color: 'success', icon: 'mdi-check-circle-outline' },
|
||||||
|
}
|
||||||
|
const display = displays[execution.state] || displays[execution.phase] || displays.running
|
||||||
|
return {
|
||||||
|
...display,
|
||||||
|
label: t(`subscribe.execution.state.${execution.state}`),
|
||||||
|
error: execution.error,
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
// 判断后端数字/布尔开关是否启用
|
// 判断后端数字/布尔开关是否启用
|
||||||
function isEnabledFlag(value: any) {
|
function isEnabledFlag(value: any) {
|
||||||
return value === true || value === 1 || value === '1'
|
return value === true || value === 1 || value === '1'
|
||||||
@@ -92,6 +120,9 @@ const hasBestVersion = computed(() => isEnabledFlag(props.media?.best_version))
|
|||||||
const isBestVersion = computed(() => hasBestVersion.value && isTvSubscribe(props.media))
|
const isBestVersion = computed(() => hasBestVersion.value && isTvSubscribe(props.media))
|
||||||
|
|
||||||
const rightBottomStateDisplay = computed(() => {
|
const rightBottomStateDisplay = computed(() => {
|
||||||
|
if (executionStateDisplay.value) {
|
||||||
|
return executionStateDisplay.value
|
||||||
|
}
|
||||||
if (subscribeState.value === 'S') {
|
if (subscribeState.value === 'S') {
|
||||||
return { icon: 'mdi-pause-circle', label: t('subscribe.cardStatePaused') }
|
return { icon: 'mdi-pause-circle', label: t('subscribe.cardStatePaused') }
|
||||||
}
|
}
|
||||||
@@ -103,6 +134,9 @@ const rightBottomStateDisplay = computed(() => {
|
|||||||
|
|
||||||
// 移动端紧凑卡片的状态展示,颜色统一映射到 Vuetify 全局主题 token。
|
// 移动端紧凑卡片的状态展示,颜色统一映射到 Vuetify 全局主题 token。
|
||||||
const compactStateDisplay = computed(() => {
|
const compactStateDisplay = computed(() => {
|
||||||
|
if (executionStateDisplay.value) {
|
||||||
|
return executionStateDisplay.value
|
||||||
|
}
|
||||||
if (subscribeState.value === 'S') {
|
if (subscribeState.value === 'S') {
|
||||||
return { color: 'secondary', icon: 'mdi-pause-circle-outline', label: t('subscribe.cardStatePaused') }
|
return { color: 'secondary', icon: 'mdi-pause-circle-outline', label: t('subscribe.cardStatePaused') }
|
||||||
}
|
}
|
||||||
@@ -175,6 +209,10 @@ const musicSubscribeMeta = computed(() => {
|
|||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
|
const compactStateText = computed(
|
||||||
|
() => executionStateDisplay.value?.label || subscribeProgressText.value || musicSubscribeMeta.value?.text || '',
|
||||||
|
)
|
||||||
|
|
||||||
// 订阅卡片 hover 文案:
|
// 订阅卡片 hover 文案:
|
||||||
// - 普通订阅:「已下载 X · 共 Y 集」
|
// - 普通订阅:「已下载 X · 共 Y 集」
|
||||||
// - 洗版订阅:「已下载 X · 已洗版 N · 共 Y 集」
|
// - 洗版订阅:「已下载 X · 已洗版 N · 共 Y 集」
|
||||||
@@ -240,7 +278,8 @@ async function removeSubscribe() {
|
|||||||
async function searchSubscribe() {
|
async function searchSubscribe() {
|
||||||
try {
|
try {
|
||||||
await api.get(`subscribe/search/${props.media?.id}`, { feedback: 'silent' })
|
await api.get(`subscribe/search/${props.media?.id}`, { feedback: 'silent' })
|
||||||
$toast.success(`${props.media?.name} 提交搜索请求成功!`)
|
$toast.success(t('subscribe.execution.searchSubmitted', { name: props.media?.name }))
|
||||||
|
emit('save')
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
$toast.error(t('subscribe.requestFailed'))
|
$toast.error(t('subscribe.requestFailed'))
|
||||||
console.log(e)
|
console.log(e)
|
||||||
@@ -651,7 +690,7 @@ function handleCardClick() {
|
|||||||
<div
|
<div
|
||||||
class="subscribe-card-mobile-state"
|
class="subscribe-card-mobile-state"
|
||||||
:style="{ color: `rgb(var(--v-theme-${compactStateDisplay.color}))` }"
|
:style="{ color: `rgb(var(--v-theme-${compactStateDisplay.color}))` }"
|
||||||
:title="compactStateDisplay.label"
|
:title="executionStateDisplay?.error || compactStateDisplay.label"
|
||||||
:aria-label="compactStateDisplay.label"
|
:aria-label="compactStateDisplay.label"
|
||||||
>
|
>
|
||||||
<VIcon
|
<VIcon
|
||||||
@@ -659,12 +698,16 @@ function handleCardClick() {
|
|||||||
:data-subscribe-state-icon="compactStateDisplay.icon"
|
:data-subscribe-state-icon="compactStateDisplay.icon"
|
||||||
size="16"
|
size="16"
|
||||||
/>
|
/>
|
||||||
<span
|
<span v-if="compactStateText" class="subscribe-card-mobile-progress-text">
|
||||||
v-if="subscribeProgressText || musicSubscribeMeta"
|
{{ compactStateText }}
|
||||||
class="subscribe-card-mobile-progress-text"
|
|
||||||
>
|
|
||||||
{{ subscribeProgressText || musicSubscribeMeta?.text }}
|
|
||||||
</span>
|
</span>
|
||||||
|
<VTooltip
|
||||||
|
v-if="executionStateDisplay?.error"
|
||||||
|
activator="parent"
|
||||||
|
location="top"
|
||||||
|
>
|
||||||
|
{{ executionStateDisplay.error }}
|
||||||
|
</VTooltip>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<IconBtn v-if="!props.sortable" class="subscribe-card-mobile-menu" size="small" @click.stop>
|
<IconBtn v-if="!props.sortable" class="subscribe-card-mobile-menu" size="small" @click.stop>
|
||||||
@@ -787,9 +830,18 @@ function handleCardClick() {
|
|||||||
<VCardText
|
<VCardText
|
||||||
v-if="rightBottomStateDisplay"
|
v-if="rightBottomStateDisplay"
|
||||||
class="absolute right-0 bottom-0 d-flex align-center p-2 text-gray-300 text-xs"
|
class="absolute right-0 bottom-0 d-flex align-center p-2 text-gray-300 text-xs"
|
||||||
|
:style="executionStateDisplay ? { color: `rgb(var(--v-theme-${executionStateDisplay.color}))` } : undefined"
|
||||||
|
:title="executionStateDisplay?.error || rightBottomStateDisplay.label"
|
||||||
>
|
>
|
||||||
<VIcon :icon="rightBottomStateDisplay.icon" class="me-1" />
|
<VIcon :icon="rightBottomStateDisplay.icon" class="me-1" />
|
||||||
{{ rightBottomStateDisplay.label }}
|
{{ rightBottomStateDisplay.label }}
|
||||||
|
<VTooltip
|
||||||
|
v-if="executionStateDisplay?.error"
|
||||||
|
activator="parent"
|
||||||
|
location="top"
|
||||||
|
>
|
||||||
|
{{ executionStateDisplay.error }}
|
||||||
|
</VTooltip>
|
||||||
</VCardText>
|
</VCardText>
|
||||||
<VCardText
|
<VCardText
|
||||||
v-else-if="lastUpdateText"
|
v-else-if="lastUpdateText"
|
||||||
|
|||||||
@@ -348,6 +348,26 @@ describe('SubscribeCard display and progress', () => {
|
|||||||
expect(screen.queryByText('已暂停')).not.toBeInTheDocument()
|
expect(screen.queryByText('已暂停')).not.toBeInTheDocument()
|
||||||
expect(container.querySelector('.subscribe-card')).not.toHaveClass('subscribe-card-paused')
|
expect(container.querySelector('.subscribe-card')).not.toHaveClass('subscribe-card-paused')
|
||||||
})
|
})
|
||||||
|
|
||||||
|
it.each([480, 1024])('shows governed execution state and safe failure detail at %ipx', async width => {
|
||||||
|
setViewport(width)
|
||||||
|
await renderCard({
|
||||||
|
execution_status: {
|
||||||
|
batch_id: 'batch-1',
|
||||||
|
can_cancel: true,
|
||||||
|
can_retry: false,
|
||||||
|
current_site_id: 9,
|
||||||
|
error: '站点 9 冷却中',
|
||||||
|
phase: 'waiting_site_budget',
|
||||||
|
requires_reconciliation: false,
|
||||||
|
state: 'waiting_site_budget',
|
||||||
|
updated_at: '2026-09-01T01:00:00+00:00',
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
expect(screen.getByText('等待站点额度')).toBeInTheDocument()
|
||||||
|
expect(screen.getByTitle('站点 9 冷却中')).toBeInTheDocument()
|
||||||
|
})
|
||||||
})
|
})
|
||||||
|
|
||||||
describe('SubscribeCard interaction boundaries', () => {
|
describe('SubscribeCard interaction boundaries', () => {
|
||||||
@@ -443,7 +463,7 @@ describe('SubscribeCard item operations', () => {
|
|||||||
})
|
})
|
||||||
|
|
||||||
it.each([
|
it.each([
|
||||||
['success', 200, { success: true }, 'success', '卡片测试媒体 提交搜索请求成功!'],
|
['success', 200, { success: true }, 'success', '卡片测试媒体 已提交搜索请求'],
|
||||||
['business failure', 200, { message: 'rejected', success: false }, 'error', '请求失败,请稍后重试'],
|
['business failure', 200, { message: 'rejected', success: false }, 'error', '请求失败,请稍后重试'],
|
||||||
['HTTP failure', 500, { message: 'server down', success: false }, 'error', '请求失败,请稍后重试'],
|
['HTTP failure', 500, { message: 'server down', success: false }, 'error', '请求失败,请稍后重试'],
|
||||||
] as const)('reports search %s through the exact endpoint', async (_case, status, response, toastType, message) => {
|
] as const)('reports search %s through the exact endpoint', async (_case, status, response, toastType, message) => {
|
||||||
|
|||||||
@@ -1396,6 +1396,29 @@ export default {
|
|||||||
paused: 'Paused',
|
paused: 'Paused',
|
||||||
cardStatePaused: 'Paused',
|
cardStatePaused: 'Paused',
|
||||||
cardStatePending: 'Pending',
|
cardStatePending: 'Pending',
|
||||||
|
execution: {
|
||||||
|
searchSubmitted: 'Search requested for {name}',
|
||||||
|
batchProgress: '{processed}/{total}',
|
||||||
|
cancel: 'Cancel this batch',
|
||||||
|
cancelRequested: 'Batch cancellation requested',
|
||||||
|
cancelFailed: 'Failed to cancel the search batch',
|
||||||
|
state: {
|
||||||
|
queued: 'Queued',
|
||||||
|
running: 'Running',
|
||||||
|
matching: 'Matching resources',
|
||||||
|
searching: 'Searching sites',
|
||||||
|
waiting_site_budget: 'Waiting for site budget',
|
||||||
|
preparing: 'Preparing download',
|
||||||
|
submitting: 'Submitting download',
|
||||||
|
accepted: 'Accepted by downloader',
|
||||||
|
retryable: 'Waiting to retry',
|
||||||
|
reconcile_required: 'Download result needs review',
|
||||||
|
failed: 'Failed',
|
||||||
|
cancelling: 'Cancelling',
|
||||||
|
cancelled: 'Cancelled',
|
||||||
|
completed: 'Completed',
|
||||||
|
},
|
||||||
|
},
|
||||||
mediaDetail: 'Media Details',
|
mediaDetail: 'Media Details',
|
||||||
fileStatistics: 'File Statistics',
|
fileStatistics: 'File Statistics',
|
||||||
sortTitle: 'Sort',
|
sortTitle: 'Sort',
|
||||||
|
|||||||
@@ -1378,6 +1378,29 @@ export default {
|
|||||||
paused: '暂停',
|
paused: '暂停',
|
||||||
cardStatePaused: '已暂停',
|
cardStatePaused: '已暂停',
|
||||||
cardStatePending: '待定中',
|
cardStatePending: '待定中',
|
||||||
|
execution: {
|
||||||
|
searchSubmitted: '{name} 已提交搜索请求',
|
||||||
|
batchProgress: '{processed}/{total}',
|
||||||
|
cancel: '取消本批次',
|
||||||
|
cancelRequested: '已请求取消搜索批次',
|
||||||
|
cancelFailed: '取消搜索批次失败',
|
||||||
|
state: {
|
||||||
|
queued: '排队中',
|
||||||
|
running: '执行中',
|
||||||
|
matching: '匹配资源',
|
||||||
|
searching: '搜索站点',
|
||||||
|
waiting_site_budget: '等待站点额度',
|
||||||
|
preparing: '准备下载',
|
||||||
|
submitting: '提交下载',
|
||||||
|
accepted: '下载器已接受',
|
||||||
|
retryable: '等待重试',
|
||||||
|
reconcile_required: '需要确认下载结果',
|
||||||
|
failed: '执行失败',
|
||||||
|
cancelling: '取消中',
|
||||||
|
cancelled: '已取消',
|
||||||
|
completed: '执行完成',
|
||||||
|
},
|
||||||
|
},
|
||||||
mediaDetail: '媒体详情',
|
mediaDetail: '媒体详情',
|
||||||
fileStatistics: '文件统计',
|
fileStatistics: '文件统计',
|
||||||
sortTitle: '排序',
|
sortTitle: '排序',
|
||||||
|
|||||||
@@ -1379,6 +1379,29 @@ export default {
|
|||||||
paused: '暫停',
|
paused: '暫停',
|
||||||
cardStatePaused: '已暫停',
|
cardStatePaused: '已暫停',
|
||||||
cardStatePending: '待定中',
|
cardStatePending: '待定中',
|
||||||
|
execution: {
|
||||||
|
searchSubmitted: '{name} 已提交搜索請求',
|
||||||
|
batchProgress: '{processed}/{total}',
|
||||||
|
cancel: '取消本批次',
|
||||||
|
cancelRequested: '已請求取消搜索批次',
|
||||||
|
cancelFailed: '取消搜索批次失敗',
|
||||||
|
state: {
|
||||||
|
queued: '排隊中',
|
||||||
|
running: '執行中',
|
||||||
|
matching: '匹配資源',
|
||||||
|
searching: '搜索站點',
|
||||||
|
waiting_site_budget: '等待站點額度',
|
||||||
|
preparing: '準備下載',
|
||||||
|
submitting: '提交下載',
|
||||||
|
accepted: '下載器已接受',
|
||||||
|
retryable: '等待重試',
|
||||||
|
reconcile_required: '需要確認下載結果',
|
||||||
|
failed: '執行失敗',
|
||||||
|
cancelling: '取消中',
|
||||||
|
cancelled: '已取消',
|
||||||
|
completed: '執行完成',
|
||||||
|
},
|
||||||
|
},
|
||||||
sortTitle: '排序',
|
sortTitle: '排序',
|
||||||
sort: {
|
sort: {
|
||||||
custom: '自定義',
|
custom: '自定義',
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
<script lang="ts" setup>
|
<script lang="ts" setup>
|
||||||
import draggable from 'vuedraggable'
|
import draggable from 'vuedraggable'
|
||||||
import api from '@/api'
|
import api from '@/api'
|
||||||
import type { Subscribe, SubscribeDeletionResult } from '@/api/types'
|
import type { Subscribe, SubscriptionBatchStatus, SubscribeDeletionResult } from '@/api/types'
|
||||||
import NoDataFound from '@/components/states/NoDataFound.vue'
|
import NoDataFound from '@/components/states/NoDataFound.vue'
|
||||||
import SubscribeCard from '@/components/cards/SubscribeCard.vue'
|
import SubscribeCard from '@/components/cards/SubscribeCard.vue'
|
||||||
import ProgressiveCardGrid from '@/components/misc/ProgressiveCardGrid.vue'
|
import ProgressiveCardGrid from '@/components/misc/ProgressiveCardGrid.vue'
|
||||||
@@ -82,6 +82,47 @@ const loadError = ref(false)
|
|||||||
// 数据列表
|
// 数据列表
|
||||||
const dataList = ref<Subscribe[]>([])
|
const dataList = ref<Subscribe[]>([])
|
||||||
|
|
||||||
|
// 最近批次用于展示聚合进度;订阅级状态仍由卡片各自渲染。
|
||||||
|
const executionBatches = ref<SubscriptionBatchStatus[]>([])
|
||||||
|
let executionPollTimer: ReturnType<typeof setTimeout> | undefined
|
||||||
|
let isUnmounted = false
|
||||||
|
|
||||||
|
const activeExecutionStates = new Set([
|
||||||
|
'queued',
|
||||||
|
'running',
|
||||||
|
'matching',
|
||||||
|
'searching',
|
||||||
|
'waiting_site_budget',
|
||||||
|
'preparing',
|
||||||
|
'submitting',
|
||||||
|
'accepted',
|
||||||
|
'cancelling',
|
||||||
|
])
|
||||||
|
|
||||||
|
const visibleExecutionBatch = computed(() => {
|
||||||
|
return (
|
||||||
|
executionBatches.value.find(batch => activeExecutionStates.has(batch.state) || activeExecutionStates.has(batch.phase)) ||
|
||||||
|
executionBatches.value.find(batch => batch.state === 'failed' || batch.state === 'cancelled') ||
|
||||||
|
null
|
||||||
|
)
|
||||||
|
})
|
||||||
|
|
||||||
|
const batchProgress = computed(() => {
|
||||||
|
const batch = visibleExecutionBatch.value
|
||||||
|
if (!batch?.total_count) return 0
|
||||||
|
return Math.min(100, Math.round((batch.processed_count / batch.total_count) * 100))
|
||||||
|
})
|
||||||
|
|
||||||
|
const hasActiveExecution = computed(() => {
|
||||||
|
return (
|
||||||
|
executionBatches.value.some(batch => activeExecutionStates.has(batch.state) || activeExecutionStates.has(batch.phase)) ||
|
||||||
|
dataList.value.some(item => {
|
||||||
|
const execution = item.execution_status
|
||||||
|
return !!execution && (activeExecutionStates.has(execution.state) || activeExecutionStates.has(execution.phase))
|
||||||
|
})
|
||||||
|
)
|
||||||
|
})
|
||||||
|
|
||||||
// 订阅顺序配置
|
// 订阅顺序配置
|
||||||
const orderConfig = ref<{ id: number }[]>([])
|
const orderConfig = ref<{ id: number }[]>([])
|
||||||
|
|
||||||
@@ -325,10 +366,16 @@ async function fetchData(context: KeepAliveRefreshContext = {}) {
|
|||||||
if (showLoading) {
|
if (showLoading) {
|
||||||
loading.value = true
|
loading.value = true
|
||||||
}
|
}
|
||||||
dataList.value = await api.get('subscribe/')
|
const [subscribes, batches] = await Promise.all([
|
||||||
|
api.get<Subscribe[]>('subscribe/'),
|
||||||
|
api.get<SubscriptionBatchStatus[]>('subscribe/execution/batches?limit=10'),
|
||||||
|
])
|
||||||
|
dataList.value = subscribes
|
||||||
|
executionBatches.value = batches
|
||||||
loadError.value = false
|
loadError.value = false
|
||||||
isRefreshed.value = true
|
isRefreshed.value = true
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
|
if (isCancelledRequest(error)) return
|
||||||
console.error(error)
|
console.error(error)
|
||||||
loadError.value = true
|
loadError.value = true
|
||||||
if (isInitialLoad) {
|
if (isInitialLoad) {
|
||||||
@@ -341,6 +388,37 @@ async function fetchData(context: KeepAliveRefreshContext = {}) {
|
|||||||
if (showLoading) {
|
if (showLoading) {
|
||||||
loading.value = false
|
loading.value = false
|
||||||
}
|
}
|
||||||
|
scheduleExecutionPoll()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 仅在页面可见且存在活动执行时轮询,终态后自动停止。
|
||||||
|
function scheduleExecutionPoll() {
|
||||||
|
if (executionPollTimer) {
|
||||||
|
clearTimeout(executionPollTimer)
|
||||||
|
executionPollTimer = undefined
|
||||||
|
}
|
||||||
|
if (isUnmounted || !props.active || !hasActiveExecution.value) return
|
||||||
|
executionPollTimer = setTimeout(() => {
|
||||||
|
void fetchData({ silent: true })
|
||||||
|
}, 2500)
|
||||||
|
}
|
||||||
|
|
||||||
|
// 页面切换触发的请求取消是正常生命周期,不应展示为业务失败。
|
||||||
|
function isCancelledRequest(error: unknown) {
|
||||||
|
return !!error && typeof error === 'object' && 'code' in error && error.code === 'ERR_CANCELED'
|
||||||
|
}
|
||||||
|
|
||||||
|
async function cancelExecutionBatch() {
|
||||||
|
const batch = visibleExecutionBatch.value
|
||||||
|
if (!batch?.can_cancel) return
|
||||||
|
try {
|
||||||
|
await api.put(`subscribe/execution/batches/${batch.batch_id}/cancel`, undefined, { feedback: 'silent' })
|
||||||
|
$toast.success(t('subscribe.execution.cancelRequested'))
|
||||||
|
await fetchData({ silent: true })
|
||||||
|
} catch (error) {
|
||||||
|
console.error(error)
|
||||||
|
$toast.error(t('subscribe.execution.cancelFailed'))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -558,6 +636,7 @@ const errorTitle = computed(() => {
|
|||||||
})
|
})
|
||||||
|
|
||||||
onMounted(async () => {
|
onMounted(async () => {
|
||||||
|
isUnmounted = false
|
||||||
await loadSubscribeOrderConfig()
|
await loadSubscribeOrderConfig()
|
||||||
await fetchData()
|
await fetchData()
|
||||||
if (props.subid) {
|
if (props.subid) {
|
||||||
@@ -570,6 +649,16 @@ onMounted(async () => {
|
|||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
|
watch(
|
||||||
|
() => props.active,
|
||||||
|
() => scheduleExecutionPoll(),
|
||||||
|
)
|
||||||
|
|
||||||
|
onBeforeUnmount(() => {
|
||||||
|
isUnmounted = true
|
||||||
|
if (executionPollTimer) clearTimeout(executionPollTimer)
|
||||||
|
})
|
||||||
|
|
||||||
useKeepAliveRefresh(fetchData, {
|
useKeepAliveRefresh(fetchData, {
|
||||||
active: computed(() => props.active),
|
active: computed(() => props.active),
|
||||||
})
|
})
|
||||||
@@ -593,6 +682,47 @@ defineExpose({
|
|||||||
{{ t('subscribe.requestFailed') }}
|
{{ t('subscribe.requestFailed') }}
|
||||||
</VAlert>
|
</VAlert>
|
||||||
|
|
||||||
|
<VAlert
|
||||||
|
v-if="visibleExecutionBatch"
|
||||||
|
:color="visibleExecutionBatch.state === 'failed' ? 'error' : visibleExecutionBatch.state === 'cancelled' ? 'secondary' : 'info'"
|
||||||
|
variant="tonal"
|
||||||
|
class="subscribe-execution-banner mb-4 mx-2 py-2"
|
||||||
|
>
|
||||||
|
<div class="d-flex min-w-0 align-center gap-3">
|
||||||
|
<VIcon
|
||||||
|
: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="d-flex min-w-0 align-center justify-space-between gap-2 text-body-2 font-weight-medium">
|
||||||
|
<span class="text-truncate">
|
||||||
|
{{ t(`subscribe.execution.state.${visibleExecutionBatch.phase}`) }}
|
||||||
|
</span>
|
||||||
|
<span class="flex-shrink-0">
|
||||||
|
{{ t('subscribe.execution.batchProgress', { processed: visibleExecutionBatch.processed_count, total: visibleExecutionBatch.total_count }) }}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<VProgressLinear
|
||||||
|
:model-value="batchProgress"
|
||||||
|
:indeterminate="visibleExecutionBatch.total_count === 0 && activeExecutionStates.has(visibleExecutionBatch.state)"
|
||||||
|
height="3"
|
||||||
|
class="mt-2"
|
||||||
|
/>
|
||||||
|
<div v-if="visibleExecutionBatch.error" class="text-caption mt-1 text-truncate">
|
||||||
|
{{ visibleExecutionBatch.error }}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<IconBtn
|
||||||
|
v-if="visibleExecutionBatch.can_cancel"
|
||||||
|
size="small"
|
||||||
|
:title="t('subscribe.execution.cancel')"
|
||||||
|
@click="cancelExecutionBatch"
|
||||||
|
>
|
||||||
|
<VIcon icon="mdi-close-circle-outline" />
|
||||||
|
</IconBtn>
|
||||||
|
</div>
|
||||||
|
</VAlert>
|
||||||
|
|
||||||
<VAlert v-if="sortMode" color="warning" variant="tonal" class="mb-4 mx-2 py-0 app-surface-static">
|
<VAlert v-if="sortMode" color="warning" variant="tonal" class="mb-4 mx-2 py-0 app-surface-static">
|
||||||
<div class="d-flex flex-wrap align-center justify-space-between gap-2 py-5">
|
<div class="d-flex flex-wrap align-center justify-space-between gap-2 py-5">
|
||||||
<span>{{ t('common.sortModeHint') }}</span>
|
<span>{{ t('common.sortModeHint') }}</span>
|
||||||
|
|||||||
@@ -1,12 +1,14 @@
|
|||||||
import type { Subscribe } from '@/api/types'
|
import type { Subscribe, SubscriptionBatchStatus } from '@/api/types'
|
||||||
import SubscribeListView from '@/views/subscribe/SubscribeListView.vue'
|
import SubscribeListView from '@/views/subscribe/SubscribeListView.vue'
|
||||||
import { fireEvent, screen, waitFor } from '@testing-library/vue'
|
import { fireEvent, screen, waitFor } from '@testing-library/vue'
|
||||||
import { createSubscribe } from '@tests/support/factories/subscribe'
|
import { createSubscribe } from '@tests/support/factories/subscribe'
|
||||||
import {
|
import {
|
||||||
deleteSubscribeByIdHandler,
|
deleteSubscribeByIdHandler,
|
||||||
|
cancelSubscriptionExecutionBatchHandler,
|
||||||
saveSubscribeOrderConfigHandler,
|
saveSubscribeOrderConfigHandler,
|
||||||
subscribeApiUrls,
|
subscribeApiUrls,
|
||||||
subscribeListHandler,
|
subscribeListHandler,
|
||||||
|
subscriptionExecutionBatchesHandler,
|
||||||
subscribeOrderConfigHandler,
|
subscribeOrderConfigHandler,
|
||||||
updateSubscribeStatusHandler,
|
updateSubscribeStatusHandler,
|
||||||
type SubscribeMediaType,
|
type SubscribeMediaType,
|
||||||
@@ -217,6 +219,8 @@ const SubscribeListHost = defineComponent({
|
|||||||
|
|
||||||
interface RenderListOptions {
|
interface RenderListOptions {
|
||||||
active?: boolean
|
active?: boolean
|
||||||
|
batchResponse?: SubscriptionBatchStatus[]
|
||||||
|
onBatchRequest?: (url: URL) => void
|
||||||
keyword?: string
|
keyword?: string
|
||||||
listResponse?: Subscribe[]
|
listResponse?: Subscribe[]
|
||||||
listStatus?: number
|
listStatus?: number
|
||||||
@@ -238,6 +242,7 @@ 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),
|
||||||
)
|
)
|
||||||
|
|
||||||
return renderWithProviders(SubscribeListHost, {
|
return renderWithProviders(SubscribeListHost, {
|
||||||
@@ -313,6 +318,33 @@ describe('SubscribeListView loading and filtering', () => {
|
|||||||
expect(screen.getByTestId('sort-by-state')).toHaveTextContent('date')
|
expect(screen.getByTestId('sort-by-state')).toHaveTextContent('date')
|
||||||
})
|
})
|
||||||
|
|
||||||
|
it('shows batch progress and sends cancellation to the stable batch endpoint', async () => {
|
||||||
|
const cancelRequested = vi.fn()
|
||||||
|
const batch: SubscriptionBatchStatus = {
|
||||||
|
batch_id: 'batch-42',
|
||||||
|
can_cancel: true,
|
||||||
|
cancelled_count: 0,
|
||||||
|
created_at: '2026-09-01T00:00:00+00:00',
|
||||||
|
failed_count: 0,
|
||||||
|
finished_count: 1,
|
||||||
|
phase: 'searching',
|
||||||
|
processed_count: 1,
|
||||||
|
source: 'manual',
|
||||||
|
state: 'running',
|
||||||
|
total_count: 3,
|
||||||
|
updated_at: '2026-09-01T00:01:00+00:00',
|
||||||
|
}
|
||||||
|
server.use(cancelSubscriptionExecutionBatchHandler(batch.batch_id, { success: true }, 200, cancelRequested))
|
||||||
|
await renderList({ batchResponse: [batch], listResponse: [movie(1, 'Own movie')] })
|
||||||
|
|
||||||
|
expect(await screen.findByText('搜索站点')).toBeInTheDocument()
|
||||||
|
expect(screen.getByText('1/3')).toBeInTheDocument()
|
||||||
|
await fireEvent.click(screen.getByTitle('取消本批次'))
|
||||||
|
|
||||||
|
await waitFor(() => expect(cancelRequested).toHaveBeenCalledOnce())
|
||||||
|
expect(mocks.toastSuccess).toHaveBeenCalledWith('已请求取消搜索批次')
|
||||||
|
})
|
||||||
|
|
||||||
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')],
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ import type {
|
|||||||
MediaInfo,
|
MediaInfo,
|
||||||
Site,
|
Site,
|
||||||
Subscribe,
|
Subscribe,
|
||||||
|
SubscriptionBatchStatus,
|
||||||
SubscribeShare,
|
SubscribeShare,
|
||||||
SubscribeShareStatistics,
|
SubscribeShareStatistics,
|
||||||
TransferDirectoryConf,
|
TransferDirectoryConf,
|
||||||
@@ -37,6 +38,9 @@ export const subscribeApiUrls = {
|
|||||||
deleteById: (id: number) => new URL(`subscribe/${id}`, API_BASE_URL).href,
|
deleteById: (id: number) => new URL(`subscribe/${id}`, API_BASE_URL).href,
|
||||||
deleteByMedia: (mediaId: string) => new URL(`subscribe/media/${mediaId}`, API_BASE_URL).href,
|
deleteByMedia: (mediaId: string) => new URL(`subscribe/media/${mediaId}`, API_BASE_URL).href,
|
||||||
details: (id: number) => new URL(`subscribe/${id}`, API_BASE_URL).href,
|
details: (id: number) => new URL(`subscribe/${id}`, API_BASE_URL).href,
|
||||||
|
executionBatches: new URL('subscribe/execution/batches', API_BASE_URL).href,
|
||||||
|
executionBatchCancel: (batchId: string) =>
|
||||||
|
new URL(`subscribe/execution/batches/${batchId}/cancel`, API_BASE_URL).href,
|
||||||
directories: new URL('system/setting/public/Directories', API_BASE_URL).href,
|
directories: new URL('system/setting/public/Directories', API_BASE_URL).href,
|
||||||
downloaders: new URL('download/clients', API_BASE_URL).href,
|
downloaders: new URL('download/clients', API_BASE_URL).href,
|
||||||
episodeGroups: (tmdbId: number) => new URL(`media/groups/${tmdbId}`, API_BASE_URL).href,
|
episodeGroups: (tmdbId: number) => new URL(`media/groups/${tmdbId}`, API_BASE_URL).href,
|
||||||
@@ -88,6 +92,29 @@ export function subscribeListHandler(
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function subscriptionExecutionBatchesHandler(
|
||||||
|
response: SubscriptionBatchStatus[] = [],
|
||||||
|
status = 200,
|
||||||
|
onRequest: (url: URL) => void = () => {},
|
||||||
|
) {
|
||||||
|
return http.get(subscribeApiUrls.executionBatches, ({ request }) => {
|
||||||
|
onRequest(new URL(request.url))
|
||||||
|
return dataResponse(response as unknown as JsonBodyType, status)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
export function cancelSubscriptionExecutionBatchHandler(
|
||||||
|
batchId: string,
|
||||||
|
response: SubscribeMutationResponse = { success: true },
|
||||||
|
status = 200,
|
||||||
|
onRequest: (url: URL) => void = () => {},
|
||||||
|
) {
|
||||||
|
return http.put(subscribeApiUrls.executionBatchCancel(batchId), ({ request }) => {
|
||||||
|
onRequest(new URL(request.url))
|
||||||
|
return mutationResponse(response, status)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
export function popularSubscribesHandler(
|
export function popularSubscribesHandler(
|
||||||
response: MediaInfo[] = [],
|
response: MediaInfo[] = [],
|
||||||
status = 200,
|
status = 200,
|
||||||
|
|||||||
Reference in New Issue
Block a user