feat(database): 优化数据库编辑体验 (#10)

改进关系型数据库表格编辑,增加新增、修改、删除和恢复等行状态展示,保存时按主键生成对应 SQL。

优化 MongoDB 文档编辑,默认保留 JSON 阅读视图,点击 Edit 后使用递归 JSON 结构编辑器,支持嵌套对象和数组分层编辑,边界更清晰。

同步中英文界面文案。

验证:pnpm build;git diff --check。
This commit is contained in:
vrustx
2026-04-30 13:03:57 +08:00
committed by GitHub
parent 19f138ad22
commit 753b72ebe4
6 changed files with 779 additions and 81 deletions
+329
View File
@@ -0,0 +1,329 @@
<script setup lang="ts">
import { computed } from "vue";
import { useI18n } from "vue-i18n";
import { Plus, Trash2 } from "lucide-vue-next";
import { Button } from "@/components/ui/button";
import type { EditNode, EditNodeKind } from "@/types/editor";
defineOptions({ name: "JsonEditNode" });
const props = withDefaults(defineProps<{
node: EditNode;
removable?: boolean;
parentKind?: EditNodeKind | "root";
}>(), {
removable: true,
parentKind: "root",
});
const emit = defineEmits<{
(e: "remove"): void;
}>();
const { t } = useI18n();
const isContainer = computed(() => props.node.kind !== "value");
const childKeyWidth = computed(() => {
const longest = props.node.children.reduce((max, child) => {
return Math.max(max, Array.from(child.keyName || "").length);
}, 0);
return `${Math.min(Math.max(longest + 4, 8), 36)}ch`;
});
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));
}, 0);
return Math.min(Math.max(estimatedRows, 2), 14);
}
function fieldValueTone(value: string): string {
const trimmed = value.trim();
if (trimmed.startsWith("\"")) return "is-string";
if (/^(true|false)$/i.test(trimmed)) return "is-boolean";
if (/^(null|NULL)$/i.test(trimmed)) return "is-null";
if (/^-?\d+(?:\.\d+)?$/.test(trimmed)) return "is-number";
if (trimmed.startsWith("{") || trimmed.startsWith("[")) return "is-complex";
return "is-string";
}
function createBlankNode(keyName: string, readonlyKey: boolean): EditNode {
return {
key: crypto.randomUUID(),
keyName,
kind: "value",
valueText: "",
readonlyKey,
readonlyValue: false,
children: [],
};
}
function addChild() {
if (props.node.kind === "array") {
props.node.children.push(createBlankNode(String(props.node.children.length), true));
return;
}
props.node.children.push(createBlankNode("", false));
}
function removeChild(idx: number) {
props.node.children.splice(idx, 1);
if (props.node.kind === "array") {
props.node.children.forEach((child, childIdx) => {
child.keyName = String(childIdx);
});
}
}
</script>
<template>
<div class="json-node">
<div class="json-edit-row" :class="{ 'is-container': isContainer }">
<div class="json-edit-key">
<template v-if="parentKind === 'array'">
<span class="json-edit-index">[{{ node.keyName }}]</span>
</template>
<template v-else>
<span class="json-edit-quote">"</span>
<input
v-model="node.keyName"
class="json-edit-key-input"
:disabled="node.readonlyKey"
:placeholder="t('mongo.fieldPlaceholder')"
/>
<span class="json-edit-quote">"</span>
</template>
<span class="json-edit-colon">:</span>
</div>
<textarea
v-if="node.kind === 'value'"
v-model="node.valueText"
class="json-edit-value"
:class="[fieldValueTone(node.valueText), { 'is-readonly': node.readonlyValue }]"
:disabled="node.readonlyValue"
:rows="fieldRows(node.valueText)"
wrap="soft"
/>
<div v-else class="json-edit-container-open">
<span>{{ node.kind === 'array' ? '[' : '{' }}</span>
<span class="json-edit-count">{{ node.children.length }}</span>
</div>
<span class="json-edit-comma">{{ node.kind === 'value' ? ',' : '' }}</span>
<Button
v-if="removable"
variant="ghost"
size="icon"
class="json-edit-remove"
:title="t('mongo.deleteField')"
@click="emit('remove')"
>
<Trash2 class="w-3 h-3" />
</Button>
<span v-else-if="node.readonlyValue" class="json-edit-lock">{{ t('mongo.readonlyId') }}</span>
</div>
<div
v-if="isContainer"
class="json-edit-children"
:style="{ '--mongo-key-width': childKeyWidth }"
>
<JsonEditNode
v-for="(child, idx) in node.children"
:key="child.key"
:node="child"
:parent-kind="node.kind"
:removable="!child.readonlyValue || node.kind === 'array'"
@remove="removeChild(idx)"
/>
<Button variant="ghost" size="sm" class="json-edit-add" @click="addChild">
<Plus class="w-3 h-3 mr-1" /> {{ t('mongo.addField') }}
</Button>
<div class="json-edit-close">
{{ node.kind === 'array' ? ']' : '}' }}<span class="json-edit-comma">,</span>
</div>
</div>
</div>
</template>
<style scoped>
.json-edit-row {
display: grid;
grid-template-columns: var(--mongo-key-width) minmax(380px, 1fr) 14px minmax(32px, auto);
gap: 8px;
align-items: start;
margin: 4px 0 4px 2ch;
padding: 7px 8px;
border: 1px solid color-mix(in oklab, var(--border) 82%, transparent);
border-left-width: 3px;
border-radius: 8px;
background: color-mix(in oklab, var(--background) 76%, var(--muted));
}
.json-edit-row:hover {
background: color-mix(in oklab, var(--background) 58%, var(--muted));
}
.json-edit-row.is-container {
border-bottom-left-radius: 4px;
border-bottom-right-radius: 4px;
}
.json-edit-key {
display: flex;
align-items: center;
min-height: 34px;
color: #7c3aed;
font-weight: 600;
}
.json-edit-key-input {
width: 100%;
min-width: 0;
border: 0;
border-radius: 4px;
background: transparent;
color: inherit;
font: inherit;
outline: none;
}
.json-edit-key-input:focus {
background: var(--background);
box-shadow: 0 0 0 1px var(--ring);
}
.json-edit-key-input:disabled {
color: var(--muted-foreground);
cursor: not-allowed;
}
.json-edit-quote,
.json-edit-colon,
.json-edit-comma,
.json-edit-close {
color: var(--muted-foreground);
}
.json-edit-colon {
margin-left: 2px;
}
.json-edit-index {
color: var(--muted-foreground);
}
.json-edit-value {
width: 100%;
min-height: 42px;
resize: vertical;
border: 1px solid var(--border);
border-radius: 6px;
background: var(--background);
padding: 5px 8px;
font: inherit;
line-height: 1.55;
outline: none;
overflow-y: auto;
white-space: pre-wrap;
overflow-wrap: anywhere;
}
.json-edit-value:focus {
border-color: var(--ring);
box-shadow: 0 0 0 2px color-mix(in oklab, var(--ring) 28%, transparent);
}
.json-edit-value.is-readonly {
color: var(--muted-foreground);
cursor: not-allowed;
}
.json-edit-value.is-string {
color: #15803d;
}
.json-edit-value.is-number {
color: #b45309;
}
.json-edit-value.is-boolean {
color: #2563eb;
font-weight: 600;
}
.json-edit-value.is-null {
color: #64748b;
font-style: italic;
}
.json-edit-value.is-complex,
.json-edit-container-open {
color: var(--foreground);
}
.json-edit-count {
margin-left: 8px;
color: var(--muted-foreground);
font-family: ui-sans-serif, system-ui, sans-serif;
font-size: 11px;
}
.json-edit-remove {
height: 28px;
width: 28px;
color: var(--destructive);
}
.json-edit-lock {
display: inline-flex;
align-items: center;
min-height: 28px;
color: var(--muted-foreground);
font-family: ui-sans-serif, system-ui, sans-serif;
font-size: 11px;
white-space: nowrap;
}
.json-edit-children {
margin: -4px 0 6px calc(2ch + 12px);
padding: 4px 0 4px 10px;
border-left: 1px solid var(--border);
}
.json-edit-close {
margin: 4px 0 4px 2ch;
font-weight: 700;
}
.json-edit-add {
margin: 6px 0 6px 2ch;
font-family: ui-sans-serif, system-ui, sans-serif;
}
:global(.dark) .json-edit-key {
color: #c4b5fd;
}
:global(.dark) .json-edit-value.is-string {
color: #86efac;
}
:global(.dark) .json-edit-value.is-number {
color: #fbbf24;
}
:global(.dark) .json-edit-value.is-boolean {
color: #93c5fd;
}
:global(.dark) .json-edit-value.is-null {
color: #94a3b8;
}
</style>
+210 -29
View File
@@ -1,10 +1,12 @@
<script setup lang="ts">
import { ref, onMounted } from "vue";
import { computed, ref, onMounted } from "vue";
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 * as api from "@/lib/tauri";
import JsonEditNode from "./JsonEditNode.vue";
import type { EditNode } from "@/types/editor";
import { Splitpanes, Pane } from "splitpanes";
import "splitpanes/dist/splitpanes.css";
@@ -16,7 +18,9 @@ const props = defineProps<{
collection: string;
}>();
const documents = ref<any[]>([]);
type JsonRecord = Record<string, unknown>;
const documents = ref<JsonRecord[]>([]);
const total = ref(0);
const loading = ref(false);
const page = ref(0);
@@ -26,6 +30,19 @@ const editJson = ref("");
const isEditing = ref(false);
const isNew = ref(false);
const error = ref("");
const editFields = ref<EditNode[]>([]);
const selectedDoc = computed(() => {
if (selectedIdx.value === null) return null;
return documents.value[selectedIdx.value] ?? null;
});
const editKeyWidth = computed(() => {
const longest = editFields.value.reduce((max, field) => {
return Math.max(max, Array.from(field.keyName || "").length);
}, 0);
return `${Math.min(Math.max(longest + 4, 8), 36)}ch`;
});
async function load() {
loading.value = true;
@@ -35,46 +52,172 @@ async function load() {
props.connectionId, props.database, props.collection,
page.value * pageSize, pageSize
);
documents.value = result.documents;
documents.value = result.documents.map(asRecord);
total.value = result.total;
} catch (e: any) {
} catch (e: unknown) {
error.value = String(e);
} finally {
loading.value = false;
}
}
function asRecord(value: unknown): JsonRecord {
if (value && typeof value === "object" && !Array.isArray(value)) {
return value as JsonRecord;
}
return {};
}
function selectDoc(idx: number) {
selectedIdx.value = idx;
editJson.value = JSON.stringify(documents.value[idx], null, 2);
isEditing.value = false;
isNew.value = false;
editFields.value = [];
}
function startNew() {
selectedIdx.value = null;
editJson.value = '{\n \n}';
editJson.value = "";
editFields.value = [createEditNode("", "", false, false)];
isEditing.value = true;
isNew.value = true;
}
function startEdit() {
const doc = selectedDoc.value;
if (!doc) return;
editFields.value = Object.entries(doc).map(([name, value]) =>
createEditNode(name, value, name === "_id", name === "_id")
);
isEditing.value = true;
isNew.value = false;
}
function cancelEdit() {
isEditing.value = false;
if (isNew.value) {
isNew.value = false;
editFields.value = [];
return;
}
if (selectedDoc.value) {
editJson.value = JSON.stringify(selectedDoc.value, null, 2);
}
editFields.value = [];
error.value = "";
}
function createEditNode(keyName: string, value: unknown, readonlyKey: boolean, readonlyValue: boolean): EditNode {
if (Array.isArray(value)) {
return {
key: crypto.randomUUID(),
keyName,
kind: "array",
valueText: "",
readonlyKey,
readonlyValue,
children: value.map((child, idx) => createEditNode(String(idx), child, true, readonlyValue)),
};
}
if (value && typeof value === "object") {
return {
key: crypto.randomUUID(),
keyName,
kind: "object",
valueText: "",
readonlyKey,
readonlyValue,
children: Object.entries(value as JsonRecord).map(([childName, child]) =>
createEditNode(childName, child, readonlyValue, readonlyValue)
),
};
}
return {
key: crypto.randomUUID(),
keyName,
kind: "value",
valueText: formatForEdit(value),
readonlyKey,
readonlyValue,
children: [],
};
}
function addField() {
editFields.value.push(createEditNode("", "", false, false));
}
function removeField(idx: number) {
if (editFields.value[idx]?.readonlyValue) return;
editFields.value.splice(idx, 1);
}
function formatForEdit(value: unknown): string {
if (value === undefined) return "";
if (value === null) return "null";
if (typeof value === "string") return JSON.stringify(value);
if (typeof value === "object") return JSON.stringify(value, null, 2);
return String(value);
}
function parseFieldValue(raw: string): unknown {
const trimmed = raw.trim();
if (trimmed === "NULL") return null;
if (/^(true|false|null)$/i.test(trimmed)) return JSON.parse(trimmed.toLowerCase());
if (/^-?\d+(?:\.\d+)?$/.test(trimmed)) return Number(trimmed);
if (trimmed.startsWith("{") || trimmed.startsWith("[") || trimmed.startsWith("\"")) {
return JSON.parse(trimmed);
}
return raw;
}
function buildObjectFromNodes(nodes: EditNode[], path: string): JsonRecord {
const doc: JsonRecord = {};
const seen = new Set<string>();
for (const field of nodes) {
const name = field.keyName.trim();
if (!name || (!path && name === "_id")) continue;
if (seen.has(name)) throw new Error(t("mongo.duplicateField", { field: name }));
seen.add(name);
doc[name] = buildValueFromNode(field, path ? `${path}.${name}` : name);
}
return doc;
}
function buildValueFromNode(node: EditNode, path: string): unknown {
if (node.kind === "value") return parseFieldValue(node.valueText);
if (node.kind === "array") {
return node.children.map((child, idx) => buildValueFromNode(child, `${path}[${idx}]`));
}
return buildObjectFromNodes(node.children, path);
}
function buildDocumentFromFields(): JsonRecord {
return buildObjectFromNodes(editFields.value, "");
}
async function saveDoc() {
error.value = "";
try {
const doc = buildDocumentFromFields();
if (isNew.value) {
await api.mongoInsertDocument(props.connectionId, props.database, props.collection, editJson.value);
await api.mongoInsertDocument(props.connectionId, props.database, props.collection, JSON.stringify(doc));
} else if (selectedIdx.value !== null) {
const doc = documents.value[selectedIdx.value];
const id = doc._id;
const current = documents.value[selectedIdx.value];
const id = current?._id;
if (!id) { error.value = "No _id field"; return; }
const parsed = JSON.parse(editJson.value);
delete parsed._id;
await api.mongoUpdateDocument(props.connectionId, props.database, props.collection, id, JSON.stringify(parsed));
await api.mongoUpdateDocument(props.connectionId, props.database, props.collection, String(id), JSON.stringify(doc));
}
isEditing.value = false;
isNew.value = false;
editFields.value = [];
await load();
} catch (e: any) {
} catch (e: unknown) {
error.value = String(e);
}
}
@@ -85,10 +228,10 @@ async function deleteDoc(idx: number) {
if (!id) return;
error.value = "";
try {
await api.mongoDeleteDocument(props.connectionId, props.database, props.collection, id);
await api.mongoDeleteDocument(props.connectionId, props.database, props.collection, String(id));
if (selectedIdx.value === idx) { selectedIdx.value = null; editJson.value = ""; }
await load();
} catch (e: any) {
} catch (e: unknown) {
error.value = String(e);
}
}
@@ -105,11 +248,11 @@ function nextPage() {
load();
}
function docPreview(doc: any): string {
function docPreview(doc: JsonRecord): string {
const id = doc._id || "";
const keys = Object.keys(doc).filter(k => k !== "_id").slice(0, 3);
const preview = keys.map(k => `${k}: ${JSON.stringify(doc[k]).substring(0, 30)}`).join(", ");
return `${id} — ${preview}`;
return `${id} - ${preview}`;
}
function highlightedJson(json: string): string {
@@ -139,10 +282,10 @@ onMounted(load);
<Pane :size="30" :min-size="15" :max-size="50">
<div class="h-full flex flex-col overflow-hidden">
<div class="flex items-center gap-1 px-3 py-1.5 border-b shrink-0 text-xs text-muted-foreground">
<span>{{ total }} documents</span>
<span>{{ t('mongo.documents', { count: total }) }}</span>
<span class="flex-1" />
<Button variant="ghost" size="icon" class="h-5 w-5" @click="startNew"><Plus class="h-3 w-3" /></Button>
<Button variant="ghost" size="icon" class="h-5 w-5" @click="load"><RefreshCw class="h-3 w-3" /></Button>
<Button variant="ghost" size="icon" class="h-5 w-5" @click="load"><RefreshCw class="h-3 w-3" :class="{ 'animate-spin': loading }" /></Button>
</div>
<div class="flex-1 overflow-y-auto">
@@ -159,7 +302,7 @@ onMounted(load);
</Button>
</div>
<div v-if="documents.length === 0 && !loading" class="px-3 py-8 text-center text-muted-foreground text-xs">
Empty collection
{{ t('mongo.emptyCollection') }}
</div>
</div>
@@ -181,19 +324,39 @@ onMounted(load);
<div class="h-full flex flex-col min-w-0 overflow-hidden">
<template v-if="selectedIdx !== null || isNew">
<div class="flex items-center gap-2 px-4 py-2 border-b bg-muted/30 shrink-0">
<Badge variant="secondary" class="text-xs">{{ isNew ? 'New' : documents[selectedIdx!]?._id }}</Badge>
<Badge variant="secondary" class="text-xs">{{ isNew ? 'New' : selectedDoc?._id }}</Badge>
<span class="flex-1" />
<Button v-if="!isEditing" variant="ghost" size="sm" class="h-6 text-xs" @click="isEditing = true">Edit</Button>
<Button v-if="!isEditing" variant="ghost" size="sm" class="h-6 text-xs" @click="startEdit">Edit</Button>
<template v-if="isEditing">
<Button variant="ghost" size="sm" class="h-6 text-xs" @click="isEditing = false; isNew = false">{{ t('grid.discard') }}</Button>
<Button variant="ghost" size="sm" class="h-6 text-xs" @click="addField">
<Plus class="w-3 h-3 mr-1" /> {{ t('mongo.addField') }}
</Button>
<Button variant="ghost" size="sm" class="h-6 text-xs" @click="cancelEdit">{{ t('grid.discard') }}</Button>
<Button size="sm" class="h-6 text-xs" @click="saveDoc"><Save class="w-3 h-3 mr-1" />{{ t('grid.save') }}</Button>
</template>
</div>
<textarea
v-if="isEditing"
v-model="editJson"
class="flex-1 p-4 font-mono text-xs bg-background resize-none outline-none"
/>
<div v-if="isEditing" class="flex-1 overflow-auto bg-muted/10">
<div class="json-edit min-w-fit p-5 font-mono text-[13px] leading-6" :style="{ '--mongo-key-width': editKeyWidth }">
<div class="json-edit-brace">{</div>
<JsonEditNode
v-for="(field, idx) in editFields"
:key="field.key"
:node="field"
parent-kind="root"
:removable="!field.readonlyValue"
@remove="removeField(idx)"
/>
<Button variant="ghost" size="sm" class="json-edit-add" @click="addField">
<Plus class="w-3 h-3 mr-1" /> {{ t('mongo.addField') }}
</Button>
<div class="json-edit-brace">}</div>
</div>
</div>
<div v-else class="flex-1 overflow-auto bg-muted/10">
<pre
class="json-viewer min-w-fit p-5 font-mono text-[13px] leading-6"
@@ -202,7 +365,7 @@ onMounted(load);
</div>
</template>
<div v-else class="h-full flex items-center justify-center text-muted-foreground text-sm">
Select a document
{{ t('mongo.selectDocument') }}
</div>
<div v-if="error" class="px-3 py-1.5 border-t bg-destructive/10 text-destructive text-xs shrink-0">
@@ -216,7 +379,24 @@ onMounted(load);
<style scoped>
.json-viewer {
tab-size: 2;
white-space: pre;
white-space: pre-wrap;
overflow-wrap: anywhere;
}
.json-edit {
tab-size: 2;
color: var(--foreground);
white-space: pre-wrap;
}
.json-edit-brace {
color: var(--muted-foreground);
font-weight: 700;
}
.json-edit-add {
margin: 6px 0 6px 2ch;
font-family: ui-sans-serif, system-ui, sans-serif;
}
:deep(.json-key) {
@@ -261,4 +441,5 @@ onMounted(load);
:global(.dark) :deep(.json-null) {
color: #94a3b8;
}
</style>