feat(safety): 增加危险删除操作二次确认 (#11)
将现有 SQL 危险确认弹窗扩展为可复用确认组件。 覆盖 SQL UPDATE/MERGE/REPLACE 等危险语句识别,并在识别前去除注释。 为表格行删除、Redis key/hash/list/set 删除、MongoDB 文档和字段删除接入确认弹窗。 补充中英文危险确认文案,保持本次提交只聚焦确认流程。
This commit is contained in:
1 parent
84ea2a6e13
commit
68430d16c1
8 files changed
+231
-27
No files matched your search
@@ -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>
|
||||
|
||||
|
||||
@@ -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>
|
||||
|
||||
Reference in new issue
Block a user