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
on:
workflow_dispatch:
pull_request:
branches:
- v3
+36
View File
@@ -24,6 +24,40 @@ export interface MediaSourceInfo {
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 {
// 媒体数据源
@@ -142,6 +176,8 @@ export interface Subscribe {
downloader?: 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 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) {
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 rightBottomStateDisplay = computed(() => {
if (executionStateDisplay.value) {
return executionStateDisplay.value
}
if (subscribeState.value === 'S') {
return { icon: 'mdi-pause-circle', label: t('subscribe.cardStatePaused') }
}
@@ -103,6 +134,9 @@ const rightBottomStateDisplay = computed(() => {
// 移动端紧凑卡片的状态展示,颜色统一映射到 Vuetify 全局主题 token。
const compactStateDisplay = computed(() => {
if (executionStateDisplay.value) {
return executionStateDisplay.value
}
if (subscribeState.value === 'S') {
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 文案:
// - 普通订阅:「已下载 X · 共 Y 集」
// - 洗版订阅:「已下载 X · 已洗版 N · 共 Y 集」
@@ -240,7 +278,8 @@ async function removeSubscribe() {
async function searchSubscribe() {
try {
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) {
$toast.error(t('subscribe.requestFailed'))
console.log(e)
@@ -651,7 +690,7 @@ function handleCardClick() {
<div
class="subscribe-card-mobile-state"
:style="{ color: `rgb(var(--v-theme-${compactStateDisplay.color}))` }"
:title="compactStateDisplay.label"
:title="executionStateDisplay?.error || compactStateDisplay.label"
:aria-label="compactStateDisplay.label"
>
<VIcon
@@ -659,12 +698,16 @@ function handleCardClick() {
:data-subscribe-state-icon="compactStateDisplay.icon"
size="16"
/>
<span
v-if="subscribeProgressText || musicSubscribeMeta"
class="subscribe-card-mobile-progress-text"
>
{{ subscribeProgressText || musicSubscribeMeta?.text }}
<span v-if="compactStateText" class="subscribe-card-mobile-progress-text">
{{ compactStateText }}
</span>
<VTooltip
v-if="executionStateDisplay?.error"
activator="parent"
location="top"
>
{{ executionStateDisplay.error }}
</VTooltip>
</div>
<IconBtn v-if="!props.sortable" class="subscribe-card-mobile-menu" size="small" @click.stop>
@@ -787,9 +830,18 @@ function handleCardClick() {
<VCardText
v-if="rightBottomStateDisplay"
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" />
{{ rightBottomStateDisplay.label }}
<VTooltip
v-if="executionStateDisplay?.error"
activator="parent"
location="top"
>
{{ executionStateDisplay.error }}
</VTooltip>
</VCardText>
<VCardText
v-else-if="lastUpdateText"
@@ -348,6 +348,26 @@ describe('SubscribeCard display and progress', () => {
expect(screen.queryByText('已暂停')).not.toBeInTheDocument()
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', () => {
@@ -443,7 +463,7 @@ describe('SubscribeCard item operations', () => {
})
it.each([
['success', 200, { success: true }, 'success', '卡片测试媒体 提交搜索请求成功!'],
['success', 200, { success: true }, 'success', '卡片测试媒体 提交搜索请求'],
['business failure', 200, { message: 'rejected', 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) => {
+23
View File
@@ -1396,6 +1396,29 @@ export default {
paused: 'Paused',
cardStatePaused: 'Paused',
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',
fileStatistics: 'File Statistics',
sortTitle: 'Sort',
+23
View File
@@ -1378,6 +1378,29 @@ export default {
paused: '暂停',
cardStatePaused: '已暂停',
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: '媒体详情',
fileStatistics: '文件统计',
sortTitle: '排序',
+23
View File
@@ -1379,6 +1379,29 @@ export default {
paused: '暫停',
cardStatePaused: '已暫停',
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: '排序',
sort: {
custom: '自定義',
+132 -2
View File
@@ -1,7 +1,7 @@
<script lang="ts" setup>
import draggable from 'vuedraggable'
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 SubscribeCard from '@/components/cards/SubscribeCard.vue'
import ProgressiveCardGrid from '@/components/misc/ProgressiveCardGrid.vue'
@@ -82,6 +82,47 @@ const loadError = ref(false)
//
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 }[]>([])
@@ -325,10 +366,16 @@ async function fetchData(context: KeepAliveRefreshContext = {}) {
if (showLoading) {
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
isRefreshed.value = true
} catch (error) {
if (isCancelledRequest(error)) return
console.error(error)
loadError.value = true
if (isInitialLoad) {
@@ -341,6 +388,37 @@ async function fetchData(context: KeepAliveRefreshContext = {}) {
if (showLoading) {
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 () => {
isUnmounted = false
await loadSubscribeOrderConfig()
await fetchData()
if (props.subid) {
@@ -570,6 +649,16 @@ onMounted(async () => {
}
})
watch(
() => props.active,
() => scheduleExecutionPoll(),
)
onBeforeUnmount(() => {
isUnmounted = true
if (executionPollTimer) clearTimeout(executionPollTimer)
})
useKeepAliveRefresh(fetchData, {
active: computed(() => props.active),
})
@@ -593,6 +682,47 @@ defineExpose({
{{ t('subscribe.requestFailed') }}
</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">
<div class="d-flex flex-wrap align-center justify-space-between gap-2 py-5">
<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 { fireEvent, screen, waitFor } from '@testing-library/vue'
import { createSubscribe } from '@tests/support/factories/subscribe'
import {
deleteSubscribeByIdHandler,
cancelSubscriptionExecutionBatchHandler,
saveSubscribeOrderConfigHandler,
subscribeApiUrls,
subscribeListHandler,
subscriptionExecutionBatchesHandler,
subscribeOrderConfigHandler,
updateSubscribeStatusHandler,
type SubscribeMediaType,
@@ -217,6 +219,8 @@ const SubscribeListHost = defineComponent({
interface RenderListOptions {
active?: boolean
batchResponse?: SubscriptionBatchStatus[]
onBatchRequest?: (url: URL) => void
keyword?: string
listResponse?: Subscribe[]
listStatus?: number
@@ -238,6 +242,7 @@ async function renderList(options: RenderListOptions = {}) {
server.use(
subscribeOrderConfigHandler(type, options.orderValue, options.orderStatus ?? 200, options.onOrderRequest),
subscribeListHandler(options.listResponse ?? [], options.listStatus ?? 200, options.onListRequest),
subscriptionExecutionBatchesHandler(options.batchResponse ?? [], 200, options.onBatchRequest),
)
return renderWithProviders(SubscribeListHost, {
@@ -313,6 +318,33 @@ describe('SubscribeListView loading and filtering', () => {
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 () => {
await renderList({
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,
Site,
Subscribe,
SubscriptionBatchStatus,
SubscribeShare,
SubscribeShareStatistics,
TransferDirectoryConf,
@@ -37,6 +38,9 @@ export const subscribeApiUrls = {
deleteById: (id: number) => new URL(`subscribe/${id}`, 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,
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,
downloaders: new URL('download/clients', 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(
response: MediaInfo[] = [],
status = 200,