feat(database): 优化数据库编辑体验 (#10)
改进关系型数据库表格编辑,增加新增、修改、删除和恢复等行状态展示,保存时按主键生成对应 SQL。 优化 MongoDB 文档编辑,默认保留 JSON 阅读视图,点击 Edit 后使用递归 JSON 结构编辑器,支持嵌套对象和数组分层编辑,边界更清晰。 同步中英文界面文案。 验证:pnpm build;git diff --check。
This commit is contained in:
parent
19f138ad22
commit
753b72ebe4
|
|
@ -7,8 +7,9 @@ const globalDdlOpen = ref(false);
|
|||
import { computed, nextTick, watch } from "vue";
|
||||
import { useElementSize } from "@vueuse/core";
|
||||
import { useI18n } from "vue-i18n";
|
||||
import { ArrowUp, ArrowDown, Download, Plus, Trash2, Save, ChevronLeft, ChevronRight, Search, Inbox, SearchX, Code2, Copy, Loader2, X } from "lucide-vue-next";
|
||||
import { ArrowUp, ArrowDown, Download, Plus, Trash2, Save, ChevronLeft, ChevronRight, Search, Inbox, SearchX, Code2, Copy, Loader2, X, Undo2 } from "lucide-vue-next";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import {
|
||||
ContextMenu, ContextMenuContent, ContextMenuItem,
|
||||
ContextMenuSeparator, ContextMenuTrigger,
|
||||
|
|
@ -90,7 +91,7 @@ function typeColorClass(t: string): string {
|
|||
if (["bytea", "blob", "binary", "varbinary", "image"].includes(s)) return "text-red-400";
|
||||
return "text-muted-foreground";
|
||||
}
|
||||
const contextCell = ref<{ row: number; col: number } | null>(null);
|
||||
const contextCell = ref<{ rowId: number; col: number } | null>(null);
|
||||
const sortCol = ref<string | null>(null);
|
||||
const sortDir = ref<"asc" | "desc">("asc");
|
||||
const searchText = ref("");
|
||||
|
|
@ -131,24 +132,28 @@ function onResizeStart(colIdx: number, event: MouseEvent) {
|
|||
}
|
||||
|
||||
const ROW_NUM_WIDTH = 48;
|
||||
const ACTION_COL_WIDTH = 112;
|
||||
const baseTotalWidth = computed(() => columnWidths.value.reduce((a, b) => a + b, 0));
|
||||
const rowActionWidth = computed(() => props.editable ? ACTION_COL_WIDTH : 0);
|
||||
const renderedColumnWidths = computed(() => {
|
||||
const widths = columnWidths.value;
|
||||
if (widths.length === 0) return widths;
|
||||
|
||||
const extraWidth = Math.max(0, gridWidth.value - ROW_NUM_WIDTH - baseTotalWidth.value);
|
||||
const extraWidth = Math.max(0, gridWidth.value - ROW_NUM_WIDTH - rowActionWidth.value - baseTotalWidth.value);
|
||||
if (extraWidth === 0) return widths;
|
||||
|
||||
const extraPerColumn = extraWidth / widths.length;
|
||||
return widths.map((width) => width + extraPerColumn);
|
||||
});
|
||||
const totalWidth = computed(() => renderedColumnWidths.value.reduce((a, b) => a + b, 0) + ROW_NUM_WIDTH);
|
||||
const totalWidth = computed(() => renderedColumnWidths.value.reduce((a, b) => a + b, 0) + ROW_NUM_WIDTH + rowActionWidth.value);
|
||||
|
||||
const columnVars = computed(() => {
|
||||
const vars: Record<string, string> = {};
|
||||
renderedColumnWidths.value.forEach((w, i) => {
|
||||
vars[`--col-w-${i}`] = `${w}px`;
|
||||
});
|
||||
vars["--row-num-w"] = `${ROW_NUM_WIDTH}px`;
|
||||
vars["--row-action-w"] = `${rowActionWidth.value}px`;
|
||||
vars['--total-w'] = `${totalWidth.value}px`;
|
||||
return vars;
|
||||
});
|
||||
|
|
@ -179,44 +184,62 @@ function changePageSize(size: number) {
|
|||
|
||||
// --- Editing ---
|
||||
type CellValue = string | number | boolean | null;
|
||||
const editingCell = ref<{ row: number; col: number } | null>(null);
|
||||
type RowStatus = "clean" | "edited" | "new" | "deleted";
|
||||
const editingCell = ref<{ rowId: number; col: number } | null>(null);
|
||||
const editValue = ref("");
|
||||
const scrollerRef = ref<HTMLElement | { $el?: HTMLElement; el?: HTMLElement | { value?: HTMLElement } } | null>(null);
|
||||
const dirtyRows = ref<Map<number, Map<number, CellValue>>>(new Map());
|
||||
const newRows = ref<CellValue[][]>([]);
|
||||
const deletedRows = ref<Set<number>>(new Set());
|
||||
|
||||
const dirtyRowCount = computed(() => dirtyRows.value.size);
|
||||
const newRowCount = computed(() => newRows.value.length);
|
||||
const deletedRowCount = computed(() => deletedRows.value.size);
|
||||
const pendingChangeCount = computed(() => dirtyRowCount.value + newRowCount.value + deletedRowCount.value);
|
||||
const hasPendingChanges = computed(() =>
|
||||
dirtyRows.value.size > 0 || newRows.value.length > 0 || deletedRows.value.size > 0
|
||||
pendingChangeCount.value > 0
|
||||
);
|
||||
|
||||
const sortedRows = computed(() => {
|
||||
let rows = props.result.rows;
|
||||
let rows = props.result.rows.map((row, sourceIndex) => ({ row, sourceIndex }));
|
||||
if (searchText.value) {
|
||||
const q = searchText.value.toLowerCase();
|
||||
rows = rows.filter((row) => row.some((cell) => cell !== null && String(cell).toLowerCase().includes(q)));
|
||||
rows = rows.filter(({ row, sourceIndex }) => {
|
||||
const data = rowDataWithChanges(row, sourceIndex);
|
||||
return data.some((cell) => cell !== null && String(cell).toLowerCase().includes(q));
|
||||
});
|
||||
}
|
||||
return rows;
|
||||
});
|
||||
|
||||
function rowDataWithChanges(row: CellValue[], sourceIndex: number): CellValue[] {
|
||||
const dirty = dirtyRows.value.get(sourceIndex);
|
||||
return row.map((v, colIdx) => dirty?.get(colIdx) ?? v);
|
||||
}
|
||||
|
||||
interface RowItem {
|
||||
id: number;
|
||||
sourceIndex?: number;
|
||||
newIndex?: number;
|
||||
data: CellValue[];
|
||||
isNew: boolean;
|
||||
isDeleted: boolean;
|
||||
isDirtyCol: boolean[];
|
||||
status: RowStatus;
|
||||
}
|
||||
|
||||
const displayItems = computed<RowItem[]>(() => {
|
||||
const cols = props.result.columns;
|
||||
const items: RowItem[] = sortedRows.value.map((row, i) => {
|
||||
const dirty = dirtyRows.value.get(i);
|
||||
const data = row.map((v, colIdx) => dirty?.get(colIdx) ?? v);
|
||||
const items: RowItem[] = sortedRows.value.map(({ row, sourceIndex }) => {
|
||||
const dirty = dirtyRows.value.get(sourceIndex);
|
||||
const data = rowDataWithChanges(row, sourceIndex);
|
||||
const isDirtyCol = row.map((_, colIdx) => dirty?.has(colIdx) ?? false);
|
||||
return { id: i, data, isNew: false, isDeleted: deletedRows.value.has(i), isDirtyCol };
|
||||
const isDeleted = deletedRows.value.has(sourceIndex);
|
||||
const status: RowStatus = isDeleted ? "deleted" : dirty ? "edited" : "clean";
|
||||
return { id: sourceIndex, sourceIndex, data, isNew: false, isDeleted, isDirtyCol, status };
|
||||
});
|
||||
newRows.value.forEach((row, i) => {
|
||||
items.push({ id: 100000 + i, data: row, isNew: true, isDeleted: false, isDirtyCol: cols.map(() => false) });
|
||||
items.push({ id: -(i + 1), newIndex: i, data: row, isNew: true, isDeleted: false, isDirtyCol: cols.map(() => false), status: "new" });
|
||||
});
|
||||
return items;
|
||||
});
|
||||
|
|
@ -244,6 +267,20 @@ function formatCell(value: CellValue): string {
|
|||
|
||||
function isNull(value: unknown): boolean { return value === null; }
|
||||
|
||||
function rowStatusLabel(item: RowItem): string {
|
||||
if (item.status === "new") return t("grid.statusNew");
|
||||
if (item.status === "edited") return t("grid.statusEdited");
|
||||
if (item.status === "deleted") return t("grid.statusDeleted");
|
||||
return t("grid.statusClean");
|
||||
}
|
||||
|
||||
function rowStatusVariant(item: RowItem): "default" | "secondary" | "destructive" | "outline" {
|
||||
if (item.status === "new") return "default";
|
||||
if (item.status === "edited") return "secondary";
|
||||
if (item.status === "deleted") return "destructive";
|
||||
return "outline";
|
||||
}
|
||||
|
||||
// --- Inline editor ---
|
||||
let isCancelling = false;
|
||||
let cancelScrollRestoreFrame = 0;
|
||||
|
|
@ -292,11 +329,29 @@ function restoreScrollAcrossFrames(restoreScroll: () => void) {
|
|||
});
|
||||
}
|
||||
|
||||
function startEdit(rowIdx: number, colIdx: number) {
|
||||
function getRowItem(rowId: number): RowItem | undefined {
|
||||
return displayItems.value.find((item) => item.id === rowId);
|
||||
}
|
||||
|
||||
function coerceCellValue(value: string, oldVal: CellValue | undefined): CellValue {
|
||||
if (value.toUpperCase() === "NULL") return null;
|
||||
if (value === "" && isNull(oldVal)) return null;
|
||||
if (typeof oldVal === "number") {
|
||||
const num = Number(value);
|
||||
if (!Number.isNaN(num)) return num;
|
||||
}
|
||||
if (typeof oldVal === "boolean") {
|
||||
return value === "true" || value === "1";
|
||||
}
|
||||
return value;
|
||||
}
|
||||
|
||||
function startEdit(rowId: number, colIdx: number) {
|
||||
if (!props.editable) return;
|
||||
const item = getRowItem(rowId);
|
||||
if (!item || item.isDeleted) return;
|
||||
isCancelling = false;
|
||||
editingCell.value = { row: rowIdx, col: colIdx };
|
||||
const item = displayItems.value.find((it) => it.id === rowIdx);
|
||||
editingCell.value = { rowId, col: colIdx };
|
||||
const val = item?.data[colIdx] ?? null;
|
||||
editValue.value = val === null ? "" : String(val);
|
||||
nextTick(() => {
|
||||
|
|
@ -309,20 +364,37 @@ function startEdit(rowIdx: number, colIdx: number) {
|
|||
function commitEdit() {
|
||||
if (isCancelling) return;
|
||||
if (!editingCell.value) return;
|
||||
const { row, col } = editingCell.value;
|
||||
const oldVal = sortedRows.value[row]?.[col];
|
||||
let newVal: CellValue = editValue.value;
|
||||
if (newVal === "" && isNull(oldVal)) newVal = null;
|
||||
else if (newVal === "NULL") newVal = null;
|
||||
else if (typeof oldVal === "number") {
|
||||
const num = Number(newVal);
|
||||
if (!isNaN(num)) newVal = num;
|
||||
} else if (typeof oldVal === "boolean") {
|
||||
newVal = newVal === "true" || newVal === "1";
|
||||
const { rowId, col } = editingCell.value;
|
||||
const item = getRowItem(rowId);
|
||||
if (!item || item.isDeleted) {
|
||||
editingCell.value = null;
|
||||
return;
|
||||
}
|
||||
|
||||
if (item.isNew && item.newIndex !== undefined) {
|
||||
const oldVal = newRows.value[item.newIndex]?.[col];
|
||||
const newVal = coerceCellValue(editValue.value, oldVal);
|
||||
if (newRows.value[item.newIndex]) {
|
||||
newRows.value[item.newIndex][col] = newVal;
|
||||
}
|
||||
editingCell.value = null;
|
||||
return;
|
||||
}
|
||||
|
||||
if (item.sourceIndex === undefined) {
|
||||
editingCell.value = null;
|
||||
return;
|
||||
}
|
||||
|
||||
const oldVal = props.result.rows[item.sourceIndex]?.[col];
|
||||
const newVal = coerceCellValue(editValue.value, oldVal);
|
||||
if (newVal !== oldVal) {
|
||||
if (!dirtyRows.value.has(row)) dirtyRows.value.set(row, new Map());
|
||||
dirtyRows.value.get(row)!.set(col, newVal);
|
||||
if (!dirtyRows.value.has(item.sourceIndex)) dirtyRows.value.set(item.sourceIndex, new Map());
|
||||
dirtyRows.value.get(item.sourceIndex)!.set(col, newVal);
|
||||
} else {
|
||||
const rowChanges = dirtyRows.value.get(item.sourceIndex);
|
||||
rowChanges?.delete(col);
|
||||
if (rowChanges?.size === 0) dirtyRows.value.delete(item.sourceIndex);
|
||||
}
|
||||
editingCell.value = null;
|
||||
}
|
||||
|
|
@ -342,15 +414,36 @@ function onEditKeydown(e: KeyboardEvent) {
|
|||
|
||||
function addRow() {
|
||||
newRows.value.push(props.result.columns.map(() => null));
|
||||
const rowId = -newRows.value.length;
|
||||
nextTick(() => {
|
||||
const el = getScrollerElement();
|
||||
if (el) el.scrollTop = el.scrollHeight;
|
||||
startEdit(rowId, 0);
|
||||
});
|
||||
}
|
||||
|
||||
function deleteRow(rowId: number) {
|
||||
const item = getRowItem(rowId);
|
||||
if (!item) return;
|
||||
if (item.isNew && item.newIndex !== undefined) {
|
||||
newRows.value.splice(item.newIndex, 1);
|
||||
} else if (item.sourceIndex !== undefined) {
|
||||
dirtyRows.value.delete(item.sourceIndex);
|
||||
deletedRows.value.add(item.sourceIndex);
|
||||
}
|
||||
if (editingCell.value?.rowId === rowId) editingCell.value = null;
|
||||
}
|
||||
|
||||
function restoreRow(rowId: number) {
|
||||
const item = getRowItem(rowId);
|
||||
if (item?.sourceIndex !== undefined) {
|
||||
deletedRows.value.delete(item.sourceIndex);
|
||||
}
|
||||
}
|
||||
|
||||
function deleteSelectedRow() {
|
||||
if (!contextCell.value) return;
|
||||
deletedRows.value.add(contextCell.value.row);
|
||||
deleteRow(contextCell.value.rowId);
|
||||
}
|
||||
|
||||
function escapeVal(v: CellValue): string {
|
||||
|
|
@ -386,7 +479,7 @@ function generateSaveStatements(): string[] {
|
|||
const tbl = qualifiedTableName();
|
||||
|
||||
for (const [rowIdx, changes] of dirtyRows.value) {
|
||||
const row = sortedRows.value[rowIdx];
|
||||
const row = props.result.rows[rowIdx];
|
||||
if (!row) continue;
|
||||
const sets = Array.from(changes.entries())
|
||||
.map(([colIdx, val]) => `${quoteIdent(cols[colIdx])} = ${escapeVal(val)}`)
|
||||
|
|
@ -398,7 +491,7 @@ function generateSaveStatements(): string[] {
|
|||
}
|
||||
|
||||
for (const rowIdx of deletedRows.value) {
|
||||
const row = sortedRows.value[rowIdx];
|
||||
const row = props.result.rows[rowIdx];
|
||||
if (!row) continue;
|
||||
const where = primaryKeys
|
||||
.map((pk) => `${quoteIdent(pk)} = ${escapeVal(row[cols.indexOf(pk)])}`)
|
||||
|
|
@ -452,44 +545,49 @@ function discardChanges() {
|
|||
}
|
||||
|
||||
// --- Copy/Export ---
|
||||
function onCellContext(rowIdx: number, colIdx: number) {
|
||||
contextCell.value = { row: rowIdx, col: colIdx };
|
||||
function onCellContext(rowId: number, colIdx: number) {
|
||||
contextCell.value = { rowId, col: colIdx };
|
||||
}
|
||||
|
||||
function copyCell() {
|
||||
if (!contextCell.value) return;
|
||||
const item = displayItems.value.find((it) => it.id === contextCell.value!.row);
|
||||
const item = getRowItem(contextCell.value.rowId);
|
||||
const val = item?.data[contextCell.value.col] ?? null;
|
||||
navigator.clipboard.writeText(formatCell(val));
|
||||
}
|
||||
|
||||
function copyRow() {
|
||||
if (!contextCell.value) return;
|
||||
const row = sortedRows.value[contextCell.value.row];
|
||||
if (!row) return;
|
||||
const item = getRowItem(contextCell.value.rowId);
|
||||
if (!item) return;
|
||||
const obj: Record<string, unknown> = {};
|
||||
props.result.columns.forEach((col, i) => { obj[col] = row[i]; });
|
||||
props.result.columns.forEach((col, i) => { obj[col] = item.data[i]; });
|
||||
navigator.clipboard.writeText(JSON.stringify(obj, null, 2));
|
||||
}
|
||||
|
||||
function copyAll() {
|
||||
const header = props.result.columns.join("\t");
|
||||
const body = sortedRows.value.map((row) => row.map((c) => formatCell(c)).join("\t")).join("\n");
|
||||
const body = sortedRows.value
|
||||
.map(({ row, sourceIndex }) => rowDataWithChanges(row, sourceIndex).map((c) => formatCell(c)).join("\t"))
|
||||
.join("\n");
|
||||
navigator.clipboard.writeText(`${header}\n${body}`);
|
||||
}
|
||||
|
||||
async function exportCsv() {
|
||||
const escape = (v: string) => `"${v.replace(/"/g, '""')}"`;
|
||||
const header = props.result.columns.map(escape).join(",");
|
||||
const body = sortedRows.value.map((row) => row.map((c) => escape(formatCell(c))).join(",")).join("\n");
|
||||
const body = sortedRows.value
|
||||
.map(({ row, sourceIndex }) => rowDataWithChanges(row, sourceIndex).map((c) => escape(formatCell(c))).join(","))
|
||||
.join("\n");
|
||||
const path = await savePath({ filters: [{ name: "CSV", extensions: ["csv"] }] });
|
||||
if (path) await writeTextFile(path, `${header}\n${body}`);
|
||||
}
|
||||
|
||||
async function exportJson() {
|
||||
const data = sortedRows.value.map((row) => {
|
||||
const data = sortedRows.value.map(({ row, sourceIndex }) => {
|
||||
const data = rowDataWithChanges(row, sourceIndex);
|
||||
const obj: Record<string, unknown> = {};
|
||||
props.result.columns.forEach((col, i) => { obj[col] = row[i]; });
|
||||
props.result.columns.forEach((col, i) => { obj[col] = data[i]; });
|
||||
return obj;
|
||||
});
|
||||
const path = await savePath({ filters: [{ name: "JSON", extensions: ["json"] }] });
|
||||
|
|
@ -499,10 +597,11 @@ async function exportJson() {
|
|||
async function exportMarkdown() {
|
||||
const pad = (s: string, len: number) => s.padEnd(len);
|
||||
const cols = props.result.columns;
|
||||
const widths = cols.map((c, i) => Math.max(c.length, ...sortedRows.value.map((r) => formatCell(r[i]).length), 3));
|
||||
const visibleRows = sortedRows.value.map(({ row, sourceIndex }) => rowDataWithChanges(row, sourceIndex));
|
||||
const widths = cols.map((c, i) => Math.max(c.length, ...visibleRows.map((r) => formatCell(r[i]).length), 3));
|
||||
const header = `| ${cols.map((c, i) => pad(c, widths[i])).join(" | ")} |`;
|
||||
const sep = `| ${widths.map((w) => "-".repeat(w)).join(" | ")} |`;
|
||||
const body = sortedRows.value.map((row) =>
|
||||
const body = visibleRows.map((row) =>
|
||||
`| ${row.map((c, i) => pad(formatCell(c), widths[i])).join(" | ")} |`
|
||||
).join("\n");
|
||||
const md = `${header}\n${sep}\n${body}\n`;
|
||||
|
|
@ -600,6 +699,9 @@ function escapeAndHighlightKeywords(s: string): string {
|
|||
<span v-if="searchText" class="text-xs text-muted-foreground">
|
||||
{{ sortedRows.length }}/{{ result.rows.length }}
|
||||
</span>
|
||||
<Button v-if="editable && tableMeta" variant="ghost" size="sm" class="h-5 text-xs px-1.5 shrink-0" @click="addRow">
|
||||
<Plus class="w-3 h-3 mr-1" /> {{ t('grid.addRow') }}
|
||||
</Button>
|
||||
<Button v-if="tableMeta && connectionId" variant="ghost" size="sm" class="h-5 text-xs px-1.5 shrink-0" :class="{ 'bg-accent': showDdl }" @click="toggleDdl">
|
||||
<Code2 class="w-3 h-3 mr-1" /> DDL
|
||||
</Button>
|
||||
|
|
@ -610,7 +712,10 @@ function escapeAndHighlightKeywords(s: string): string {
|
|||
<!-- Sticky header -->
|
||||
<div ref="headerRef" class="shrink-0 bg-muted z-10 border-b border-border overflow-hidden">
|
||||
<div class="flex text-xs font-medium" :style="{ width: 'var(--total-w)' }">
|
||||
<div class="shrink-0 w-12 px-2 py-1.5 border-r border-border text-center text-muted-foreground select-none">#</div>
|
||||
<div class="shrink-0 px-2 py-1.5 border-r border-border text-center text-muted-foreground select-none" :style="{ width: 'var(--row-num-w)' }">#</div>
|
||||
<div v-if="editable" class="shrink-0 px-2 py-1.5 border-r border-border text-center text-muted-foreground select-none" :style="{ width: 'var(--row-action-w)' }">
|
||||
{{ t('grid.rowState') }}
|
||||
</div>
|
||||
<div
|
||||
v-for="(col, colIdx) in result.columns"
|
||||
:key="col"
|
||||
|
|
@ -667,13 +772,42 @@ function escapeAndHighlightKeywords(s: string): string {
|
|||
<div
|
||||
class="flex text-xs border-b border-border hover:bg-accent/50"
|
||||
:class="{
|
||||
'line-through opacity-30': item.isDeleted,
|
||||
'bg-green-500/10': item.isNew,
|
||||
'bg-destructive/5 opacity-70': item.isDeleted,
|
||||
'bg-primary/5': item.isNew,
|
||||
'bg-muted/30': !item.isNew && !item.isDeleted && index % 2 === 1,
|
||||
}"
|
||||
:style="{ height: '26px', width: 'var(--total-w)' }"
|
||||
>
|
||||
<div class="shrink-0 w-12 px-2 py-1 border-r border-border text-center text-muted-foreground select-none">{{ index + 1 }}</div>
|
||||
<div class="shrink-0 px-2 py-1 border-r border-border text-center text-muted-foreground select-none" :style="{ width: 'var(--row-num-w)' }">{{ index + 1 }}</div>
|
||||
<div
|
||||
v-if="editable"
|
||||
class="shrink-0 px-1.5 py-0.5 border-r border-border flex items-center justify-between gap-1"
|
||||
:style="{ width: 'var(--row-action-w)' }"
|
||||
>
|
||||
<Badge :variant="rowStatusVariant(item)" class="h-4 px-1.5 text-[10px]">
|
||||
{{ rowStatusLabel(item) }}
|
||||
</Badge>
|
||||
<Button
|
||||
v-if="editable && !item.isDeleted"
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
class="h-5 w-5 shrink-0 text-destructive"
|
||||
:title="t('grid.deleteRow')"
|
||||
@click.stop="deleteRow(item.id)"
|
||||
>
|
||||
<Trash2 class="w-3 h-3" />
|
||||
</Button>
|
||||
<Button
|
||||
v-else-if="editable && item.isDeleted"
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
class="h-5 w-5 shrink-0"
|
||||
:title="t('grid.restoreRow')"
|
||||
@click.stop="restoreRow(item.id)"
|
||||
>
|
||||
<Undo2 class="w-3 h-3" />
|
||||
</Button>
|
||||
</div>
|
||||
<div
|
||||
v-for="(cell, colIdx) in item.data"
|
||||
:key="colIdx"
|
||||
|
|
@ -683,15 +817,18 @@ function escapeAndHighlightKeywords(s: string): string {
|
|||
'text-muted-foreground italic': isNull(cell),
|
||||
'bg-yellow-500/10': item.isDirtyCol[colIdx],
|
||||
'tabular-nums': typeof cell === 'number',
|
||||
'cursor-text hover:bg-accent/50': editable && !item.isDeleted,
|
||||
'line-through': item.isDeleted,
|
||||
}"
|
||||
@dblclick="!item.isNew && !item.isDeleted && startEdit(item.id, colIdx)"
|
||||
@dblclick="editable && !item.isDeleted && startEdit(item.id, colIdx)"
|
||||
@contextmenu="onCellContext(item.id, colIdx)"
|
||||
>
|
||||
<template v-if="editingCell?.row === item.id && editingCell?.col === colIdx">
|
||||
<template v-if="editingCell?.rowId === item.id && editingCell?.col === colIdx">
|
||||
<input
|
||||
v-model="editValue"
|
||||
class="cell-edit-input absolute inset-0 bg-background border-2 border-primary px-2 py-0.5 text-xs outline-none z-10"
|
||||
@blur="commitEdit"
|
||||
@click.stop
|
||||
@keydown.stop="onEditKeydown"
|
||||
/>
|
||||
</template>
|
||||
|
|
@ -758,15 +895,15 @@ function escapeAndHighlightKeywords(s: string): string {
|
|||
<span>{{ result.execution_time_ms }}ms</span>
|
||||
|
||||
<template v-if="editable && tableMeta">
|
||||
<span v-if="hasPendingChanges" class="ml-2 text-foreground">
|
||||
{{ t('grid.pendingChanges', { count: pendingChangeCount }) }}
|
||||
</span>
|
||||
<Button v-if="hasPendingChanges" variant="default" size="sm" class="h-5 text-xs ml-2" @click="saveChanges">
|
||||
<Save class="w-3 h-3 mr-1" /> {{ t('grid.save') }}
|
||||
</Button>
|
||||
<Button v-if="hasPendingChanges" variant="ghost" size="sm" class="h-5 text-xs" @click="discardChanges">
|
||||
{{ t('grid.discard') }}
|
||||
</Button>
|
||||
<Button variant="ghost" size="sm" class="h-5 text-xs" @click="addRow">
|
||||
<Plus class="w-3 h-3 mr-1" /> {{ t('grid.addRow') }}
|
||||
</Button>
|
||||
<Button variant="ghost" size="sm" class="h-5 text-xs" @click="toggleDdl">
|
||||
<Code2 class="w-3 h-3 mr-1" /> DDL
|
||||
</Button>
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -70,6 +70,13 @@ export default {
|
|||
dismiss: "Dismiss",
|
||||
addRow: "Add Row",
|
||||
deleteRow: "Delete Row",
|
||||
restoreRow: "Restore Row",
|
||||
rowState: "State",
|
||||
statusClean: "Clean",
|
||||
statusNew: "New",
|
||||
statusEdited: "Edited",
|
||||
statusDeleted: "Deleted",
|
||||
pendingChanges: "{count} pending",
|
||||
rowsPerPageShort: " rows",
|
||||
},
|
||||
welcome: {
|
||||
|
|
@ -153,6 +160,19 @@ export default {
|
|||
members: "{count} members",
|
||||
noExpiry: "no expiry",
|
||||
},
|
||||
mongo: {
|
||||
documents: "{count} documents",
|
||||
addField: "Add Field",
|
||||
deleteField: "Delete Field",
|
||||
field: "Field",
|
||||
value: "Value",
|
||||
fieldPlaceholder: "Field name",
|
||||
emptyCollection: "Empty collection",
|
||||
selectDocument: "Select a document",
|
||||
readonlyId: "_id is read-only",
|
||||
invalidJsonValue: "This cell is not a valid JSON value",
|
||||
duplicateField: "Duplicate field name: {field}",
|
||||
},
|
||||
history: {
|
||||
title: "History",
|
||||
search: "Search history...",
|
||||
|
|
|
|||
|
|
@ -70,6 +70,13 @@ export default {
|
|||
dismiss: "关闭",
|
||||
addRow: "新增行",
|
||||
deleteRow: "删除行",
|
||||
restoreRow: "恢复行",
|
||||
rowState: "状态",
|
||||
statusClean: "未改",
|
||||
statusNew: "新增",
|
||||
statusEdited: "已改",
|
||||
statusDeleted: "删除",
|
||||
pendingChanges: "{count} 项待保存",
|
||||
rowsPerPageShort: " 行/页",
|
||||
},
|
||||
welcome: {
|
||||
|
|
@ -151,6 +158,19 @@ export default {
|
|||
members: "{count} 个成员",
|
||||
noExpiry: "永不过期",
|
||||
},
|
||||
mongo: {
|
||||
documents: "{count} 个文档",
|
||||
addField: "添加字段",
|
||||
deleteField: "删除字段",
|
||||
field: "字段",
|
||||
value: "值",
|
||||
fieldPlaceholder: "字段名",
|
||||
emptyCollection: "空集合",
|
||||
selectDocument: "选择一个文档",
|
||||
readonlyId: "_id 只读",
|
||||
invalidJsonValue: "当前单元格不是合法 JSON 值",
|
||||
duplicateField: "字段名重复:{field}",
|
||||
},
|
||||
history: {
|
||||
title: "查询历史",
|
||||
search: "搜索历史...",
|
||||
|
|
|
|||
|
|
@ -0,0 +1,11 @@
|
|||
export type EditNodeKind = "object" | "array" | "value";
|
||||
|
||||
export interface EditNode {
|
||||
key: string;
|
||||
keyName: string;
|
||||
kind: EditNodeKind;
|
||||
valueText: string;
|
||||
readonlyKey: boolean;
|
||||
readonlyValue: boolean;
|
||||
children: EditNode[];
|
||||
}
|
||||
Loading…
Reference in New Issue