mirror of
https://github.com/jxxghp/MoviePilot-Frontend.git
synced 2026-09-04 23:18:44 +08:00
fix(download): confirm destructive task deletion (#718)
This commit is contained in:
@@ -511,9 +511,6 @@
|
|||||||
"src/composables/useConfirm.ts": {
|
"src/composables/useConfirm.ts": {
|
||||||
"@typescript-eslint/no-explicit-any": {
|
"@typescript-eslint/no-explicit-any": {
|
||||||
"count": 1
|
"count": 1
|
||||||
},
|
|
||||||
"@typescript-eslint/no-unused-vars": {
|
|
||||||
"count": 1
|
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"src/composables/useDynamicButton.ts": {
|
"src/composables/useDynamicButton.ts": {
|
||||||
|
|||||||
@@ -2,6 +2,7 @@
|
|||||||
import api from '@/api'
|
import api from '@/api'
|
||||||
import type { DownloadingInfo } from '@/api/types'
|
import type { DownloadingInfo } from '@/api/types'
|
||||||
import { formatFileSize } from '@/@core/utils/formatters'
|
import { formatFileSize } from '@/@core/utils/formatters'
|
||||||
|
import { useConfirm } from '@/composables/useConfirm'
|
||||||
import { useGlobalSettingsStore } from '@/stores'
|
import { useGlobalSettingsStore } from '@/stores'
|
||||||
import { getDisplayImageUrl } from '@/utils/imageUtils'
|
import { getDisplayImageUrl } from '@/utils/imageUtils'
|
||||||
import { useI18n } from 'vue-i18n'
|
import { useI18n } from 'vue-i18n'
|
||||||
@@ -19,11 +20,13 @@ const props = defineProps({
|
|||||||
})
|
})
|
||||||
|
|
||||||
const { t } = useI18n()
|
const { t } = useI18n()
|
||||||
|
const createConfirm = useConfirm()
|
||||||
const globalSettingsStore = useGlobalSettingsStore()
|
const globalSettingsStore = useGlobalSettingsStore()
|
||||||
|
|
||||||
// 卡片在删除成功后就地隐藏,等待外层轮询同步任务列表。
|
// 卡片在删除成功后就地隐藏,等待外层轮询同步任务列表。
|
||||||
const cardState = ref(true)
|
const cardState = ref(true)
|
||||||
const pendingAction = ref<'delete' | 'toggle' | null>(null)
|
const pendingAction = ref<'delete' | 'toggle' | null>(null)
|
||||||
|
const deleteConfirmationPending = ref(false)
|
||||||
const imageLoadError = ref(false)
|
const imageLoadError = ref(false)
|
||||||
const media = computed(() => props.info?.media ?? {})
|
const media = computed(() => props.info?.media ?? {})
|
||||||
|
|
||||||
@@ -135,9 +138,24 @@ async function toggleDownload() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/** 删除当前下载任务,并仅在业务请求成功后隐藏卡片。 */
|
/** 确认删除当前下载任务及对应文件,并仅在业务请求成功后隐藏卡片。 */
|
||||||
async function deleteDownload() {
|
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'
|
pendingAction.value = 'delete'
|
||||||
try {
|
try {
|
||||||
@@ -274,7 +292,7 @@ async function deleteDownload() {
|
|||||||
:aria-label="t('common.delete')"
|
:aria-label="t('common.delete')"
|
||||||
class="downloading-card__delete-action"
|
class="downloading-card__delete-action"
|
||||||
color="on-surface"
|
color="on-surface"
|
||||||
:disabled="pendingAction === 'toggle'"
|
:disabled="pendingAction === 'toggle' || deleteConfirmationPending"
|
||||||
:loading="pendingAction === 'delete'"
|
:loading="pendingAction === 'delete'"
|
||||||
icon
|
icon
|
||||||
size="small"
|
size="small"
|
||||||
|
|||||||
@@ -7,6 +7,14 @@ import { server } from '@tests/support/msw/server'
|
|||||||
import { defineComponent, h } from 'vue'
|
import { defineComponent, h } from 'vue'
|
||||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
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 {
|
interface DownloadingCardInfo extends DownloadingInfo {
|
||||||
site_name?: string
|
site_name?: string
|
||||||
@@ -58,6 +66,7 @@ function actionButtons(container: Element) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
beforeEach(() => {
|
beforeEach(() => {
|
||||||
|
mocks.confirm.mockReset().mockResolvedValue(true)
|
||||||
vi.spyOn(console, 'error').mockImplementation(() => {})
|
vi.spyOn(console, 'error').mockImplementation(() => {})
|
||||||
})
|
})
|
||||||
|
|
||||||
@@ -308,6 +317,43 @@ describe('DownloadingCard display and pause state', () => {
|
|||||||
})
|
})
|
||||||
|
|
||||||
describe('DownloadingCard deletion', () => {
|
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 () => {
|
it('keeps the card visible when HTTP 200 reports business failure', async () => {
|
||||||
const requested = vi.fn()
|
const requested = vi.fn()
|
||||||
server.use(deleteDownloadHandler('hash-1', { success: false, message: '任务仍在运行' }, 200, requested))
|
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 { createApp } from 'vue'
|
||||||
import i18n from '@/plugins/i18n'
|
import i18n from '@/plugins/i18n'
|
||||||
import vuetify from '@/plugins/vuetify'
|
import vuetify from '@/plugins/vuetify'
|
||||||
@@ -18,16 +17,27 @@ export interface ConfirmOptions {
|
|||||||
/** 可注入到联邦插件中的确认弹窗调用入口。 */
|
/** 可注入到联邦插件中的确认弹窗调用入口。 */
|
||||||
export type ConfirmDialogFn = (options?: ConfirmOptions) => Promise<boolean>
|
export type ConfirmDialogFn = (options?: ConfirmOptions) => Promise<boolean>
|
||||||
|
|
||||||
let resolvePromise: ((value: boolean) => void) | null = null
|
|
||||||
|
|
||||||
/** 创建主应用确认弹窗并等待用户选择结果。 */
|
/** 创建主应用确认弹窗并等待用户选择结果。 */
|
||||||
async function createConfirmDialog(options: ConfirmOptions = {}) {
|
async function createConfirmDialog(options: ConfirmOptions = {}) {
|
||||||
return new Promise<boolean>(resolve => {
|
return new Promise<boolean>(resolve => {
|
||||||
resolvePromise = resolve
|
|
||||||
|
|
||||||
// 创建容器
|
// 创建容器
|
||||||
const container = document.createElement('div')
|
const container = document.createElement('div')
|
||||||
document.body.appendChild(container)
|
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 = {
|
const i18nOptions = {
|
||||||
@@ -38,21 +48,17 @@ async function createConfirmDialog(options: ConfirmOptions = {}) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// 创建应用实例
|
// 创建应用实例
|
||||||
const app = createApp(ConfirmDialog, {
|
app = createApp(ConfirmDialog, {
|
||||||
modelValue: true,
|
modelValue: true,
|
||||||
...i18nOptions,
|
...i18nOptions,
|
||||||
'onUpdate:modelValue': (val: boolean) => {
|
'onUpdate:modelValue': (val: boolean) => {
|
||||||
if (!val) {
|
if (!val) settle(false)
|
||||||
cleanup()
|
|
||||||
}
|
|
||||||
},
|
},
|
||||||
onConfirm: () => {
|
onConfirm: () => {
|
||||||
resolvePromise?.(true)
|
settle(true)
|
||||||
cleanup()
|
|
||||||
},
|
},
|
||||||
onCancel: () => {
|
onCancel: () => {
|
||||||
resolvePromise?.(false)
|
settle(false)
|
||||||
cleanup()
|
|
||||||
},
|
},
|
||||||
})
|
})
|
||||||
|
|
||||||
@@ -65,12 +71,6 @@ async function createConfirmDialog(options: ConfirmOptions = {}) {
|
|||||||
|
|
||||||
// 挂载应用
|
// 挂载应用
|
||||||
app.mount(container)
|
app.mount(container)
|
||||||
|
|
||||||
// 清理函数
|
|
||||||
const cleanup = () => {
|
|
||||||
app.unmount()
|
|
||||||
document.body.removeChild(container)
|
|
||||||
}
|
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1443,6 +1443,7 @@ export default {
|
|||||||
title: 'Downloading',
|
title: 'Downloading',
|
||||||
noTask: 'No Task',
|
noTask: 'No Task',
|
||||||
noTaskDescription: 'Downloading tasks will be displayed here.',
|
noTaskDescription: 'Downloading tasks will be displayed here.',
|
||||||
|
confirmDelete: 'Delete task "{name}" and its associated download files from the downloader?',
|
||||||
},
|
},
|
||||||
resource: {
|
resource: {
|
||||||
searchResults: 'Resource Search Results',
|
searchResults: 'Resource Search Results',
|
||||||
|
|||||||
@@ -1432,6 +1432,7 @@ export default {
|
|||||||
title: '下载',
|
title: '下载',
|
||||||
noTask: '没有任务',
|
noTask: '没有任务',
|
||||||
noTaskDescription: '正在下载的任务将会显示在这里。',
|
noTaskDescription: '正在下载的任务将会显示在这里。',
|
||||||
|
confirmDelete: '确认从下载器删除任务“{name}”及对应下载文件吗?',
|
||||||
},
|
},
|
||||||
resource: {
|
resource: {
|
||||||
searchResults: '资源搜索结果',
|
searchResults: '资源搜索结果',
|
||||||
|
|||||||
@@ -1430,6 +1430,7 @@ export default {
|
|||||||
title: '下載',
|
title: '下載',
|
||||||
noTask: '沒有任務',
|
noTask: '沒有任務',
|
||||||
noTaskDescription: '正在下載的任務將會顯示在這裡。',
|
noTaskDescription: '正在下載的任務將會顯示在這裡。',
|
||||||
|
confirmDelete: '確認從下載器刪除任務「{name}」及對應下載檔案嗎?',
|
||||||
},
|
},
|
||||||
resource: {
|
resource: {
|
||||||
searchResults: '資源搜索結果',
|
searchResults: '資源搜索結果',
|
||||||
|
|||||||
Reference in New Issue
Block a user