Codex/subscription governance (#744)

* feat(subscribe): show execution governance status

* ci(frontend): allow manual test validation
This commit is contained in:
jxxghp
2026-09-02 19:53:11 +08:00
committed by GitHub
parent 29fd83edec
commit 3a87974442
10 changed files with 378 additions and 11 deletions
+1
View File
@@ -1,6 +1,7 @@
name: Frontend Tests name: Frontend Tests
on: on:
workflow_dispatch:
pull_request: pull_request:
branches: branches:
- v3 - v3
+36
View File
@@ -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
} }
/** 订阅删除成功后的机器可判断结果。 */ /** 订阅删除成功后的机器可判断结果。 */
+59 -7
View File
@@ -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) => {
+23
View File
@@ -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',
+23
View File
@@ -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: '排序',
+23
View File
@@ -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: '自定義',
+132 -2
View File
@@ -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')],
+27
View File
@@ -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,