From e4ca74d935d387ff7bf3ccf42bb036a09dcede15 Mon Sep 17 00:00:00 2001 From: Syngnat Date: Thu, 23 Jul 2026 10:05:08 +0800 Subject: [PATCH] =?UTF-8?q?=F0=9F=90=9B=20fix(sidebar):=20=E4=B8=BA?= =?UTF-8?q?=E8=A1=A8=E5=BA=93=E5=88=A0=E9=99=A4=E5=A2=9E=E5=8A=A0=E4=BA=94?= =?UTF-8?q?=E7=A7=92=E7=A1=AE=E8=AE=A4?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 统一侧边栏与表概览的 DROP 表库确认流程 - 倒计时期间禁用危险按钮并阻止提前执行,关闭时清理计时器 - 补齐六语言文案及倒计时边界与入口接线测试 --- frontend/src/components/TableOverview.tsx | 4 +- .../common/countdownDangerConfirm.test.tsx | 134 ++++++++++++++++ .../common/countdownDangerConfirm.tsx | 148 ++++++++++++++++++ .../countdownDangerConfirm.wiring.test.ts | 59 +++++++ .../sidebar/useSidebarObjectActions.tsx | 7 +- shared/i18n/de-DE.json | 3 + shared/i18n/en-US.json | 3 + shared/i18n/ja-JP.json | 3 + shared/i18n/ru-RU.json | 3 + shared/i18n/zh-CN.json | 3 + shared/i18n/zh-TW.json | 3 + 11 files changed, 364 insertions(+), 6 deletions(-) create mode 100644 frontend/src/components/common/countdownDangerConfirm.test.tsx create mode 100644 frontend/src/components/common/countdownDangerConfirm.tsx create mode 100644 frontend/src/components/common/countdownDangerConfirm.wiring.test.ts diff --git a/frontend/src/components/TableOverview.tsx b/frontend/src/components/TableOverview.tsx index 2614a87a..9160f3b2 100644 --- a/frontend/src/components/TableOverview.tsx +++ b/frontend/src/components/TableOverview.tsx @@ -1,4 +1,5 @@ import Modal from './common/ResizableDraggableModal'; +import { showCountdownDangerConfirm } from './common/countdownDangerConfirm'; import React, { useState, useEffect, useMemo, useCallback, useDeferredValue, useRef } from 'react'; import { createPortal } from 'react-dom'; import { Input, Spin, Empty, Dropdown, message, Tooltip, Button } from 'antd'; @@ -646,10 +647,9 @@ const TableOverview: React.FC = ({ tab }) => { const handleDeleteTable = useCallback((tableName: string) => { const config = buildConfig(); if (!config) return; - Modal.confirm({ + showCountdownDangerConfirm({ title: t('table_overview.modal.delete_table.title'), content: t('table_overview.modal.delete_table.content', { table: tableName }), - okButtonProps: { danger: true }, onOk: async () => { const res = await DropTable(buildRpcConnectionConfig(config) as any, tab.dbName || '', tableName); if (res.success) { diff --git a/frontend/src/components/common/countdownDangerConfirm.test.tsx b/frontend/src/components/common/countdownDangerConfirm.test.tsx new file mode 100644 index 00000000..97d25174 --- /dev/null +++ b/frontend/src/components/common/countdownDangerConfirm.test.tsx @@ -0,0 +1,134 @@ +import type { ModalFuncProps } from 'antd'; +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; + +vi.mock('./ResizableDraggableModal', () => ({ + default: { + confirm: vi.fn(), + }, +})); + +import { getCurrentLanguage, setCurrentLanguage } from '../../i18n'; +import Modal from './ResizableDraggableModal'; +import { + DANGER_CONFIRM_COUNTDOWN_SECONDS, + showCountdownDangerConfirm, +} from './countdownDangerConfirm'; + +describe('showCountdownDangerConfirm', () => { + let previousLanguage: string; + let update: ReturnType; + let destroy: ReturnType; + + const getLatestUpdate = (): ModalFuncProps => ( + update.mock.calls[update.mock.calls.length - 1]?.[0] as ModalFuncProps + ); + + beforeEach(() => { + previousLanguage = getCurrentLanguage(); + setCurrentLanguage('en-US'); + vi.useFakeTimers(); + vi.setSystemTime(new Date('2026-07-23T00:00:00Z')); + update = vi.fn(); + destroy = vi.fn(); + vi.mocked(Modal.confirm).mockReset(); + vi.mocked(Modal.confirm).mockReturnValue({ update, destroy }); + }); + + afterEach(() => { + vi.clearAllTimers(); + vi.useRealTimers(); + setCurrentLanguage(previousLanguage); + }); + + it('keeps the danger action locked for the full five seconds', async () => { + const onOk = vi.fn().mockResolvedValue(undefined); + + showCountdownDangerConfirm({ + title: 'Delete table', + content: 'Delete users?', + onOk, + }); + + expect(DANGER_CONFIRM_COUNTDOWN_SECONDS).toBe(5); + expect(Modal.confirm).toHaveBeenCalledTimes(1); + const initialConfig = vi.mocked(Modal.confirm).mock.calls[0][0] as ModalFuncProps; + expect(initialConfig.autoFocusButton).toBe('cancel'); + expect(initialConfig.okText).toBe('Delete (5s)'); + expect(initialConfig.okButtonProps).toMatchObject({ danger: true, disabled: true }); + + expect(initialConfig.onOk?.()).toBe(false); + expect(onOk).not.toHaveBeenCalled(); + + vi.advanceTimersByTime(4_999); + const lockedUpdate = getLatestUpdate(); + expect(lockedUpdate.okText).toBe('Delete (1s)'); + expect(lockedUpdate.okButtonProps?.disabled).toBe(true); + expect(onOk).not.toHaveBeenCalled(); + + vi.advanceTimersByTime(1); + const unlockedUpdate = getLatestUpdate(); + expect(typeof unlockedUpdate).toBe('object'); + expect(unlockedUpdate.okText).toBe('Delete'); + expect(unlockedUpdate.okButtonProps).toMatchObject({ danger: true, disabled: false }); + expect(vi.getTimerCount()).toBe(0); + + await initialConfig.onOk?.(); + expect(onOk).toHaveBeenCalledTimes(1); + }); + + it('preserves an explicitly disabled confirm button after the countdown', () => { + showCountdownDangerConfirm({ + title: 'Delete table', + content: 'Delete users?', + countdownSeconds: 1, + okButtonProps: { disabled: true, className: 'custom-danger-button' }, + onOk: vi.fn(), + }); + + vi.advanceTimersByTime(1_000); + const unlockedUpdate = getLatestUpdate(); + expect(unlockedUpdate.okButtonProps).toMatchObject({ + danger: true, + disabled: true, + className: 'custom-danger-button', + }); + }); + + it('stops updating after cancellation or after the modal closes', () => { + const onCancel = vi.fn(); + const afterClose = vi.fn(); + showCountdownDangerConfirm({ + title: 'Delete table', + content: 'Delete users?', + onOk: vi.fn(), + onCancel, + afterClose, + }); + + const initialConfig = vi.mocked(Modal.confirm).mock.calls[0][0] as ModalFuncProps; + initialConfig.onCancel?.('cancel'); + expect(onCancel).toHaveBeenCalledWith('cancel'); + expect(vi.getTimerCount()).toBe(0); + + vi.advanceTimersByTime(5_000); + expect(update).not.toHaveBeenCalled(); + + initialConfig.afterClose?.(); + expect(afterClose).toHaveBeenCalledTimes(1); + }); + + it('cleans up the countdown when destroyed through the returned reference', () => { + const modalRef = showCountdownDangerConfirm({ + title: 'Delete database', + content: 'Delete app?', + onOk: vi.fn(), + }); + + modalRef.destroy(); + expect(destroy).toHaveBeenCalledTimes(1); + expect(vi.getTimerCount()).toBe(0); + + vi.advanceTimersByTime(5_000); + expect(update).not.toHaveBeenCalled(); + }); +}); diff --git a/frontend/src/components/common/countdownDangerConfirm.tsx b/frontend/src/components/common/countdownDangerConfirm.tsx new file mode 100644 index 00000000..0b6392ed --- /dev/null +++ b/frontend/src/components/common/countdownDangerConfirm.tsx @@ -0,0 +1,148 @@ +import type React from 'react'; +import type { ModalFuncProps } from 'antd'; + +import { t } from '../../i18n'; +import Modal from './ResizableDraggableModal'; + +export const DANGER_CONFIRM_COUNTDOWN_SECONDS = 5; + +export type CountdownDangerConfirmOptions = Omit< + ModalFuncProps, + 'content' | 'okText' | 'onOk' | 'onCancel' | 'afterClose' +> & { + content: React.ReactNode; + confirmText?: string; + countdownSeconds?: number; + onOk: NonNullable; + onCancel?: ModalFuncProps['onCancel']; + afterClose?: ModalFuncProps['afterClose']; +}; + +const countdownStatusStyle: React.CSSProperties = { + marginTop: 12, + fontSize: 12, + lineHeight: 1.5, + opacity: 0.78, +}; + +export const showCountdownDangerConfirm = ({ + content, + confirmText = t('common.delete'), + countdownSeconds = DANGER_CONFIRM_COUNTDOWN_SECONDS, + okButtonProps, + cancelText = t('common.cancel'), + autoFocusButton = 'cancel', + modalRender, + onOk, + onCancel, + afterClose, + ...modalProps +}: CountdownDangerConfirmOptions): ReturnType => { + const normalizedSeconds = Number.isFinite(countdownSeconds) + ? Math.max(0, Math.ceil(countdownSeconds)) + : DANGER_CONFIRM_COUNTDOWN_SECONDS; + const originallyDisabled = okButtonProps?.disabled === true; + let remainingSeconds = normalizedSeconds; + let deadline = 0; + let timer: ReturnType | null = null; + let modalRef: ReturnType | null = null; + let closed = false; + + const stopTimer = () => { + if (timer === null) return; + clearInterval(timer); + timer = null; + }; + + const finish = () => { + closed = true; + stopTimer(); + }; + + const renderContent = () => ( +
+
{content}
+
+ {remainingSeconds > 0 + ? t('common.destructive_confirm.countdown', { seconds: remainingSeconds }) + : t('common.destructive_confirm.ready')} +
+
+ ); + + const buildOkButtonProps = () => ({ + ...okButtonProps, + danger: true, + disabled: originallyDisabled || remainingSeconds > 0, + }); + + const buildOkText = () => ( + remainingSeconds > 0 + ? t('common.destructive_confirm.action_countdown', { + action: confirmText, + seconds: remainingSeconds, + }) + : confirmText + ); + + const handleOk: NonNullable = (...args) => { + if (remainingSeconds > 0) return false; + finish(); + return onOk(...args); + }; + + const handleCancel: NonNullable = (...args) => { + finish(); + return onCancel?.(...args); + }; + + const handleAfterClose = () => { + finish(); + afterClose?.(); + }; + + try { + modalRef = Modal.confirm({ + ...modalProps, + content: renderContent(), + okText: buildOkText(), + cancelText, + autoFocusButton, + modalRender, + okButtonProps: buildOkButtonProps(), + onOk: handleOk, + onCancel: handleCancel, + afterClose: handleAfterClose, + }); + } catch (error) { + finish(); + throw error; + } + + if (!closed && remainingSeconds > 0) { + deadline = performance.now() + normalizedSeconds * 1000; + timer = setInterval(() => { + if (closed || !modalRef) return; + const nextRemainingSeconds = Math.max(0, Math.ceil((deadline - performance.now()) / 1000)); + if (nextRemainingSeconds === remainingSeconds) return; + + remainingSeconds = nextRemainingSeconds; + modalRef.update({ + content: renderContent(), + okText: buildOkText(), + okButtonProps: buildOkButtonProps(), + modalRender, + }); + if (remainingSeconds === 0) stopTimer(); + }, 1000); + } + + const rawDestroy = modalRef.destroy; + return { + ...modalRef, + destroy: () => { + finish(); + rawDestroy(); + }, + }; +}; diff --git a/frontend/src/components/common/countdownDangerConfirm.wiring.test.ts b/frontend/src/components/common/countdownDangerConfirm.wiring.test.ts new file mode 100644 index 00000000..731728e9 --- /dev/null +++ b/frontend/src/components/common/countdownDangerConfirm.wiring.test.ts @@ -0,0 +1,59 @@ +import { readFileSync } from 'node:fs'; +import { describe, expect, it } from 'vitest'; + +const sidebarObjectActionsSource = readFileSync( + new URL('../sidebar/useSidebarObjectActions.tsx', import.meta.url), + 'utf8', +); +const tableOverviewSource = readFileSync( + new URL('../TableOverview.tsx', import.meta.url), + 'utf8', +); +const locales = ['zh-CN', 'zh-TW', 'en-US', 'ja-JP', 'de-DE', 'ru-RU'] as const; + +const sliceBetween = (source: string, start: string, end: string): string => { + const startIndex = source.indexOf(start); + const endIndex = source.indexOf(end, startIndex + start.length); + expect(startIndex).toBeGreaterThanOrEqual(0); + expect(endIndex).toBeGreaterThan(startIndex); + return source.slice(startIndex, endIndex); +}; + +describe('countdown danger confirmation wiring', () => { + it('protects every table and database right-click DROP entry point', () => { + const sidebarDeleteDatabase = sliceBetween( + sidebarObjectActionsSource, + 'const handleDeleteDatabase =', + 'const handleRenameTable =', + ); + const sidebarDeleteTable = sliceBetween( + sidebarObjectActionsSource, + 'const handleDeleteTable =', + 'const handleTableDataDangerAction =', + ); + const overviewDeleteTable = sliceBetween( + tableOverviewSource, + 'const handleDeleteTable =', + 'const handleTableDataDangerAction =', + ); + + [sidebarDeleteDatabase, sidebarDeleteTable, overviewDeleteTable].forEach((source) => { + expect(source).toContain('showCountdownDangerConfirm({'); + expect(source).not.toContain('Modal.confirm({'); + }); + }); + + it('keeps countdown text and placeholders available in every locale', () => { + locales.forEach((locale) => { + const catalog = JSON.parse(readFileSync( + new URL(`../../../../shared/i18n/${locale}.json`, import.meta.url), + 'utf8', + )) as Record; + + expect(catalog['common.destructive_confirm.action_countdown'], `${locale}:action`).toContain('{{action}}'); + expect(catalog['common.destructive_confirm.action_countdown'], `${locale}:seconds`).toContain('{{seconds}}'); + expect(catalog['common.destructive_confirm.countdown'], `${locale}:countdown`).toContain('{{seconds}}'); + expect(catalog['common.destructive_confirm.ready'], `${locale}:ready`).toBeTruthy(); + }); + }); +}); diff --git a/frontend/src/components/sidebar/useSidebarObjectActions.tsx b/frontend/src/components/sidebar/useSidebarObjectActions.tsx index 6cf02e60..0046b69f 100644 --- a/frontend/src/components/sidebar/useSidebarObjectActions.tsx +++ b/frontend/src/components/sidebar/useSidebarObjectActions.tsx @@ -3,6 +3,7 @@ import { message } from 'antd'; import type { FormInstance } from 'antd/es/form'; import Modal from '../common/ResizableDraggableModal'; +import { showCountdownDangerConfirm } from '../common/countdownDangerConfirm'; import type { SavedConnection, SavedQuery } from '../../types'; import { useStore } from '../../store'; import { t } from '../../i18n'; @@ -598,10 +599,9 @@ export const useSidebarObjectActions = ({ const conn = node.dataRef; const dbName = String(conn.dbName || '').trim(); if (!dbName) return; - Modal.confirm({ + showCountdownDangerConfirm({ title: t('sidebar.modal.confirm_delete_database.title'), content: t('sidebar.modal.confirm_delete_database.content', { name: dbName }), - okButtonProps: { danger: true }, onOk: async () => { const config = buildRuntimeConfig(conn, conn.dbName); const res = await DropDatabase(buildRpcConnectionConfig(config) as any, dbName); @@ -653,10 +653,9 @@ export const useSidebarObjectActions = ({ const conn = node.dataRef; const tableName = String(conn.tableName || '').trim(); if (!tableName) return; - Modal.confirm({ + showCountdownDangerConfirm({ title: t('sidebar.modal.confirm_delete_table.title'), content: t('sidebar.modal.confirm_delete_table.content', { name: tableName }), - okButtonProps: { danger: true }, onOk: async () => { const config = buildRuntimeConfig(conn, conn.dbName); const res = await DropTable(buildRpcConnectionConfig(config) as any, conn.dbName, tableName); diff --git a/shared/i18n/de-DE.json b/shared/i18n/de-DE.json index 1e07a246..09a8ba8c 100644 --- a/shared/i18n/de-DE.json +++ b/shared/i18n/de-DE.json @@ -3139,6 +3139,9 @@ "common.confirm": "Bestätigen", "common.continue": "Fortfahren", "common.delete": "Löschen", + "common.destructive_confirm.action_countdown": "{{action}} ({{seconds}} s)", + "common.destructive_confirm.countdown": "Lesen Sie den Warnhinweis sorgfältig. Sie können in {{seconds}} s bestätigen.", + "common.destructive_confirm.ready": "Die Wartezeit ist abgelaufen. Prüfen Sie das Ziel vor der Bestätigung erneut.", "common.edit": "Bearbeiten", "common.error": "Fehler", "common.loading": "Wird geladen", diff --git a/shared/i18n/en-US.json b/shared/i18n/en-US.json index c8180013..e70f9eab 100644 --- a/shared/i18n/en-US.json +++ b/shared/i18n/en-US.json @@ -3139,6 +3139,9 @@ "common.confirm": "Confirm", "common.continue": "Continue", "common.delete": "Delete", + "common.destructive_confirm.action_countdown": "{{action}} ({{seconds}}s)", + "common.destructive_confirm.countdown": "Review the warning carefully. You can confirm in {{seconds}}s.", + "common.destructive_confirm.ready": "The waiting period has ended. Check the target again before confirming.", "common.edit": "Edit", "common.error": "Error", "common.loading": "Loading", diff --git a/shared/i18n/ja-JP.json b/shared/i18n/ja-JP.json index 82f4d4f2..6bbd72fd 100644 --- a/shared/i18n/ja-JP.json +++ b/shared/i18n/ja-JP.json @@ -3139,6 +3139,9 @@ "common.confirm": "確認", "common.continue": "続行", "common.delete": "削除", + "common.destructive_confirm.action_countdown": "{{action}}({{seconds}} 秒)", + "common.destructive_confirm.countdown": "警告をよく確認してください。{{seconds}} 秒後に確認できます。", + "common.destructive_confirm.ready": "待機時間が終了しました。対象を再確認してから実行してください。", "common.edit": "編集", "common.error": "エラー", "common.loading": "読み込み中", diff --git a/shared/i18n/ru-RU.json b/shared/i18n/ru-RU.json index 7f63d92b..f42adc58 100644 --- a/shared/i18n/ru-RU.json +++ b/shared/i18n/ru-RU.json @@ -3139,6 +3139,9 @@ "common.confirm": "Подтвердить", "common.continue": "Продолжить", "common.delete": "Удалить", + "common.destructive_confirm.action_countdown": "{{action}} ({{seconds}} с)", + "common.destructive_confirm.countdown": "Внимательно прочитайте предупреждение. Подтверждение станет доступно через {{seconds}} с.", + "common.destructive_confirm.ready": "Время ожидания истекло. Перед подтверждением ещё раз проверьте объект.", "common.edit": "Изменить", "common.error": "Ошибка", "common.loading": "Загрузка", diff --git a/shared/i18n/zh-CN.json b/shared/i18n/zh-CN.json index 01f233c1..2545bb08 100644 --- a/shared/i18n/zh-CN.json +++ b/shared/i18n/zh-CN.json @@ -3139,6 +3139,9 @@ "common.confirm": "确认", "common.continue": "继续", "common.delete": "删除", + "common.destructive_confirm.action_countdown": "{{action}}({{seconds}} 秒)", + "common.destructive_confirm.countdown": "请仔细阅读风险提示,{{seconds}} 秒后才能确认。", + "common.destructive_confirm.ready": "等待结束,请再次确认操作对象。", "common.edit": "编辑", "common.error": "错误", "common.loading": "加载中", diff --git a/shared/i18n/zh-TW.json b/shared/i18n/zh-TW.json index 2a4cf466..a512ca1c 100644 --- a/shared/i18n/zh-TW.json +++ b/shared/i18n/zh-TW.json @@ -3139,6 +3139,9 @@ "common.confirm": "確認", "common.continue": "繼續", "common.delete": "刪除", + "common.destructive_confirm.action_countdown": "{{action}}({{seconds}} 秒)", + "common.destructive_confirm.countdown": "請仔細閱讀風險提示,{{seconds}} 秒後才能確認。", + "common.destructive_confirm.ready": "等待結束,請再次確認操作對象。", "common.edit": "編輯", "common.error": "錯誤", "common.loading": "載入中",