feat(mongo): add GridFS manager for MongoDB
* feat(mongo): browse GridFS buckets and files Refs #2342 * feat(mongo): add GridFS manager for MongoDB * chore(i18n): autofill new translations --------- Co-authored-by: dbx-i18n-bot <github-actions[bot]@users.noreply.github.com> Co-authored-by: t8y2 <1156263951@qq.com>
This commit is contained in:
35 files changed
+1959
-39
No files matched your search
@@ -0,0 +1,271 @@
|
||||
<script setup lang="ts">
|
||||
import { computed, onMounted, ref } from "vue";
|
||||
import { useI18n } from "vue-i18n";
|
||||
import { Download, RefreshCcw, Trash2, Upload } from "@lucide/vue";
|
||||
import DangerConfirmDialog from "@/components/editor/DangerConfirmDialog.vue";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { useToast } from "@/composables/useToast";
|
||||
import { downloadBinaryCellPayload, type BinaryCellDownloadPayload } from "@/lib/binaryCellDownload";
|
||||
import { isTauriRuntime } from "@/lib/tauriRuntime";
|
||||
import * as api from "@/lib/api";
|
||||
import { useConnectionStore } from "@/stores/connectionStore";
|
||||
|
||||
const props = defineProps<{
|
||||
connectionId: string;
|
||||
database: string;
|
||||
bucket: string;
|
||||
}>();
|
||||
|
||||
const { t } = useI18n();
|
||||
const { toast } = useToast();
|
||||
const connectionStore = useConnectionStore();
|
||||
|
||||
const loading = ref(false);
|
||||
const uploading = ref(false);
|
||||
const deleting = ref(false);
|
||||
const error = ref("");
|
||||
const files = ref<Awaited<ReturnType<typeof api.documentListGridFsFiles>>>([]);
|
||||
const selectedFileId = ref("");
|
||||
const showDeleteConfirm = ref(false);
|
||||
|
||||
const totalBytes = computed(() => files.value.reduce((sum, file) => sum + (file.length || 0), 0));
|
||||
const selectedFile = computed(() => files.value.find((file) => file.id === selectedFileId.value) || null);
|
||||
const selectedMetadata = computed(() => (selectedFile.value?.metadata ? JSON.stringify(selectedFile.value.metadata, null, 2) : ""));
|
||||
const isReadonly = computed(() => connectionStore.getConfig(props.connectionId)?.read_only ?? false);
|
||||
|
||||
function formatBytes(bytes: number): string {
|
||||
if (bytes < 1024) return `${bytes} B`;
|
||||
if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(bytes < 10 * 1024 ? 1 : 0)} KB`;
|
||||
if (bytes < 1024 * 1024 * 1024) return `${(bytes / (1024 * 1024)).toFixed(bytes < 10 * 1024 * 1024 ? 1 : 0)} MB`;
|
||||
return `${(bytes / (1024 * 1024 * 1024)).toFixed(1)} GB`;
|
||||
}
|
||||
|
||||
function displayName(file: (typeof files.value)[number]): string {
|
||||
return file.filename || file.id;
|
||||
}
|
||||
|
||||
async function loadFiles() {
|
||||
loading.value = true;
|
||||
error.value = "";
|
||||
try {
|
||||
const nextFiles = await api.documentListGridFsFiles(props.connectionId, props.database, props.bucket);
|
||||
files.value = nextFiles;
|
||||
if (selectedFileId.value && !nextFiles.some((file) => file.id === selectedFileId.value)) {
|
||||
selectedFileId.value = "";
|
||||
}
|
||||
} catch (e: any) {
|
||||
error.value = e?.message || String(e);
|
||||
} finally {
|
||||
loading.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
async function downloadFile(file: (typeof files.value)[number]) {
|
||||
try {
|
||||
const bytes = await api.documentDownloadGridFsFile(props.connectionId, props.database, props.bucket, file.id);
|
||||
const payload: BinaryCellDownloadPayload = {
|
||||
data: bytes,
|
||||
mimeType: file.contentType || "application/octet-stream",
|
||||
extension: file.filename?.includes(".") ? file.filename.split(".").pop() || "bin" : "bin",
|
||||
};
|
||||
const result = await downloadBinaryCellPayload(payload, displayName(file));
|
||||
if (result.kind === "saved") {
|
||||
toast(t("grid.exported"), 2500);
|
||||
}
|
||||
} catch (e: any) {
|
||||
toast(e?.message || String(e), 5000);
|
||||
}
|
||||
}
|
||||
|
||||
async function pickUploadFile(): Promise<{ name: string; data: Uint8Array; contentType?: string } | null> {
|
||||
if (isTauriRuntime()) {
|
||||
const [{ open }, fs] = await Promise.all([import("@tauri-apps/plugin-dialog"), import("@tauri-apps/plugin-fs")]);
|
||||
const selected = await open({ multiple: false });
|
||||
const path = Array.isArray(selected) ? selected[0] : selected;
|
||||
if (!path || typeof path !== "string") return null;
|
||||
const data = await fs.readFile(path);
|
||||
const name = path.split(/[\\/]/).pop() || "file";
|
||||
return { name, data };
|
||||
}
|
||||
|
||||
return new Promise((resolve, reject) => {
|
||||
const input = document.createElement("input");
|
||||
input.type = "file";
|
||||
input.onchange = async () => {
|
||||
try {
|
||||
const file = input.files?.[0];
|
||||
if (!file) {
|
||||
resolve(null);
|
||||
return;
|
||||
}
|
||||
const data = new Uint8Array(await file.arrayBuffer());
|
||||
resolve({ name: file.name, data, contentType: file.type || undefined });
|
||||
} catch (error) {
|
||||
reject(error);
|
||||
}
|
||||
};
|
||||
input.click();
|
||||
});
|
||||
}
|
||||
|
||||
async function uploadFile() {
|
||||
if (uploading.value) return;
|
||||
try {
|
||||
const selected = await pickUploadFile();
|
||||
if (!selected) return;
|
||||
uploading.value = true;
|
||||
const fileId = await api.documentUploadGridFsFile(props.connectionId, props.database, props.bucket, selected.name, selected.data, selected.contentType);
|
||||
await loadFiles();
|
||||
selectedFileId.value = fileId;
|
||||
toast(t("gridfsBrowser.fileUploaded", { fileName: selected.name }), 2500);
|
||||
} catch (e: any) {
|
||||
toast(e?.message || String(e), 5000);
|
||||
} finally {
|
||||
uploading.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
async function deleteSelectedFile() {
|
||||
const file = selectedFile.value;
|
||||
if (!file || deleting.value) return;
|
||||
deleting.value = true;
|
||||
try {
|
||||
await api.documentDeleteGridFsFile(props.connectionId, props.database, props.bucket, file.id);
|
||||
showDeleteConfirm.value = false;
|
||||
selectedFileId.value = "";
|
||||
await loadFiles();
|
||||
toast(t("gridfsBrowser.fileDeleted", { fileName: displayName(file) }), 2500);
|
||||
} catch (e: any) {
|
||||
toast(e?.message || String(e), 5000);
|
||||
} finally {
|
||||
deleting.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
void loadFiles();
|
||||
});
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="flex h-full min-h-0 flex-col">
|
||||
<div class="border-b border-border px-4 py-3">
|
||||
<div class="flex flex-wrap items-center justify-between gap-3">
|
||||
<div class="min-w-0">
|
||||
<div class="truncate text-sm font-semibold">{{ database }}.{{ bucket }}</div>
|
||||
<div class="text-xs text-muted-foreground">{{ files.length }} {{ t("gridfsBrowser.fileCount") }} / {{ formatBytes(totalBytes) }}</div>
|
||||
</div>
|
||||
<div class="flex flex-wrap items-center gap-2">
|
||||
<Button size="sm" class="h-8 gap-1.5" :disabled="isReadonly || uploading" @click="uploadFile">
|
||||
<Upload class="h-3.5 w-3.5" />
|
||||
{{ t("gridfsBrowser.uploadFile") }}
|
||||
</Button>
|
||||
<Button variant="outline" size="sm" class="h-8 gap-1.5" :disabled="!selectedFile" @click="selectedFile && downloadFile(selectedFile)">
|
||||
<Download class="h-3.5 w-3.5" />
|
||||
{{ t("gridfsBrowser.downloadFile") }}
|
||||
</Button>
|
||||
<Button variant="destructive" size="sm" class="h-8 gap-1.5" :disabled="isReadonly || !selectedFile || deleting" @click="showDeleteConfirm = true">
|
||||
<Trash2 class="h-3.5 w-3.5" />
|
||||
{{ t("gridfsBrowser.deleteFile") }}
|
||||
</Button>
|
||||
<Button variant="outline" size="sm" class="h-8 gap-1.5" :disabled="loading" @click="loadFiles">
|
||||
<RefreshCcw class="h-3.5 w-3.5" :class="{ 'animate-spin': loading }" />
|
||||
{{ t("grid.refresh") }}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div v-if="error" class="px-4 py-3 text-sm text-destructive">
|
||||
{{ error }}
|
||||
</div>
|
||||
|
||||
<div v-else-if="loading && files.length === 0" class="flex flex-1 items-center justify-center text-sm text-muted-foreground">
|
||||
{{ t("executionSummary.executing") }}
|
||||
</div>
|
||||
|
||||
<div v-else-if="files.length === 0" class="flex flex-1 items-center justify-center px-6 text-sm text-muted-foreground">
|
||||
{{ t("gridfsBrowser.emptyFiles") }}
|
||||
</div>
|
||||
|
||||
<div v-else class="min-h-0 flex-1 flex-col xl:flex-row xl:divide-x xl:divide-border">
|
||||
<div class="min-h-0 flex-1 overflow-auto">
|
||||
<table class="min-w-full border-collapse text-sm">
|
||||
<thead class="sticky top-0 z-10 bg-background">
|
||||
<tr class="border-b border-border text-left text-xs text-muted-foreground">
|
||||
<th class="px-4 py-2 font-medium">{{ t("gridfsBrowser.name") }}</th>
|
||||
<th class="px-4 py-2 font-medium">ID</th>
|
||||
<th class="px-4 py-2 font-medium">{{ t("gridfsBrowser.totalSize") }}</th>
|
||||
<th class="px-4 py-2 font-medium">{{ t("gridfsBrowser.chunkSize") }}</th>
|
||||
<th class="px-4 py-2 font-medium">{{ t("gridfsBrowser.uploadDate") }}</th>
|
||||
<th class="px-4 py-2 font-medium">{{ t("gridfsBrowser.contentType") }}</th>
|
||||
<th class="px-4 py-2 font-medium">MD5</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr v-for="file in files" :key="file.id" class="cursor-pointer border-b border-border/60 transition-colors hover:bg-muted/40" :class="{ 'bg-accent/45': selectedFileId === file.id }" @click="selectedFileId = file.id" @dblclick="downloadFile(file)">
|
||||
<td class="px-4 py-2 align-top">
|
||||
<div class="font-medium">{{ displayName(file) }}</div>
|
||||
<div v-if="file.aliases?.length" class="mt-1 text-xs text-muted-foreground">{{ file.aliases.join(", ") }}</div>
|
||||
</td>
|
||||
<td class="px-4 py-2 align-top text-xs text-muted-foreground">{{ file.id }}</td>
|
||||
<td class="px-4 py-2 align-top text-muted-foreground">{{ formatBytes(file.length) }}</td>
|
||||
<td class="px-4 py-2 align-top text-muted-foreground">{{ formatBytes(file.chunkSize || 0) }}</td>
|
||||
<td class="px-4 py-2 align-top text-muted-foreground">{{ file.uploadDate || "-" }}</td>
|
||||
<td class="px-4 py-2 align-top text-muted-foreground">{{ file.contentType || "-" }}</td>
|
||||
<td class="px-4 py-2 align-top text-xs text-muted-foreground">{{ file.md5 || "-" }}</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
<aside class="border-t border-border px-4 py-4 xl:w-80 xl:shrink-0 xl:border-t-0">
|
||||
<template v-if="selectedFile">
|
||||
<div class="text-xs font-semibold uppercase tracking-[0.18em] text-muted-foreground">{{ t("tabs.gridfs") }}</div>
|
||||
<div class="mt-2 break-all text-lg font-semibold">{{ displayName(selectedFile) }}</div>
|
||||
<div class="mt-1 break-all text-xs text-muted-foreground">{{ selectedFile.id }}</div>
|
||||
|
||||
<div class="mt-5 space-y-4 text-sm">
|
||||
<div>
|
||||
<div class="text-xs text-muted-foreground">{{ t("gridfsBrowser.totalSize") }}</div>
|
||||
<div class="mt-1 font-medium">{{ formatBytes(selectedFile.length) }}</div>
|
||||
</div>
|
||||
<div>
|
||||
<div class="text-xs text-muted-foreground">{{ t("gridfsBrowser.chunkSize") }}</div>
|
||||
<div class="mt-1 font-medium">{{ formatBytes(selectedFile.chunkSize || 0) }}</div>
|
||||
</div>
|
||||
<div>
|
||||
<div class="text-xs text-muted-foreground">{{ t("gridfsBrowser.uploadDate") }}</div>
|
||||
<div class="mt-1 font-medium">{{ selectedFile.uploadDate || "-" }}</div>
|
||||
</div>
|
||||
<div>
|
||||
<div class="text-xs text-muted-foreground">{{ t("gridfsBrowser.contentType") }}</div>
|
||||
<div class="mt-1 break-all font-medium">{{ selectedFile.contentType || "-" }}</div>
|
||||
</div>
|
||||
<div v-if="selectedFile.md5">
|
||||
<div class="text-xs text-muted-foreground">MD5</div>
|
||||
<div class="mt-1 break-all font-medium">{{ selectedFile.md5 }}</div>
|
||||
</div>
|
||||
<div v-if="selectedMetadata">
|
||||
<div class="text-xs text-muted-foreground">{{ t("gridfsBrowser.metadata") }}</div>
|
||||
<pre class="mt-1 max-h-44 overflow-auto rounded bg-muted px-3 py-2 text-xs whitespace-pre-wrap">{{ selectedMetadata }}</pre>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
<div v-else class="text-sm text-muted-foreground">
|
||||
{{ t("gridfsBrowser.selectFile") }}
|
||||
</div>
|
||||
</aside>
|
||||
</div>
|
||||
|
||||
<DangerConfirmDialog
|
||||
v-model:open="showDeleteConfirm"
|
||||
:loading="deleting"
|
||||
:title="t('gridfsBrowser.deleteFileTitle')"
|
||||
:message="t('gridfsBrowser.deleteFileMessage')"
|
||||
:details="selectedFile ? displayName(selectedFile) : ''"
|
||||
:confirm-label="t('gridfsBrowser.deleteFile')"
|
||||
@confirm="deleteSelectedFile"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
@@ -0,0 +1,211 @@
|
||||
<script setup lang="ts">
|
||||
import { computed, onMounted, ref } from "vue";
|
||||
import { useI18n } from "vue-i18n";
|
||||
import { FolderOpen, Plus, RefreshCcw, Trash2 } from "@lucide/vue";
|
||||
import DangerConfirmDialog from "@/components/editor/DangerConfirmDialog.vue";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Dialog, DialogContent, DialogFooter, DialogHeader, DialogTitle } from "@/components/ui/dialog";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { useToast } from "@/composables/useToast";
|
||||
import * as api from "@/lib/api";
|
||||
import { useConnectionStore } from "@/stores/connectionStore";
|
||||
import { useQueryStore } from "@/stores/queryStore";
|
||||
|
||||
const props = defineProps<{
|
||||
connectionId: string;
|
||||
database: string;
|
||||
}>();
|
||||
|
||||
const { t } = useI18n();
|
||||
const { toast } = useToast();
|
||||
const queryStore = useQueryStore();
|
||||
const connectionStore = useConnectionStore();
|
||||
|
||||
const loading = ref(false);
|
||||
const creating = ref(false);
|
||||
const deleting = ref(false);
|
||||
const error = ref("");
|
||||
const buckets = ref<Awaited<ReturnType<typeof api.documentListGridFsBuckets>>>([]);
|
||||
const selectedBucketName = ref("");
|
||||
const showCreateDialog = ref(false);
|
||||
const showDeleteConfirm = ref(false);
|
||||
const newBucketName = ref("");
|
||||
|
||||
const selectedBucket = computed(() => buckets.value.find((bucket) => bucket.name === selectedBucketName.value) || null);
|
||||
const totalFiles = computed(() => buckets.value.reduce((sum, bucket) => sum + bucket.fileCount, 0));
|
||||
const totalBytes = computed(() => buckets.value.reduce((sum, bucket) => sum + bucket.totalBytes, 0));
|
||||
const isReadonly = computed(() => connectionStore.getConfig(props.connectionId)?.read_only ?? false);
|
||||
|
||||
function formatBytes(bytes: number): string {
|
||||
if (bytes < 1024) return `${bytes} B`;
|
||||
if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(bytes < 10 * 1024 ? 1 : 0)} KB`;
|
||||
if (bytes < 1024 * 1024 * 1024) return `${(bytes / (1024 * 1024)).toFixed(bytes < 10 * 1024 * 1024 ? 1 : 0)} MB`;
|
||||
return `${(bytes / (1024 * 1024 * 1024)).toFixed(1)} GB`;
|
||||
}
|
||||
|
||||
async function loadBuckets() {
|
||||
loading.value = true;
|
||||
error.value = "";
|
||||
try {
|
||||
const nextBuckets = await api.documentListGridFsBuckets(props.connectionId, props.database);
|
||||
buckets.value = nextBuckets;
|
||||
if (selectedBucketName.value && !nextBuckets.some((bucket) => bucket.name === selectedBucketName.value)) {
|
||||
selectedBucketName.value = "";
|
||||
}
|
||||
} catch (e: any) {
|
||||
error.value = e?.message || String(e);
|
||||
} finally {
|
||||
loading.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
function openBucket(bucketName = selectedBucketName.value) {
|
||||
if (!bucketName) return;
|
||||
queryStore.openMongoBucket(props.connectionId, props.database, bucketName);
|
||||
}
|
||||
|
||||
async function createBucket() {
|
||||
const bucketName = newBucketName.value.trim();
|
||||
if (!bucketName || creating.value) return;
|
||||
creating.value = true;
|
||||
try {
|
||||
await api.documentCreateGridFsBucket(props.connectionId, props.database, bucketName);
|
||||
showCreateDialog.value = false;
|
||||
newBucketName.value = "";
|
||||
selectedBucketName.value = bucketName;
|
||||
await loadBuckets();
|
||||
toast(t("gridfsBrowser.bucketCreated", { bucket: bucketName }), 2500);
|
||||
} catch (e: any) {
|
||||
toast(e?.message || String(e), 5000);
|
||||
} finally {
|
||||
creating.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
async function deleteBucket() {
|
||||
const bucketName = selectedBucketName.value;
|
||||
if (!bucketName || deleting.value) return;
|
||||
deleting.value = true;
|
||||
try {
|
||||
await api.documentDeleteGridFsBucket(props.connectionId, props.database, bucketName);
|
||||
showDeleteConfirm.value = false;
|
||||
selectedBucketName.value = "";
|
||||
await loadBuckets();
|
||||
toast(t("gridfsBrowser.bucketDeleted", { bucket: bucketName }), 2500);
|
||||
} catch (e: any) {
|
||||
toast(e?.message || String(e), 5000);
|
||||
} finally {
|
||||
deleting.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
void loadBuckets();
|
||||
});
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="flex h-full min-h-0 flex-col">
|
||||
<div class="border-b border-border px-4 py-3">
|
||||
<div class="flex flex-wrap items-center justify-between gap-3">
|
||||
<div class="min-w-0">
|
||||
<div class="truncate text-sm font-semibold">{{ database }} / {{ t("tabs.gridfs") }}</div>
|
||||
<div class="text-xs text-muted-foreground">{{ buckets.length }} {{ t("gridfsBrowser.bucketCount") }} / {{ totalFiles }} {{ t("gridfsBrowser.fileCount") }} / {{ formatBytes(totalBytes) }}</div>
|
||||
</div>
|
||||
<div class="flex flex-wrap items-center gap-2">
|
||||
<Button size="sm" class="h-8 gap-1.5" :disabled="isReadonly" @click="showCreateDialog = true">
|
||||
<Plus class="h-3.5 w-3.5" />
|
||||
{{ t("gridfsBrowser.createBucket") }}
|
||||
</Button>
|
||||
<Button variant="outline" size="sm" class="h-8 gap-1.5" :disabled="!selectedBucket" @click="openBucket()">
|
||||
<FolderOpen class="h-3.5 w-3.5" />
|
||||
{{ t("gridfsBrowser.openBucket") }}
|
||||
</Button>
|
||||
<Button variant="destructive" size="sm" class="h-8 gap-1.5" :disabled="isReadonly || !selectedBucket || deleting" @click="showDeleteConfirm = true">
|
||||
<Trash2 class="h-3.5 w-3.5" />
|
||||
{{ t("gridfsBrowser.deleteBucket") }}
|
||||
</Button>
|
||||
<Button variant="outline" size="sm" class="h-8 gap-1.5" :disabled="loading" @click="loadBuckets">
|
||||
<RefreshCcw class="h-3.5 w-3.5" :class="{ 'animate-spin': loading }" />
|
||||
{{ t("grid.refresh") }}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div v-if="error" class="px-4 py-3 text-sm text-destructive">
|
||||
{{ error }}
|
||||
</div>
|
||||
|
||||
<div v-else-if="loading && buckets.length === 0" class="flex flex-1 items-center justify-center text-sm text-muted-foreground">
|
||||
{{ t("executionSummary.executing") }}
|
||||
</div>
|
||||
|
||||
<div v-else-if="buckets.length === 0" class="flex flex-1 items-center justify-center px-6 text-sm text-muted-foreground">
|
||||
{{ t("gridfsBrowser.emptyBuckets") }}
|
||||
</div>
|
||||
|
||||
<div v-else class="min-h-0 flex-1 flex-col xl:flex-row xl:divide-x xl:divide-border">
|
||||
<div class="min-h-0 flex-1 overflow-auto">
|
||||
<table class="min-w-full border-collapse text-sm">
|
||||
<thead class="sticky top-0 z-10 bg-background">
|
||||
<tr class="border-b border-border text-left text-xs text-muted-foreground">
|
||||
<th class="px-4 py-2 font-medium">{{ t("gridfsBrowser.name") }}</th>
|
||||
<th class="px-4 py-2 font-medium">{{ t("gridfsBrowser.fileCount") }}</th>
|
||||
<th class="px-4 py-2 font-medium">{{ t("gridfsBrowser.totalSize") }}</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr v-for="bucket in buckets" :key="bucket.name" class="cursor-pointer border-b border-border/60 transition-colors hover:bg-muted/40" :class="{ 'bg-accent/45': selectedBucketName === bucket.name }" @click="selectedBucketName = bucket.name" @dblclick="openBucket(bucket.name)">
|
||||
<td class="px-4 py-2 font-medium">{{ bucket.name }}</td>
|
||||
<td class="px-4 py-2 text-muted-foreground">{{ bucket.fileCount }}</td>
|
||||
<td class="px-4 py-2 text-muted-foreground">{{ formatBytes(bucket.totalBytes) }}</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
<aside class="border-t border-border px-4 py-4 xl:w-72 xl:shrink-0 xl:border-t-0">
|
||||
<template v-if="selectedBucket">
|
||||
<div class="text-xs font-semibold uppercase tracking-[0.18em] text-muted-foreground">{{ t("tabs.gridfs") }}</div>
|
||||
<div class="mt-2 break-all text-lg font-semibold">{{ selectedBucket.name }}</div>
|
||||
|
||||
<div class="mt-5 space-y-4 text-sm">
|
||||
<div>
|
||||
<div class="text-xs text-muted-foreground">{{ t("gridfsBrowser.fileCount") }}</div>
|
||||
<div class="mt-1 font-medium">{{ selectedBucket.fileCount }}</div>
|
||||
</div>
|
||||
<div>
|
||||
<div class="text-xs text-muted-foreground">{{ t("gridfsBrowser.totalSize") }}</div>
|
||||
<div class="mt-1 font-medium">{{ formatBytes(selectedBucket.totalBytes) }}</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
<div v-else class="text-sm text-muted-foreground">
|
||||
{{ t("gridfsBrowser.selectBucket") }}
|
||||
</div>
|
||||
</aside>
|
||||
</div>
|
||||
|
||||
<Dialog v-model:open="showCreateDialog">
|
||||
<DialogContent class="sm:max-w-[420px]">
|
||||
<DialogHeader>
|
||||
<DialogTitle>{{ t("gridfsBrowser.createTitle") }}</DialogTitle>
|
||||
</DialogHeader>
|
||||
<div class="space-y-2 py-2">
|
||||
<label class="text-sm font-medium">{{ t("gridfsBrowser.bucketName") }}</label>
|
||||
<Input v-model="newBucketName" :placeholder="t('gridfsBrowser.bucketNamePlaceholder')" @keydown.enter="createBucket" />
|
||||
</div>
|
||||
<DialogFooter>
|
||||
<Button variant="outline" :disabled="creating" @click="showCreateDialog = false">{{ t("common.cancel") }}</Button>
|
||||
<Button :disabled="creating || !newBucketName.trim()" class="gap-1.5" @click="createBucket">
|
||||
<RefreshCcw v-if="creating" class="h-3.5 w-3.5 animate-spin" />
|
||||
{{ t("gridfsBrowser.createBucket") }}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
|
||||
<DangerConfirmDialog v-model:open="showDeleteConfirm" :loading="deleting" :title="t('gridfsBrowser.deleteTitle')" :message="t('gridfsBrowser.deleteMessage')" :details="selectedBucketName" :confirm-label="t('gridfsBrowser.deleteBucket')" @confirm="deleteBucket" />
|
||||
</div>
|
||||
</template>
|
||||
@@ -39,6 +39,8 @@ const RedisDashboard = defineAsyncComponent(() => import("@/components/redis/Red
|
||||
const EtcdKeyBrowser = defineAsyncComponent(() => import("@/components/etcd/EtcdKeyBrowser.vue"));
|
||||
const ZooKeeperKeyBrowser = defineAsyncComponent(() => import("@/components/zookeeper/ZooKeeperKeyBrowser.vue"));
|
||||
const DocumentBrowser = defineAsyncComponent(() => import("@/components/document/DocumentBrowser.vue"));
|
||||
const MongoGridFsBrowser = defineAsyncComponent(() => import("@/components/document/MongoGridFsBrowser.vue"));
|
||||
const MongoBucketBrowser = defineAsyncComponent(() => import("@/components/document/MongoBucketBrowser.vue"));
|
||||
const VectorBrowser = defineAsyncComponent(() => import("@/components/vector/VectorBrowser.vue"));
|
||||
const MqAdminConsole = defineAsyncComponent(() => import("@/components/mq/MqAdminConsole.vue"));
|
||||
const NacosAdminConsole = defineAsyncComponent(() => import("@/components/nacos/NacosAdminConsole.vue"));
|
||||
@@ -1288,6 +1290,18 @@ defineExpose({ focusSearch, refreshData, handleModRTarget, requestQueryEditorExe
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<template v-else-if="activeTab.mode === 'mongo-gridfs'">
|
||||
<div class="flex-1 min-h-0">
|
||||
<MongoGridFsBrowser :key="activeTab.id" :connection-id="activeTab.connectionId" :database="activeTab.database" />
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<template v-else-if="activeTab.mode === 'mongo-bucket'">
|
||||
<div class="flex-1 min-h-0">
|
||||
<MongoBucketBrowser :key="activeTab.id" :connection-id="activeTab.connectionId" :database="activeTab.database" :bucket="activeTab.mongoBucket?.bucketName || activeTab.sql" />
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<!-- Vector mode: Qdrant and Milvus collections -->
|
||||
<template v-else-if="activeTab.mode === 'vector'">
|
||||
<div class="flex-1 min-h-0">
|
||||
|
||||
@@ -114,7 +114,7 @@ const SEARCH_SCOPE_TO_NODE_TYPES: Record<SearchScope, TreeNodeType[]> = {
|
||||
connection: ["connection"],
|
||||
database: ["database", "redis-db", "mq-tenant", "nacos-namespace", "mongo-db"],
|
||||
schema: ["schema"],
|
||||
table: ["table", "mongo-collection", "vector-collection", "elasticsearch-index"],
|
||||
table: ["table", "mongo-collection", "mongo-bucket", "vector-collection", "elasticsearch-index"],
|
||||
view: ["view"],
|
||||
};
|
||||
|
||||
|
||||
@@ -55,6 +55,7 @@ import {
|
||||
SquarePen,
|
||||
ListX,
|
||||
Info,
|
||||
Archive,
|
||||
} from "@lucide/vue";
|
||||
import CustomContextMenu, { type ContextMenuItem } from "@/components/ui/CustomContextMenu.vue";
|
||||
import { useConnectionStore } from "@/stores/connectionStore";
|
||||
@@ -76,7 +77,7 @@ import { clearActiveTableReferencePayload, createTableReferencePayload, createTa
|
||||
import { editableRowIdentifierColumns, usesSyntheticRowIdKey } from "@/lib/tableEditing";
|
||||
import { tableOpenPageLimit } from "@/lib/tableOpenPageLimit";
|
||||
import { supportsDatabaseCreation, supportsDatabaseSearch, supportsFieldLineage, supportsObjectBrowserTreeNode, supportsSchemaDiagram, supportsSqlFileExecution, supportsTableImport, supportsTableTruncate, supportsTableStructureEditing, usesTreeSchemaMode } from "@/lib/databaseCapabilities";
|
||||
import { copyNameForTreeNode, objectSourceKindForTreeNode, shouldRunTreeNodeRowAction, sidebarSelectionCopyAction, treeNodeRowAction, treeNodeRowDoubleClickAction } from "@/lib/treeNodeClick";
|
||||
import { copyNameForTreeNode, isDocumentBrowserTreeNode, objectSourceKindForTreeNode, shouldRunTreeNodeRowAction, sidebarSelectionCopyAction, treeNodeRowAction, treeNodeRowDoubleClickAction } from "@/lib/treeNodeClick";
|
||||
import { formatSqlInsert } from "@/lib/exportFormats";
|
||||
import { joinExportedDdls } from "@/lib/ddlExport";
|
||||
import { fetchTableDataForExport } from "@/lib/tableDataExport";
|
||||
@@ -315,6 +316,11 @@ function getIconInfo(node: TreeNode): { icon: any; colorClass: string } | null {
|
||||
return { icon: Database, colorClass: "text-blue-500" };
|
||||
case "mongo-db":
|
||||
return { icon: Database, colorClass: "text-yellow-500" };
|
||||
case "mongo-gridfs":
|
||||
case "mongo-buckets":
|
||||
return { icon: Archive, colorClass: "text-amber-500" };
|
||||
case "mongo-bucket":
|
||||
return { icon: Archive, colorClass: "text-amber-400" };
|
||||
case "mongo-collection":
|
||||
return { icon: Table, colorClass: "text-green-400" };
|
||||
case "vector-collection":
|
||||
@@ -648,12 +654,16 @@ function runRowClickAction(clickDetail: number) {
|
||||
void openObjectBrowser();
|
||||
return;
|
||||
}
|
||||
if (node.type === "mongo-gridfs") {
|
||||
openMongoTreeData(node);
|
||||
return;
|
||||
}
|
||||
const action = treeNodeRowAction(node.type, canExpand.value, settingsStore.editorSettings.sidebarActivation);
|
||||
if (!shouldRunTreeNodeRowAction(action, clickDetail)) return;
|
||||
if (action === "open-data") {
|
||||
openData();
|
||||
} else if (node.type === "mongo-collection") {
|
||||
openMongoCollectionData(node);
|
||||
} else if (isDocumentBrowserTreeNode(node.type)) {
|
||||
openMongoTreeData(node);
|
||||
} else if (node.type === "procedure" || node.type === "function" || node.type === "sequence" || node.type === "package" || node.type === "package-body") {
|
||||
void viewObjectSource();
|
||||
} else if (action === "toggle") {
|
||||
@@ -928,16 +938,25 @@ function onDoubleClick() {
|
||||
void viewObjectSource();
|
||||
} else if (action === "open-saved-sql") {
|
||||
openSavedSqlFile();
|
||||
} else if (action === "toggle" && props.node.type === "mongo-collection") {
|
||||
openMongoCollectionData(props.node);
|
||||
} else if (action === "toggle" && (props.node.type === "mongo-gridfs" || isDocumentBrowserTreeNode(props.node.type))) {
|
||||
openMongoTreeData(props.node);
|
||||
} else if (action === "toggle") {
|
||||
toggle();
|
||||
}
|
||||
}
|
||||
|
||||
function openMongoCollectionData(node: TreeNode) {
|
||||
if (node.type !== "mongo-collection" || !node.connectionId || !node.database) return;
|
||||
function openMongoTreeData(node: TreeNode) {
|
||||
if (!node.connectionId || !node.database) return;
|
||||
if (node.type === "mongo-gridfs") {
|
||||
queryStore.openMongoGridFs(node.connectionId, node.database);
|
||||
return;
|
||||
}
|
||||
const tabTitle = `${node.database}.${node.label}`;
|
||||
if (node.type === "mongo-bucket") {
|
||||
queryStore.openMongoBucket(node.connectionId, node.database, node.label);
|
||||
return;
|
||||
}
|
||||
if (node.type !== "mongo-collection") return;
|
||||
const tab = queryStore.createTab(node.connectionId, node.database, tabTitle, "mongo");
|
||||
queryStore.updateSql(tab, node.label);
|
||||
}
|
||||
|
||||
Reference in new issue
Block a user