🐛 fix(sidebar): 为表库删除增加五秒确认

- 统一侧边栏与表概览的 DROP 表库确认流程
- 倒计时期间禁用危险按钮并阻止提前执行,关闭时清理计时器
- 补齐六语言文案及倒计时边界与入口接线测试
This commit is contained in:
Syngnat
2026-07-23 10:05:08 +08:00
parent 430e27fb1e
commit e4ca74d935
11 changed files with 364 additions and 6 deletions

View File

@@ -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<TableOverviewProps> = ({ 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) {

View File

@@ -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<typeof vi.fn>;
let destroy: ReturnType<typeof vi.fn>;
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();
});
});

View File

@@ -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<ModalFuncProps['onOk']>;
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<typeof Modal.confirm> => {
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<typeof setInterval> | null = null;
let modalRef: ReturnType<typeof Modal.confirm> | null = null;
let closed = false;
const stopTimer = () => {
if (timer === null) return;
clearInterval(timer);
timer = null;
};
const finish = () => {
closed = true;
stopTimer();
};
const renderContent = () => (
<div>
<div>{content}</div>
<div role="status" aria-live="polite" aria-atomic="true" style={countdownStatusStyle}>
{remainingSeconds > 0
? t('common.destructive_confirm.countdown', { seconds: remainingSeconds })
: t('common.destructive_confirm.ready')}
</div>
</div>
);
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<ModalFuncProps['onOk']> = (...args) => {
if (remainingSeconds > 0) return false;
finish();
return onOk(...args);
};
const handleCancel: NonNullable<ModalFuncProps['onCancel']> = (...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();
},
};
};

View File

@@ -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<string, string>;
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();
});
});
});

View File

@@ -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);

View File

@@ -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",

View File

@@ -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",

View File

@@ -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": "読み込み中",

View File

@@ -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": "Загрузка",

View File

@@ -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": "加载中",

View File

@@ -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": "載入中",