From 5b5daf26d0fbd60dd6d84c2937fb26357fab0086 Mon Sep 17 00:00:00 2001 From: InfinityPacer <160988576+InfinityPacer@users.noreply.github.com> Date: Tue, 25 Aug 2026 13:36:41 +0800 Subject: [PATCH] fix(download): confirm destructive task deletion (#718) --- eslint-suppressions.json | 3 -- src/components/cards/DownloadingCard.vue | 24 ++++++++-- .../cards/__tests__/DownloadingCard.spec.ts | 46 +++++++++++++++++++ src/composables/__tests__/useConfirm.spec.ts | 22 +++++++++ src/composables/useConfirm.ts | 38 +++++++-------- src/locales/en-US.ts | 1 + src/locales/zh-CN.ts | 1 + src/locales/zh-TW.ts | 1 + 8 files changed, 111 insertions(+), 25 deletions(-) create mode 100644 src/composables/__tests__/useConfirm.spec.ts diff --git a/eslint-suppressions.json b/eslint-suppressions.json index 0ab83faf..9dfc4ce5 100644 --- a/eslint-suppressions.json +++ b/eslint-suppressions.json @@ -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": { diff --git a/src/components/cards/DownloadingCard.vue b/src/components/cards/DownloadingCard.vue index b1ce57ed..5cf6a717 100644 --- a/src/components/cards/DownloadingCard.vue +++ b/src/components/cards/DownloadingCard.vue @@ -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" diff --git a/src/components/cards/__tests__/DownloadingCard.spec.ts b/src/components/cards/__tests__/DownloadingCard.spec.ts index ca5bbe2b..9c07ed26 100644 --- a/src/components/cards/__tests__/DownloadingCard.spec.ts +++ b/src/components/cards/__tests__/DownloadingCard.spec.ts @@ -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(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)) diff --git a/src/composables/__tests__/useConfirm.spec.ts b/src/composables/__tests__/useConfirm.spec.ts new file mode 100644 index 00000000..ec9db00b --- /dev/null +++ b/src/composables/__tests__/useConfirm.spec.ts @@ -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) + }) +}) diff --git a/src/composables/useConfirm.ts b/src/composables/useConfirm.ts index 8b0e2a43..d2faa223 100644 --- a/src/composables/useConfirm.ts +++ b/src/composables/useConfirm.ts @@ -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 -let resolvePromise: ((value: boolean) => void) | null = null - /** 创建主应用确认弹窗并等待用户选择结果。 */ async function createConfirmDialog(options: ConfirmOptions = {}) { return new Promise(resolve => { - resolvePromise = resolve - // 创建容器 const container = document.createElement('div') document.body.appendChild(container) + let app: ReturnType | 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) - } }) } diff --git a/src/locales/en-US.ts b/src/locales/en-US.ts index bbdf95db..1e0f0530 100644 --- a/src/locales/en-US.ts +++ b/src/locales/en-US.ts @@ -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', diff --git a/src/locales/zh-CN.ts b/src/locales/zh-CN.ts index f0f084c4..a33d02cb 100644 --- a/src/locales/zh-CN.ts +++ b/src/locales/zh-CN.ts @@ -1432,6 +1432,7 @@ export default { title: '下载', noTask: '没有任务', noTaskDescription: '正在下载的任务将会显示在这里。', + confirmDelete: '确认从下载器删除任务“{name}”及对应下载文件吗?', }, resource: { searchResults: '资源搜索结果', diff --git a/src/locales/zh-TW.ts b/src/locales/zh-TW.ts index 9cadbee5..ed810c34 100644 --- a/src/locales/zh-TW.ts +++ b/src/locales/zh-TW.ts @@ -1430,6 +1430,7 @@ export default { title: '下載', noTask: '沒有任務', noTaskDescription: '正在下載的任務將會顯示在這裡。', + confirmDelete: '確認從下載器刪除任務「{name}」及對應下載檔案嗎?', }, resource: { searchResults: '資源搜索結果',