fix(download): confirm destructive task deletion (#718)

This commit is contained in:
InfinityPacer
2026-08-25 13:36:41 +08:00
committed by GitHub
parent 618c56461a
commit 5b5daf26d0
8 changed files with 111 additions and 25 deletions
-3
View File
@@ -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": {
+21 -3
View File
@@ -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)
})
})
+19 -19
View File
@@ -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)
}
})
}
+1
View File
@@ -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',
+1
View File
@@ -1432,6 +1432,7 @@ export default {
title: '下载',
noTask: '没有任务',
noTaskDescription: '正在下载的任务将会显示在这里。',
confirmDelete: '确认从下载器删除任务“{name}”及对应下载文件吗?',
},
resource: {
searchResults: '资源搜索结果',
+1
View File
@@ -1430,6 +1430,7 @@ export default {
title: '下載',
noTask: '沒有任務',
noTaskDescription: '正在下載的任務將會顯示在這裡。',
confirmDelete: '確認從下載器刪除任務「{name}」及對應下載檔案嗎?',
},
resource: {
searchResults: '資源搜索結果',