mirror of
https://github.com/jxxghp/MoviePilot-Frontend.git
synced 2026-08-28 19:47:49 +08:00
fix(download): confirm destructive task deletion (#718)
This commit is contained in:
@@ -511,9 +511,6 @@
|
||||
"src/composables/useConfirm.ts": {
|
||||
"@typescript-eslint/no-explicit-any": {
|
||||
"count": 1
|
||||
},
|
||||
"@typescript-eslint/no-unused-vars": {
|
||||
"count": 1
|
||||
}
|
||||
},
|
||||
"src/composables/useDynamicButton.ts": {
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
import api from '@/api'
|
||||
import type { DownloadingInfo } from '@/api/types'
|
||||
import { formatFileSize } from '@/@core/utils/formatters'
|
||||
import { useConfirm } from '@/composables/useConfirm'
|
||||
import { useGlobalSettingsStore } from '@/stores'
|
||||
import { getDisplayImageUrl } from '@/utils/imageUtils'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
@@ -19,11 +20,13 @@ const props = defineProps({
|
||||
})
|
||||
|
||||
const { t } = useI18n()
|
||||
const createConfirm = useConfirm()
|
||||
const globalSettingsStore = useGlobalSettingsStore()
|
||||
|
||||
// 卡片在删除成功后就地隐藏,等待外层轮询同步任务列表。
|
||||
const cardState = ref(true)
|
||||
const pendingAction = ref<'delete' | 'toggle' | null>(null)
|
||||
const deleteConfirmationPending = ref(false)
|
||||
const imageLoadError = ref(false)
|
||||
const media = computed(() => props.info?.media ?? {})
|
||||
|
||||
@@ -135,9 +138,24 @@ async function toggleDownload() {
|
||||
}
|
||||
}
|
||||
|
||||
/** 删除当前下载任务,并仅在业务请求成功后隐藏卡片。 */
|
||||
/** 确认删除当前下载任务及对应文件,并仅在业务请求成功后隐藏卡片。 */
|
||||
async function deleteDownload() {
|
||||
if (pendingAction.value) return
|
||||
if (pendingAction.value || deleteConfirmationPending.value) return
|
||||
|
||||
deleteConfirmationPending.value = true
|
||||
try {
|
||||
const confirmed = await createConfirm({
|
||||
type: 'warn',
|
||||
title: t('common.confirm'),
|
||||
content: t('downloading.confirmDelete', {
|
||||
name: props.info?.title || props.info?.name || t('common.unknown'),
|
||||
}),
|
||||
confirmText: t('common.delete'),
|
||||
})
|
||||
if (!confirmed || pendingAction.value) return
|
||||
} finally {
|
||||
deleteConfirmationPending.value = false
|
||||
}
|
||||
|
||||
pendingAction.value = 'delete'
|
||||
try {
|
||||
@@ -274,7 +292,7 @@ async function deleteDownload() {
|
||||
:aria-label="t('common.delete')"
|
||||
class="downloading-card__delete-action"
|
||||
color="on-surface"
|
||||
:disabled="pendingAction === 'toggle'"
|
||||
:disabled="pendingAction === 'toggle' || deleteConfirmationPending"
|
||||
:loading="pendingAction === 'delete'"
|
||||
icon
|
||||
size="small"
|
||||
|
||||
@@ -7,6 +7,14 @@ import { server } from '@tests/support/msw/server'
|
||||
import { defineComponent, h } from 'vue'
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
|
||||
const mocks = vi.hoisted(() => ({
|
||||
confirm: vi.fn(),
|
||||
}))
|
||||
|
||||
vi.mock('@/composables/useConfirm', () => ({
|
||||
useConfirm: () => mocks.confirm,
|
||||
}))
|
||||
|
||||
/** 扩展卡片会消费但公共下载类型尚未声明的站点字段。 */
|
||||
interface DownloadingCardInfo extends DownloadingInfo {
|
||||
site_name?: string
|
||||
@@ -58,6 +66,7 @@ function actionButtons(container: Element) {
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
mocks.confirm.mockReset().mockResolvedValue(true)
|
||||
vi.spyOn(console, 'error').mockImplementation(() => {})
|
||||
})
|
||||
|
||||
@@ -308,6 +317,43 @@ describe('DownloadingCard display and pause state', () => {
|
||||
})
|
||||
|
||||
describe('DownloadingCard deletion', () => {
|
||||
it('opens only one confirmation while the user decision is pending', async () => {
|
||||
let resolveConfirm: (value: boolean) => void = () => {}
|
||||
mocks.confirm.mockReturnValue(
|
||||
new Promise<boolean>(resolve => {
|
||||
resolveConfirm = resolve
|
||||
}),
|
||||
)
|
||||
const { container } = await renderCard()
|
||||
const { deleteButton } = actionButtons(container)
|
||||
|
||||
await fireEvent.click(deleteButton)
|
||||
await fireEvent.click(deleteButton)
|
||||
|
||||
expect(mocks.confirm).toHaveBeenCalledOnce()
|
||||
resolveConfirm(false)
|
||||
})
|
||||
|
||||
it('explains the destructive scope and does not request deletion when confirmation is cancelled', async () => {
|
||||
const requested = vi.fn()
|
||||
mocks.confirm.mockResolvedValue(false)
|
||||
server.use(deleteDownloadHandler('hash-1', { success: true }, 200, requested))
|
||||
const { container } = await renderCard()
|
||||
|
||||
await fireEvent.click(actionButtons(container).deleteButton)
|
||||
|
||||
await waitFor(() =>
|
||||
expect(mocks.confirm).toHaveBeenCalledWith({
|
||||
type: 'warn',
|
||||
title: '确认',
|
||||
content: '确认从下载器删除任务“下载任务标题”及对应下载文件吗?',
|
||||
confirmText: '删除',
|
||||
}),
|
||||
)
|
||||
expect(requested).not.toHaveBeenCalled()
|
||||
expect(container.querySelector('.downloading-card')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('keeps the card visible when HTTP 200 reports business failure', async () => {
|
||||
const requested = vi.fn()
|
||||
server.use(deleteDownloadHandler('hash-1', { success: false, message: '任务仍在运行' }, 200, requested))
|
||||
|
||||
@@ -0,0 +1,22 @@
|
||||
import { useConfirm } from '@/composables/useConfirm'
|
||||
import { fireEvent, screen, waitFor } from '@testing-library/vue'
|
||||
import { describe, expect, it } from 'vitest'
|
||||
|
||||
describe('useConfirm', () => {
|
||||
it('settles an implicit dialog close as cancellation and allows the next confirmation', async () => {
|
||||
const createConfirm = useConfirm()
|
||||
const firstResult = createConfirm({ content: '第一次确认' })
|
||||
|
||||
await screen.findByText('第一次确认')
|
||||
await fireEvent.keyDown(document, { key: 'Escape' })
|
||||
|
||||
await expect(firstResult).resolves.toBe(false)
|
||||
await waitFor(() => expect(screen.queryByText('第一次确认')).not.toBeInTheDocument())
|
||||
|
||||
const secondResult = createConfirm({ content: '第二次确认', confirmText: '继续' })
|
||||
await screen.findByText('第二次确认')
|
||||
await fireEvent.click(screen.getByRole('button', { name: '继续' }))
|
||||
|
||||
await expect(secondResult).resolves.toBe(true)
|
||||
})
|
||||
})
|
||||
@@ -1,4 +1,3 @@
|
||||
import { ref } from 'vue'
|
||||
import { createApp } from 'vue'
|
||||
import i18n from '@/plugins/i18n'
|
||||
import vuetify from '@/plugins/vuetify'
|
||||
@@ -18,16 +17,27 @@ export interface ConfirmOptions {
|
||||
/** 可注入到联邦插件中的确认弹窗调用入口。 */
|
||||
export type ConfirmDialogFn = (options?: ConfirmOptions) => Promise<boolean>
|
||||
|
||||
let resolvePromise: ((value: boolean) => void) | null = null
|
||||
|
||||
/** 创建主应用确认弹窗并等待用户选择结果。 */
|
||||
async function createConfirmDialog(options: ConfirmOptions = {}) {
|
||||
return new Promise<boolean>(resolve => {
|
||||
resolvePromise = resolve
|
||||
|
||||
// 创建容器
|
||||
const container = document.createElement('div')
|
||||
document.body.appendChild(container)
|
||||
let app: ReturnType<typeof createApp> | null = null
|
||||
let settled = false
|
||||
|
||||
const cleanup = () => {
|
||||
app?.unmount()
|
||||
container.remove()
|
||||
}
|
||||
|
||||
// 遮罩、Esc、关闭按钮和取消都属于同一种否定结果,且每个弹窗只能结算一次。
|
||||
const settle = (value: boolean) => {
|
||||
if (settled) return
|
||||
settled = true
|
||||
resolve(value)
|
||||
cleanup()
|
||||
}
|
||||
|
||||
// 处理国际化
|
||||
const i18nOptions = {
|
||||
@@ -38,21 +48,17 @@ async function createConfirmDialog(options: ConfirmOptions = {}) {
|
||||
}
|
||||
|
||||
// 创建应用实例
|
||||
const app = createApp(ConfirmDialog, {
|
||||
app = createApp(ConfirmDialog, {
|
||||
modelValue: true,
|
||||
...i18nOptions,
|
||||
'onUpdate:modelValue': (val: boolean) => {
|
||||
if (!val) {
|
||||
cleanup()
|
||||
}
|
||||
if (!val) settle(false)
|
||||
},
|
||||
onConfirm: () => {
|
||||
resolvePromise?.(true)
|
||||
cleanup()
|
||||
settle(true)
|
||||
},
|
||||
onCancel: () => {
|
||||
resolvePromise?.(false)
|
||||
cleanup()
|
||||
settle(false)
|
||||
},
|
||||
})
|
||||
|
||||
@@ -65,12 +71,6 @@ async function createConfirmDialog(options: ConfirmOptions = {}) {
|
||||
|
||||
// 挂载应用
|
||||
app.mount(container)
|
||||
|
||||
// 清理函数
|
||||
const cleanup = () => {
|
||||
app.unmount()
|
||||
document.body.removeChild(container)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
|
||||
@@ -1443,6 +1443,7 @@ export default {
|
||||
title: 'Downloading',
|
||||
noTask: 'No Task',
|
||||
noTaskDescription: 'Downloading tasks will be displayed here.',
|
||||
confirmDelete: 'Delete task "{name}" and its associated download files from the downloader?',
|
||||
},
|
||||
resource: {
|
||||
searchResults: 'Resource Search Results',
|
||||
|
||||
@@ -1432,6 +1432,7 @@ export default {
|
||||
title: '下载',
|
||||
noTask: '没有任务',
|
||||
noTaskDescription: '正在下载的任务将会显示在这里。',
|
||||
confirmDelete: '确认从下载器删除任务“{name}”及对应下载文件吗?',
|
||||
},
|
||||
resource: {
|
||||
searchResults: '资源搜索结果',
|
||||
|
||||
@@ -1430,6 +1430,7 @@ export default {
|
||||
title: '下載',
|
||||
noTask: '沒有任務',
|
||||
noTaskDescription: '正在下載的任務將會顯示在這裡。',
|
||||
confirmDelete: '確認從下載器刪除任務「{name}」及對應下載檔案嗎?',
|
||||
},
|
||||
resource: {
|
||||
searchResults: '資源搜索結果',
|
||||
|
||||
Reference in New Issue
Block a user