feat(safety): 增加危险删除操作二次确认 (#11)

将现有 SQL 危险确认弹窗扩展为可复用确认组件。

覆盖 SQL UPDATE/MERGE/REPLACE 等危险语句识别,并在识别前去除注释。

为表格行删除、Redis key/hash/list/set 删除、MongoDB 文档和字段删除接入确认弹窗。

补充中英文危险确认文案,保持本次提交只聚焦确认流程。
This commit is contained in:
vrustx 2026-04-30 14:00:57 +08:00 committed by GitHub
parent 84ea2a6e13
commit 68430d16c1
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
8 changed files with 231 additions and 27 deletions

View File

@ -201,10 +201,17 @@ async function openConnectionQuery(connectionId: string) {
queryStore.createTab(connectionId, database);
}
const DANGER_RE = /^\s*(DROP|DELETE|TRUNCATE|ALTER)\b/i;
const DANGER_RE = /\b(DROP|DELETE|TRUNCATE|ALTER|UPDATE|MERGE|REPLACE)\b/i;
function stripSqlComments(sql: string): string {
return sql
.replace(/\/\*[\s\S]*?\*\//g, " ")
.replace(/--.*$/gm, " ")
.replace(/#.*$/gm, " ");
}
function isDangerousSql(sql: string): boolean {
return DANGER_RE.test(sql);
return DANGER_RE.test(stripSqlComments(sql));
}
function tryExecute() {

View File

@ -10,9 +10,19 @@ const { t } = useI18n();
const open = defineModel<boolean>("open", { default: false });
defineProps<{
sql: string;
}>();
withDefaults(defineProps<{
sql?: string;
title?: string;
message?: string;
details?: string;
confirmLabel?: string;
}>(), {
sql: "",
title: "",
message: "",
details: "",
confirmLabel: "",
});
const emit = defineEmits<{
confirm: [];
@ -30,18 +40,18 @@ function onConfirm() {
<DialogHeader>
<DialogTitle class="flex items-center gap-2 text-destructive">
<AlertTriangle class="h-5 w-5" />
{{ t('dangerDialog.title') }}
{{ title || t('dangerDialog.title') }}
</DialogTitle>
</DialogHeader>
<div class="py-4">
<p class="text-sm text-muted-foreground mb-3">{{ t('dangerDialog.message') }}</p>
<pre class="text-xs bg-muted p-3 rounded overflow-auto max-h-40 font-mono whitespace-pre-wrap">{{ sql }}</pre>
<p class="text-sm text-muted-foreground mb-3">{{ message || t('dangerDialog.message') }}</p>
<pre v-if="details || sql" class="text-xs bg-muted p-3 rounded overflow-auto max-h-40 font-mono whitespace-pre-wrap">{{ details || sql }}</pre>
</div>
<DialogFooter>
<Button variant="outline" @click="open = false">{{ t('dangerDialog.cancel') }}</Button>
<Button variant="destructive" @click="onConfirm">{{ t('dangerDialog.confirm') }}</Button>
<Button variant="destructive" @click="onConfirm">{{ confirmLabel || t('dangerDialog.confirm') }}</Button>
</DialogFooter>
</DialogContent>
</Dialog>

View File

@ -18,6 +18,7 @@ import {
DropdownMenu, DropdownMenuContent, DropdownMenuItem, DropdownMenuTrigger,
} from "@/components/ui/dropdown-menu";
import { Tooltip, TooltipContent, TooltipTrigger } from "@/components/ui/tooltip";
import DangerConfirmDialog from "@/components/editor/DangerConfirmDialog.vue";
import type { QueryResult, ColumnInfo, DatabaseType } from "@/types/database";
import { save as savePath } from "@tauri-apps/plugin-dialog";
import { writeTextFile } from "@tauri-apps/plugin-fs";
@ -422,7 +423,7 @@ function addRow() {
});
}
function deleteRow(rowId: number) {
function applyDeleteRow(rowId: number) {
const item = getRowItem(rowId);
if (!item) return;
if (item.isNew && item.newIndex !== undefined) {
@ -434,6 +435,25 @@ function deleteRow(rowId: number) {
if (editingCell.value?.rowId === rowId) editingCell.value = null;
}
const showDeleteRowConfirm = ref(false);
const pendingDeleteRowId = ref<number | null>(null);
const deleteRowDetails = computed(() =>
props.tableMeta?.tableName
? t("dangerDialog.deleteRowDetails", { table: props.tableMeta.tableName })
: t("dangerDialog.deleteRowDetailsNoTable")
);
function requestDeleteRow(rowId: number) {
pendingDeleteRowId.value = rowId;
showDeleteRowConfirm.value = true;
}
function confirmDeleteRow() {
if (pendingDeleteRowId.value === null) return;
applyDeleteRow(pendingDeleteRowId.value);
pendingDeleteRowId.value = null;
}
function restoreRow(rowId: number) {
const item = getRowItem(rowId);
if (item?.sourceIndex !== undefined) {
@ -443,7 +463,7 @@ function restoreRow(rowId: number) {
function deleteSelectedRow() {
if (!contextCell.value) return;
deleteRow(contextCell.value.rowId);
requestDeleteRow(contextCell.value.rowId);
}
function escapeVal(v: CellValue): string {
@ -793,7 +813,7 @@ function escapeAndHighlightKeywords(s: string): string {
size="icon"
class="h-5 w-5 shrink-0 text-destructive"
:title="t('grid.deleteRow')"
@click.stop="deleteRow(item.id)"
@click.stop="requestDeleteRow(item.id)"
>
<Trash2 class="w-3 h-3" />
</Button>
@ -955,6 +975,14 @@ function escapeAndHighlightKeywords(s: string): string {
</TooltipContent>
</Tooltip>
</div>
<DangerConfirmDialog
v-model:open="showDeleteRowConfirm"
:message="t('dangerDialog.deleteRowMessage')"
:details="deleteRowDetails"
:confirm-label="t('grid.deleteRow')"
@confirm="confirmDeleteRow"
/>
</div>
</template>

View File

@ -1,8 +1,9 @@
<script setup lang="ts">
import { computed } from "vue";
import { computed, ref } from "vue";
import { useI18n } from "vue-i18n";
import { Plus, Trash2 } from "lucide-vue-next";
import { Button } from "@/components/ui/button";
import DangerConfirmDialog from "@/components/editor/DangerConfirmDialog.vue";
import type { EditNode, EditNodeKind } from "@/types/editor";
defineOptions({ name: "JsonEditNode" });
@ -23,6 +24,8 @@ const emit = defineEmits<{
const { t } = useI18n();
const isContainer = computed(() => props.node.kind !== "value");
const showChildDeleteConfirm = ref(false);
const pendingChildDeleteIdx = ref<number | null>(null);
const childKeyWidth = computed(() => {
const longest = props.node.children.reduce((max, child) => {
@ -31,6 +34,17 @@ const childKeyWidth = computed(() => {
return `${Math.min(Math.max(longest + 4, 8), 36)}ch`;
});
const childDeleteDetails = computed(() => {
const idx = pendingChildDeleteIdx.value;
if (idx === null) return "";
const child = props.node.children[idx];
if (!child) return "";
if (props.node.kind === "array") {
return t("dangerDialog.mongoArrayItemDetails", { index: child.keyName });
}
return t("dangerDialog.mongoFieldDetails", { field: child.keyName || t("mongo.field") });
});
function fieldRows(value: string): number {
const estimatedRows = value.split(/\r?\n/).reduce((sum, line) => {
return sum + Math.max(1, Math.ceil(Array.from(line).length / 110));
@ -76,6 +90,17 @@ function removeChild(idx: number) {
});
}
}
function requestRemoveChild(idx: number) {
pendingChildDeleteIdx.value = idx;
showChildDeleteConfirm.value = true;
}
function confirmRemoveChild() {
if (pendingChildDeleteIdx.value === null) return;
removeChild(pendingChildDeleteIdx.value);
pendingChildDeleteIdx.value = null;
}
</script>
<template>
@ -138,7 +163,7 @@ function removeChild(idx: number) {
:node="child"
:parent-kind="node.kind"
:removable="!child.readonlyValue || node.kind === 'array'"
@remove="removeChild(idx)"
@remove="requestRemoveChild(idx)"
/>
<Button variant="ghost" size="sm" class="json-edit-add" @click="addChild">
@ -149,6 +174,14 @@ function removeChild(idx: number) {
{{ node.kind === 'array' ? ']' : '}' }}<span class="json-edit-comma">,</span>
</div>
</div>
<DangerConfirmDialog
v-model:open="showChildDeleteConfirm"
:message="t('dangerDialog.deleteMessage')"
:details="childDeleteDetails"
:confirm-label="t('mongo.deleteField')"
@confirm="confirmRemoveChild"
/>
</div>
</template>

View File

@ -4,6 +4,7 @@ import { useI18n } from "vue-i18n";
import { RefreshCw, Trash2, Plus, Save, ChevronLeft, ChevronRight } from "lucide-vue-next";
import { Button } from "@/components/ui/button";
import { Badge } from "@/components/ui/badge";
import DangerConfirmDialog from "@/components/editor/DangerConfirmDialog.vue";
import * as api from "@/lib/tauri";
import JsonEditNode from "./JsonEditNode.vue";
import type { EditNode } from "@/types/editor";
@ -31,6 +32,13 @@ const isEditing = ref(false);
const isNew = ref(false);
const error = ref("");
const editFields = ref<EditNode[]>([]);
const showDeleteConfirm = ref(false);
type PendingDelete =
| { kind: "document"; index: number }
| { kind: "field"; index: number; name: string };
const pendingDelete = ref<PendingDelete | null>(null);
const selectedDoc = computed(() => {
if (selectedIdx.value === null) return null;
@ -44,6 +52,16 @@ const editKeyWidth = computed(() => {
return `${Math.min(Math.max(longest + 4, 8), 36)}ch`;
});
const deleteDetails = computed(() => {
const pending = pendingDelete.value;
if (!pending) return "";
if (pending.kind === "document") {
const id = documents.value[pending.index]?._id ?? "";
return t("dangerDialog.mongoDocumentDetails", { collection: props.collection, id: String(id) });
}
return t("dangerDialog.mongoFieldDetails", { field: pending.name || t("mongo.field") });
});
async function load() {
loading.value = true;
error.value = "";
@ -150,11 +168,18 @@ function addField() {
editFields.value.push(createEditNode("", "", false, false));
}
function removeField(idx: number) {
function applyRemoveField(idx: number) {
if (editFields.value[idx]?.readonlyValue) return;
editFields.value.splice(idx, 1);
}
function requestRemoveField(idx: number) {
const field = editFields.value[idx];
if (!field || field.readonlyValue) return;
pendingDelete.value = { kind: "field", index: idx, name: field.keyName };
showDeleteConfirm.value = true;
}
function formatForEdit(value: unknown): string {
if (value === undefined) return "";
if (value === null) return "null";
@ -222,7 +247,7 @@ async function saveDoc() {
}
}
async function deleteDoc(idx: number) {
async function applyDeleteDoc(idx: number) {
const doc = documents.value[idx];
const id = doc._id;
if (!id) return;
@ -236,6 +261,22 @@ async function deleteDoc(idx: number) {
}
}
function requestDeleteDoc(idx: number) {
pendingDelete.value = { kind: "document", index: idx };
showDeleteConfirm.value = true;
}
async function confirmDelete() {
const pending = pendingDelete.value;
if (!pending) return;
if (pending.kind === "document") {
await applyDeleteDoc(pending.index);
} else {
applyRemoveField(pending.index);
}
pendingDelete.value = null;
}
function prevPage() {
if (page.value <= 0) return;
page.value--;
@ -297,7 +338,7 @@ onMounted(load);
@click="selectDoc(idx)"
>
<span class="truncate flex-1">{{ docPreview(doc) }}</span>
<Button variant="ghost" size="icon" class="h-5 w-5 opacity-0 group-hover:opacity-100 text-destructive shrink-0" @click.stop="deleteDoc(idx)">
<Button variant="ghost" size="icon" class="h-5 w-5 opacity-0 group-hover:opacity-100 text-destructive shrink-0" @click.stop="requestDeleteDoc(idx)">
<Trash2 class="w-3 h-3" />
</Button>
</div>
@ -346,7 +387,7 @@ onMounted(load);
:node="field"
parent-kind="root"
:removable="!field.readonlyValue"
@remove="removeField(idx)"
@remove="requestRemoveField(idx)"
/>
<Button variant="ghost" size="sm" class="json-edit-add" @click="addField">
@ -371,6 +412,13 @@ onMounted(load);
<div v-if="error" class="px-3 py-1.5 border-t bg-destructive/10 text-destructive text-xs shrink-0">
{{ error }}
</div>
<DangerConfirmDialog
v-model:open="showDeleteConfirm"
:message="t('dangerDialog.deleteMessage')"
:details="deleteDetails"
:confirm-label="t('dangerDialog.deleteConfirm')"
@confirm="confirmDelete"
/>
</div>
</Pane>
</Splitpanes>

View File

@ -1,10 +1,11 @@
<script setup lang="ts">
import { ref, onMounted } from "vue";
import { computed, ref, onMounted } from "vue";
import { useI18n } from "vue-i18n";
import { Copy, Trash2, Save, RefreshCw, Plus } from "lucide-vue-next";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Badge } from "@/components/ui/badge";
import DangerConfirmDialog from "@/components/editor/DangerConfirmDialog.vue";
import * as api from "@/lib/tauri";
import type { RedisValue } from "@/lib/tauri";
@ -23,6 +24,24 @@ const editValue = ref("");
const isEditing = ref(false);
const newField = ref("");
const newValue = ref("");
const showDeleteConfirm = ref(false);
type PendingDelete =
| { kind: "key" }
| { kind: "hash"; field: string }
| { kind: "list"; index: number }
| { kind: "set"; member: string };
const pendingDelete = ref<PendingDelete | null>(null);
const deleteDetails = computed(() => {
const pending = pendingDelete.value;
if (!pending) return "";
if (pending.kind === "key") return t("dangerDialog.redisKeyDetails", { key: props.keyName });
if (pending.kind === "hash") return t("dangerDialog.redisHashFieldDetails", { key: props.keyName, field: pending.field });
if (pending.kind === "list") return t("dangerDialog.redisListItemDetails", { key: props.keyName, index: pending.index });
return t("dangerDialog.redisSetMemberDetails", { key: props.keyName, member: pending.member });
});
async function load() {
loading.value = true;
@ -42,11 +61,16 @@ async function saveString() {
await load();
}
async function deleteKey() {
async function applyDeleteKey() {
await api.redisDeleteKey(props.connectionId, props.keyName);
emit("deleted");
}
function requestDeleteKey() {
pendingDelete.value = { kind: "key" };
showDeleteConfirm.value = true;
}
function copyValue() {
if (!data.value) return;
const text = typeof data.value.value === "string" ? data.value.value : JSON.stringify(data.value.value, null, 2);
@ -61,10 +85,14 @@ async function hashSet() {
newValue.value = "";
await load();
}
async function hashDel(field: string) {
async function applyHashDel(field: string) {
await api.redisHashDel(props.connectionId, props.keyName, field);
await load();
}
function requestHashDel(field: string) {
pendingDelete.value = { kind: "hash", field };
showDeleteConfirm.value = true;
}
// List
async function listPush() {
@ -73,10 +101,14 @@ async function listPush() {
newValue.value = "";
await load();
}
async function listRemove(index: number) {
async function applyListRemove(index: number) {
await api.redisListRemove(props.connectionId, props.keyName, index);
await load();
}
function requestListRemove(index: number) {
pendingDelete.value = { kind: "list", index };
showDeleteConfirm.value = true;
}
// Set
async function setAdd() {
@ -85,10 +117,24 @@ async function setAdd() {
newValue.value = "";
await load();
}
async function setRemove(member: string) {
async function applySetRemove(member: string) {
await api.redisSetRemove(props.connectionId, props.keyName, member);
await load();
}
function requestSetRemove(member: string) {
pendingDelete.value = { kind: "set", member };
showDeleteConfirm.value = true;
}
async function confirmDelete() {
const pending = pendingDelete.value;
if (!pending) return;
if (pending.kind === "key") await applyDeleteKey();
else if (pending.kind === "hash") await applyHashDel(pending.field);
else if (pending.kind === "list") await applyListRemove(pending.index);
else await applySetRemove(pending.member);
pendingDelete.value = null;
}
function formatValue(val: any): string {
if (typeof val === "string") return val;
@ -114,7 +160,7 @@ onMounted(load);
<span class="flex-1" />
<Button variant="ghost" size="icon" class="h-7 w-7" @click="load"><RefreshCw class="h-3.5 w-3.5" /></Button>
<Button variant="ghost" size="icon" class="h-7 w-7" @click="copyValue"><Copy class="h-3.5 w-3.5" /></Button>
<Button variant="ghost" size="icon" class="h-7 w-7 text-destructive" @click="deleteKey"><Trash2 class="h-3.5 w-3.5" /></Button>
<Button variant="ghost" size="icon" class="h-7 w-7 text-destructive" @click="requestDeleteKey"><Trash2 class="h-3.5 w-3.5" /></Button>
</div>
<!-- String -->
@ -138,7 +184,7 @@ onMounted(load);
<div v-for="(item, idx) in data.value" :key="idx" class="px-4 py-1.5 border-b text-sm font-mono hover:bg-accent/50 flex items-center gap-2 group">
<span class="text-muted-foreground text-xs w-8 shrink-0">{{ idx }}</span>
<span class="truncate flex-1">{{ item }}</span>
<Button variant="ghost" size="icon" class="h-5 w-5 opacity-0 group-hover:opacity-100 text-destructive" @click="listRemove(Number(idx))"><Trash2 class="w-3 h-3" /></Button>
<Button variant="ghost" size="icon" class="h-5 w-5 opacity-0 group-hover:opacity-100 text-destructive" @click="requestListRemove(Number(idx))"><Trash2 class="w-3 h-3" /></Button>
</div>
</div>
</div>
@ -154,7 +200,7 @@ onMounted(load);
<div class="flex-1 overflow-y-auto">
<div v-for="(item, idx) in data.value" :key="idx" class="px-4 py-1.5 border-b text-sm font-mono hover:bg-accent/50 flex items-center gap-2 group">
<span class="truncate flex-1">{{ item }}</span>
<Button variant="ghost" size="icon" class="h-5 w-5 opacity-0 group-hover:opacity-100 text-destructive" @click="setRemove(String(item))"><Trash2 class="w-3 h-3" /></Button>
<Button variant="ghost" size="icon" class="h-5 w-5 opacity-0 group-hover:opacity-100 text-destructive" @click="requestSetRemove(String(item))"><Trash2 class="w-3 h-3" /></Button>
</div>
</div>
</div>
@ -172,7 +218,7 @@ onMounted(load);
<div v-for="(val, field) in data.value" :key="String(field)" class="px-4 py-1.5 border-b text-sm font-mono hover:bg-accent/50 flex items-center gap-3 group">
<span class="text-blue-500 shrink-0 min-w-24">{{ field }}</span>
<span class="truncate text-muted-foreground flex-1">{{ val }}</span>
<Button variant="ghost" size="icon" class="h-5 w-5 opacity-0 group-hover:opacity-100 text-destructive" @click="hashDel(String(field))"><Trash2 class="w-3 h-3" /></Button>
<Button variant="ghost" size="icon" class="h-5 w-5 opacity-0 group-hover:opacity-100 text-destructive" @click="requestHashDel(String(field))"><Trash2 class="w-3 h-3" /></Button>
</div>
</div>
</div>
@ -193,5 +239,13 @@ onMounted(load);
<pre class="font-mono text-sm whitespace-pre-wrap">{{ formatValue(data.value) }}</pre>
</div>
</template>
<DangerConfirmDialog
v-model:open="showDeleteConfirm"
:message="t('dangerDialog.deleteMessage')"
:details="deleteDetails"
:confirm-label="t('dangerDialog.deleteConfirm')"
@confirm="confirmDelete"
/>
</div>
</template>

View File

@ -196,6 +196,18 @@ export default {
dangerDialog: {
title: "Dangerous Operation",
message: "This SQL statement may modify or delete data irreversibly. Are you sure you want to execute it?",
deleteMessage: "This delete operation may be irreversible. Continue?",
deleteConfirm: "Confirm Delete",
deleteRowMessage: "This row will be marked for deletion and removed from the database after saving. Continue?",
deleteRowDetails: "Table: {table}",
deleteRowDetailsNoTable: "Current result row",
redisKeyDetails: "Redis key: {key}",
redisHashFieldDetails: "Redis hash: {key}\nField: {field}",
redisListItemDetails: "Redis list: {key}\nIndex: {index}",
redisSetMemberDetails: "Redis set: {key}\nMember: {member}",
mongoDocumentDetails: "MongoDB collection: {collection}\nDocument _id: {id}",
mongoFieldDetails: "Field: {field}",
mongoArrayItemDetails: "Array item index: {index}",
cancel: "Cancel",
confirm: "Execute",
},

View File

@ -194,6 +194,18 @@ export default {
dangerDialog: {
title: "危险操作",
message: "此 SQL 语句可能不可逆地修改或删除数据,确认要执行吗?",
deleteMessage: "此删除操作可能不可逆,确认要继续吗?",
deleteConfirm: "确认删除",
deleteRowMessage: "此行将被标记为删除,保存后会从数据库删除,确认要继续吗?",
deleteRowDetails: "数据表:{table}",
deleteRowDetailsNoTable: "当前结果集中的行",
redisKeyDetails: "Redis key{key}",
redisHashFieldDetails: "Redis hash{key}\n字段{field}",
redisListItemDetails: "Redis list{key}\n索引{index}",
redisSetMemberDetails: "Redis set{key}\n成员{member}",
mongoDocumentDetails: "MongoDB 集合:{collection}\n文档 _id{id}",
mongoFieldDetails: "字段:{field}",
mongoArrayItemDetails: "数组项索引:{index}",
cancel: "取消",
confirm: "执行",
},