feat(transfer): persist reusable transfer tasks

This commit is contained in:
lizzzzz777 authored and GitHub committed 2026-08-14 18:25:05 +08:00
1 parent 1a10db4c18
commit 05361eb40d
26 files changed
+2628 -241

No files matched your search

File diff suppressed because it is too large. Load diff
@@ -0,0 +1,752 @@
<script setup lang="ts">
import { computed, nextTick, onBeforeUnmount, reactive, ref } from "vue";
import { useI18n } from "vue-i18n";
import { ArrowRightLeft, ChevronRight, Copy, ExternalLink, FolderClosed, FolderOpen, FolderPlus, Pencil, Plus, Search, Trash2, X } from "@lucide/vue";
import { Button } from "@/components/ui/button";
import { Dialog, DialogContent, DialogDescription, DialogFooter, DialogHeader, DialogTitle } from "@/components/ui/dialog";
import CustomContextMenu, { type ContextMenuItem as CtxMenuItem } from "@/components/ui/CustomContextMenu.vue";
import LightTooltip from "@/components/ui/LightTooltip.vue";
import { useToast } from "@/composables/useToast";
import { useTransferTaskStore, TransferTaskNameConflictError } from "@/stores/transferTaskStore";
import { focusSidebarRenameInput } from "@/lib/sidebar/sidebarRenameFocus";
import type { TransferTask, TransferTaskFolder } from "@/types/database";
const { t } = useI18n();
const { toast } = useToast();
const taskStore = useTransferTaskStore();
const props = defineProps<{
selectedTaskId: string | null;
}>();
const emit = defineEmits<{
/** Request to load a task's config into the form (the dialog may veto when dirty). */
select: [task: TransferTask];
/** Request to clear the form and start a new unsaved configuration. */
newBlank: [];
"update:selectedTaskId": [id: string | null];
}>();
const UNFILED_DROP_TARGET_ID = "__transfer-task-unfiled__";
const DRAG_THRESHOLD = 5;
type DragItemType = "folder" | "task" | "unfiled";
type DropPosition = "before" | "after" | "inside";
const searchText = ref("");
const searchQuery = computed(() => searchText.value.trim().toLowerCase());
const collapsedFolders = ref<Set<string>>(new Set());
function isFolderExpanded(folderId: string) {
return !collapsedFolders.value.has(folderId);
}
function toggleFolder(folderId: string) {
if (suppressNextRowClick.value) return;
const next = new Set(collapsedFolders.value);
if (next.has(folderId)) next.delete(folderId);
else next.add(folderId);
collapsedFolders.value = next;
}
function taskMatchesQuery(task: TransferTask) {
const q = searchQuery.value;
if (!q) return true;
return [task.name, task.config.sourceDatabase, task.config.targetDatabase].filter(Boolean).some((value) => String(value).toLowerCase().includes(q));
}
function folderMatchesQuery(folder: TransferTaskFolder) {
const q = searchQuery.value;
if (!q) return true;
if (folder.name.toLowerCase().includes(q)) return true;
return taskStore.listTasks(folder.id).some((task) => taskMatchesQuery(task));
}
function childFolders(parentFolderId?: string) {
return taskStore.allFolders.filter((folder) => (folder.parentFolderId || "") === (parentFolderId || ""));
}
function descendantFolders(parentFolderId: string): TransferTaskFolder[] {
const direct = childFolders(parentFolderId);
return direct.flatMap((folder) => [folder, ...descendantFolders(folder.id)]);
}
function folderBranchMatchesQuery(folder: TransferTaskFolder) {
if (folderMatchesQuery(folder)) return true;
return descendantFolders(folder.id).some((child) => folderMatchesQuery(child));
}
function tasksInFolder(folderId: string) {
const folder = taskStore.allFolders.find((item) => item.id === folderId);
const includeAllTasksForMatchedFolder = !!folder && !!searchQuery.value && folder.name.toLowerCase().includes(searchQuery.value);
return taskStore.listTasks(folderId).filter((task) => includeAllTasksForMatchedFolder || taskMatchesQuery(task));
}
function folderTaskCount(folderId: string) {
let count = taskStore.listTasks(folderId).length;
for (const child of descendantFolders(folderId)) {
count += taskStore.listTasks(child.id).length;
}
return count;
}
type TaskTreeRow = { type: "folder"; folder: TransferTaskFolder; depth: number } | { type: "task"; task: TransferTask; depth: number };
const visibleFolderRows = computed<TaskTreeRow[]>(() => {
const rows: TaskTreeRow[] = [];
const appendFolder = (folder: TransferTaskFolder, depth: number) => {
if (!folderBranchMatchesQuery(folder)) return;
rows.push({ type: "folder", folder, depth });
if (!isFolderExpanded(folder.id)) return;
for (const child of childFolders(folder.id)) {
appendFolder(child, depth + 1);
}
for (const task of tasksInFolder(folder.id)) {
rows.push({ type: "task", task, depth: depth + 1 });
}
};
for (const folder of childFolders()) {
appendFolder(folder, 0);
}
return rows;
});
const visibleRootTasks = computed(() => taskStore.listTasks(undefined).filter((task) => taskMatchesQuery(task)));
const hasAnyVisibleItem = computed(() => visibleFolderRows.value.length > 0 || visibleRootTasks.value.length > 0);
function folderPath(folder: TransferTaskFolder) {
const folderById = new Map(taskStore.allFolders.map((item) => [item.id, item]));
const parts: string[] = [];
const seen = new Set<string>();
let current: TransferTaskFolder | undefined = folder;
while (current && !seen.has(current.id)) {
seen.add(current.id);
parts.unshift(current.name);
current = current.parentFolderId ? folderById.get(current.parentFolderId) : undefined;
}
return parts.join(" / ");
}
// ---- inline rename (create-then-rename, mirroring SqlLibraryPanel) ----
const renamingTarget = ref<{ type: "folder" | "task"; id: string } | null>(null);
const renameValue = ref("");
const renameInputRef = ref<HTMLInputElement | null>(null);
function setRenameInputRef(el: unknown) {
renameInputRef.value = (el as HTMLInputElement) ?? null;
}
function isRenamingFolder(folderId: string) {
return renamingTarget.value?.type === "folder" && renamingTarget.value.id === folderId;
}
function isRenamingTask(taskId: string) {
return renamingTarget.value?.type === "task" && renamingTarget.value.id === taskId;
}
function startRenameFolder(folder: TransferTaskFolder) {
resetDragState();
markSuppressedClick();
renameInputRef.value = null;
renamingTarget.value = { type: "folder", id: folder.id };
renameValue.value = folder.name;
nextTick(() => {
focusSidebarRenameInput(() => renameInputRef.value ?? undefined);
});
}
function startRenameTask(task: TransferTask) {
resetDragState();
markSuppressedClick();
renameInputRef.value = null;
renamingTarget.value = { type: "task", id: task.id };
renameValue.value = task.name;
nextTick(() => {
focusSidebarRenameInput(() => renameInputRef.value ?? undefined);
});
}
defineExpose({ startRenameTask });
function renameErrorMessage(error: unknown) {
return error instanceof TransferTaskNameConflictError ? t("transfer.tasks.nameConflict", { name: error.entryName }) : (error as Error)?.message || String(error);
}
async function confirmRename() {
if (!renamingTarget.value) return;
const { type, id } = renamingTarget.value;
const name = renameValue.value.trim();
renamingTarget.value = null;
renameValue.value = "";
if (!name) return;
try {
if (type === "folder") await taskStore.renameFolder(id, name);
else await taskStore.renameTask(id, name);
} catch (error) {
toast(t("transfer.tasks.saveFailed", { message: renameErrorMessage(error) }), 5000);
}
}
function cancelRename() {
renamingTarget.value = null;
renameValue.value = "";
}
/** Default name for a new folder under the given parent: "新建文件夹", "新建文件夹1", ... avoiding sibling conflicts. */
function nextFolderDefaultName(parentFolderId?: string) {
const base = t("transfer.tasks.newFolderDefault");
const taken = new Set(taskStore.allFolders.filter((folder) => (folder.parentFolderId || "") === (parentFolderId || "")).map((folder) => folder.name.trim().toLocaleLowerCase()));
if (!taken.has(base.toLocaleLowerCase())) return base;
let index = 1;
while (taken.has(`${base}${index}`.toLocaleLowerCase())) index++;
return `${base}${index}`;
}
async function openNewFolderInput(parentFolderId?: string) {
if (parentFolderId) {
collapsedFolders.value = new Set([...collapsedFolders.value].filter((id) => id !== parentFolderId));
}
try {
const folder = await taskStore.createFolder(nextFolderDefaultName(parentFolderId), parentFolderId);
searchText.value = "";
startRenameFolder(folder);
} catch (error) {
toast(t("transfer.tasks.saveFailed", { message: renameErrorMessage(error) }), 5000);
}
}
function requestNewBlank() {
emit("newBlank");
}
// ---- delete ----
const deleteTarget = ref<{ type: "folder" | "task"; id: string; name: string } | null>(null);
const showDeleteConfirm = ref(false);
function confirmDeleteFolder(folder: TransferTaskFolder) {
deleteTarget.value = { type: "folder", id: folder.id, name: folder.name };
showDeleteConfirm.value = true;
}
function confirmDeleteTask(task: TransferTask) {
deleteTarget.value = { type: "task", id: task.id, name: task.name };
showDeleteConfirm.value = true;
}
async function executeDelete() {
if (!deleteTarget.value) return;
const { type, id } = deleteTarget.value;
try {
if (type === "folder") {
await taskStore.deleteFolder(id);
// Folder deletion cascades to contained tasks; clear the selection when
// the currently loaded task was inside the deleted branch.
if (props.selectedTaskId && !taskStore.getTask(props.selectedTaskId)) {
emit("update:selectedTaskId", null);
}
} else {
await taskStore.deleteTask(id);
if (props.selectedTaskId === id) emit("update:selectedTaskId", null);
}
} catch (error) {
toast(t("transfer.tasks.deleteFailed", { message: (error as Error)?.message || String(error) }), 5000);
}
showDeleteConfirm.value = false;
deleteTarget.value = null;
}
async function duplicateTask(task: TransferTask) {
try {
const copy = await taskStore.duplicateTask(task.id);
if (copy) startRenameTask(copy);
} catch (error) {
toast(t("transfer.tasks.saveFailed", { message: (error as Error)?.message || String(error) }), 5000);
}
}
// ---- context menu ----
const contextTarget = ref<TransferTaskFolder | TransferTask | "panel" | null>(null);
function isFolderTarget(target: unknown): target is TransferTaskFolder {
return !!target && target !== "panel" && !("config" in (target as object));
}
function clearContextTarget() {
contextTarget.value = null;
}
function taskMoveMenuItems(task: TransferTask): CtxMenuItem[] {
const folderItems = taskStore.allFolders.map((folder) => ({
label: folderPath(folder),
action: () => void moveTask(task.id, folder.id),
disabled: task.folderId === folder.id,
icon: FolderClosed,
}));
return [{ label: t("transfer.tasks.ungrouped"), action: () => void moveTask(task.id, undefined), disabled: !task.folderId, icon: FolderOpen }, ...(folderItems.length > 0 ? [{ label: "", separator: true } as CtxMenuItem, ...folderItems] : [])];
}
async function moveTask(taskId: string, folderId?: string) {
try {
await taskStore.moveTaskToFolder(taskId, folderId);
} catch (error) {
toast(t("transfer.tasks.moveFailed", { message: (error as Error)?.message || String(error) }), 5000);
}
}
const contextMenuItems = computed<CtxMenuItem[]>(() => {
const target = contextTarget.value;
if (!target) return [];
if (target === "panel") {
return [
{ label: t("transfer.tasks.newTask"), action: requestNewBlank, icon: Plus },
{ label: t("transfer.tasks.newFolder"), action: () => openNewFolderInput(), icon: FolderPlus },
];
}
if (isFolderTarget(target)) {
return [
{ label: t("transfer.tasks.newSubfolder"), action: () => openNewFolderInput(target.id), icon: FolderPlus },
{ label: t("transfer.tasks.rename"), action: () => startRenameFolder(target), icon: Pencil },
{ label: "", separator: true },
{ label: t("transfer.tasks.delete"), action: () => confirmDeleteFolder(target), icon: Trash2, variant: "destructive" },
];
}
return [
{ label: t("transfer.tasks.open"), action: () => emit("select", target), icon: ExternalLink },
{ label: t("transfer.tasks.duplicate"), action: () => duplicateTask(target), icon: Copy },
{ label: t("transfer.tasks.moveToFolder"), icon: FolderClosed, children: taskMoveMenuItems(target) },
{ label: "", separator: true },
{ label: t("transfer.tasks.rename"), action: () => startRenameTask(target), icon: Pencil },
{ label: "", separator: true },
{ label: t("transfer.tasks.delete"), action: () => confirmDeleteTask(target), icon: Trash2, variant: "destructive" },
];
});
// ---- drag & drop (mouse-based, mirroring SqlLibraryPanel) ----
const dragState = reactive<{
active: boolean;
draggedId: string | null;
draggedType: DragItemType | null;
targetId: string | null;
targetType: DragItemType | null;
dropPosition: DropPosition | null;
}>({
active: false,
draggedId: null,
draggedType: null,
targetId: null,
targetType: null,
dropPosition: null,
});
let pendingDrag: {
id: string;
type: DragItemType;
startX: number;
startY: number;
sourceEl: HTMLElement | null;
} | null = null;
let dragGhostEl: HTMLElement | null = null;
let clearSuppressTimer: number | undefined;
const suppressNextRowClick = ref(false);
function markSuppressedClick() {
suppressNextRowClick.value = true;
window.clearTimeout(clearSuppressTimer);
clearSuppressTimer = window.setTimeout(() => {
suppressNextRowClick.value = false;
}, 0);
}
function resetDragState() {
dragState.active = false;
dragState.draggedId = null;
dragState.draggedType = null;
dragState.targetId = null;
dragState.targetType = null;
dragState.dropPosition = null;
pendingDrag = null;
if (dragGhostEl) {
dragGhostEl.remove();
dragGhostEl = null;
}
document.body.style.cursor = "";
document.body.style.userSelect = "";
}
function createDragGhost(sourceEl: HTMLElement, x: number, y: number) {
const ghost = document.createElement("div");
const textNode = sourceEl.querySelector(".dbx-transfer-task-drag-label");
ghost.textContent = textNode?.textContent || "";
ghost.style.cssText = `
position: fixed;
pointer-events: none;
z-index: 9999;
opacity: 0.9;
box-shadow: 0 2px 8px rgba(0,0,0,0.12);
border-radius: var(--dbx-radius-fixed-4);
background: var(--background, #fff);
border: 1px solid var(--border, #e5e7eb);
max-width: 220px;
height: 24px;
padding: 0 8px;
font-size: 13px;
line-height: 24px;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
left: ${x + 8}px;
top: ${y - 12}px;
`;
document.body.appendChild(ghost);
return ghost;
}
function moveDragGhost(x: number, y: number) {
if (!dragGhostEl) return;
dragGhostEl.style.left = `${x + 8}px`;
dragGhostEl.style.top = `${y - 12}px`;
}
function onDocumentMouseMove(event: MouseEvent) {
if (!pendingDrag && !dragState.active) return;
if (pendingDrag && !dragState.active) {
const dx = event.clientX - pendingDrag.startX;
const dy = event.clientY - pendingDrag.startY;
if (Math.abs(dx) < DRAG_THRESHOLD && Math.abs(dy) < DRAG_THRESHOLD) return;
dragState.active = true;
dragState.draggedId = pendingDrag.id;
dragState.draggedType = pendingDrag.type;
document.body.style.cursor = "grabbing";
document.body.style.userSelect = "none";
if (pendingDrag.sourceEl) {
dragGhostEl = createDragGhost(pendingDrag.sourceEl, event.clientX, event.clientY);
}
pendingDrag = null;
}
if (dragState.active) {
moveDragGhost(event.clientX, event.clientY);
}
}
async function performDrop() {
const draggedId = dragState.draggedId;
const draggedType = dragState.draggedType;
const targetId = dragState.targetId;
const targetType = dragState.targetType;
const dropPosition = dragState.dropPosition;
if (!draggedId || !draggedType || !targetId || !targetType || !dropPosition) return;
if (draggedType === "folder" && targetType === "folder" && dropPosition !== "inside") {
await taskStore.reorderFolders(draggedId, targetId, dropPosition);
return;
}
if (draggedType === "folder" && targetType === "folder" && dropPosition === "inside") {
await taskStore.moveFolderToFolder(draggedId, targetId);
return;
}
if (draggedType !== "task") return;
if (targetType === "folder") {
await taskStore.moveTaskToFolder(draggedId, targetId);
return;
}
if (targetType === "unfiled") {
await taskStore.moveTaskToFolder(draggedId, undefined);
return;
}
if (targetType === "task" && dropPosition !== "inside") {
await taskStore.reorderTasks(draggedId, targetId, dropPosition);
}
}
function onDocumentMouseUp() {
const hadActiveDrag = dragState.active;
const dropPromise = hadActiveDrag ? performDrop() : Promise.resolve();
if (hadActiveDrag) markSuppressedClick();
resetDragState();
void dropPromise.catch((error) => toast(t("transfer.tasks.moveFailed", { message: (error as Error)?.message || String(error) }), 5000));
}
document.addEventListener("mousemove", onDocumentMouseMove, true);
document.addEventListener("mouseup", onDocumentMouseUp, true);
onBeforeUnmount(() => {
document.removeEventListener("mousemove", onDocumentMouseMove, true);
document.removeEventListener("mouseup", onDocumentMouseUp, true);
window.clearTimeout(clearSuppressTimer);
resetDragState();
});
function handleDragMouseDown(event: MouseEvent, id: string, type: Extract<DragItemType, "folder" | "task">) {
if (event.button !== 0) return;
if (event.shiftKey || event.metaKey || event.ctrlKey) return;
const target = event.target as HTMLElement | null;
if (target?.closest("[data-no-drag='true']")) return;
pendingDrag = {
id,
type,
startX: event.clientX,
startY: event.clientY,
sourceEl: event.currentTarget as HTMLElement,
};
}
function updateDropTarget(event: MouseEvent, targetId: string, targetType: DragItemType) {
if (!dragState.active || !dragState.draggedId || !dragState.draggedType) return;
if (dragState.draggedId === targetId) {
clearDropTarget(targetId);
return;
}
let nextPosition: DropPosition | null = null;
if (dragState.draggedType === "folder" && targetType === "folder") {
const rect = (event.currentTarget as HTMLElement).getBoundingClientRect();
const y = event.clientY - rect.top;
const third = rect.height / 3;
nextPosition = y > third && y < rect.height - third ? "inside" : y < rect.height / 2 ? "before" : "after";
} else if (dragState.draggedType === "task" && targetType === "task") {
const rect = (event.currentTarget as HTMLElement).getBoundingClientRect();
nextPosition = event.clientY - rect.top < rect.height / 2 ? "before" : "after";
} else if (dragState.draggedType === "task" && (targetType === "folder" || targetType === "unfiled")) {
nextPosition = "inside";
}
dragState.targetId = nextPosition ? targetId : null;
dragState.targetType = nextPosition ? targetType : null;
dragState.dropPosition = nextPosition;
}
function clearDropTarget(targetId: string) {
if (dragState.targetId !== targetId) return;
dragState.targetId = null;
dragState.targetType = null;
dragState.dropPosition = null;
}
function isDraggingItem(id: string) {
return dragState.active && dragState.draggedId === id;
}
function showDropBefore(targetId: string) {
return dragState.active && dragState.targetId === targetId && dragState.dropPosition === "before";
}
function showDropAfter(targetId: string) {
return dragState.active && dragState.targetId === targetId && dragState.dropPosition === "after";
}
function showDropInside(targetId: string) {
return dragState.active && dragState.targetId === targetId && dragState.dropPosition === "inside";
}
function onTaskClick(task: TransferTask) {
if (suppressNextRowClick.value) return;
emit("select", task);
}
function taskRowClass(taskId: string) {
return props.selectedTaskId === taskId ? "bg-accent text-accent-foreground" : "hover:bg-muted/70";
}
</script>
<template>
<div class="h-full flex flex-col overflow-hidden bg-muted/10 select-none">
<div class="h-9 flex items-center gap-1 px-2 border-b shrink-0 bg-muted/20">
<span class="text-[13px] font-medium">{{ t("transfer.tasks.title") }}</span>
<span class="flex-1" />
<LightTooltip :text="t('transfer.tasks.newTask')" side="bottom" :delay="0" :close-delay="0" nowrap>
<Button variant="ghost" size="icon" class="h-5 w-5" @click="requestNewBlank">
<Plus class="h-3 w-3" />
</Button>
</LightTooltip>
<LightTooltip :text="t('transfer.tasks.newFolder')" side="bottom" :delay="0" :close-delay="0" nowrap>
<Button variant="ghost" size="icon" class="h-5 w-5" @click="openNewFolderInput()">
<FolderPlus class="h-3 w-3" />
</Button>
</LightTooltip>
</div>
<div class="border-b shrink-0 px-2 py-1">
<div class="relative">
<Search class="absolute left-2 top-1/2 -translate-y-1/2 h-3 w-3 text-muted-foreground" />
<input v-model="searchText" autocapitalize="off" autocorrect="off" spellcheck="false" class="w-full h-6 pl-7 pr-6 text-[13px] rounded border border-border bg-background focus:outline-none focus:ring-1 focus:ring-ring" :placeholder="t('transfer.tasks.searchTasks')" />
<button v-if="searchText" type="button" class="absolute right-1.5 top-1/2 -translate-y-1/2 text-muted-foreground hover:text-foreground" @click="searchText = ''">
<X class="h-3 w-3" />
</button>
</div>
</div>
<div class="min-h-0 flex-1 overflow-y-auto py-1">
<CustomContextMenu :items="() => contextMenuItems" @close="clearContextTarget">
<template #default="{ onContextMenu }">
<div
class="h-full"
@contextmenu.capture="contextTarget = 'panel'"
@contextmenu.prevent="
contextTarget = 'panel';
onContextMenu($event);
"
>
<div v-for="row in visibleFolderRows" :key="row.type === 'folder' ? row.folder.id : row.task.id">
<div
v-if="row.type === 'folder'"
class="relative flex cursor-default items-center gap-1 py-1.5 pr-2 text-[13px] group"
:style="{ paddingLeft: `${8 + row.depth * 16}px` }"
:class="[showDropInside(row.folder.id) ? 'ring-1 ring-primary/50 bg-primary/5' : 'hover:bg-muted/70', isDraggingItem(row.folder.id) ? 'opacity-50' : '']"
@mousedown="handleDragMouseDown($event, row.folder.id, 'folder')"
@mousemove="updateDropTarget($event, row.folder.id, 'folder')"
@mouseleave="clearDropTarget(row.folder.id)"
@click="toggleFolder(row.folder.id)"
@contextmenu.capture="contextTarget = row.folder"
@contextmenu.prevent="
contextTarget = row.folder;
onContextMenu($event);
"
>
<div v-if="showDropBefore(row.folder.id)" class="absolute left-2 right-2 top-0 border-t-2 border-primary" />
<div v-if="showDropAfter(row.folder.id)" class="absolute left-2 right-2 bottom-0 border-b-2 border-primary" />
<ChevronRight class="h-3 w-3 text-muted-foreground/80 transition-transform duration-200 shrink-0" :class="{ 'rotate-90': isFolderExpanded(row.folder.id) }" />
<component :is="isFolderExpanded(row.folder.id) ? FolderOpen : FolderClosed" class="h-4 w-4 text-amber-500 shrink-0" />
<template v-if="isRenamingFolder(row.folder.id)">
<input
:ref="setRenameInputRef"
v-model="renameValue"
data-no-drag="true"
class="min-w-0 flex-1 rounded border border-primary/50 bg-transparent px-1 text-[13px] outline-none"
@keydown.enter.prevent="confirmRename"
@keydown.escape.prevent="cancelRename"
@blur="confirmRename"
@mousedown.stop
@click.stop
/>
</template>
<span v-else class="dbx-transfer-task-drag-label min-w-0 flex-1 truncate">
{{ row.folder.name }}
<span class="ml-1 text-muted-foreground">({{ folderTaskCount(row.folder.id) }})</span>
</span>
</div>
<div
v-else
class="relative flex cursor-default items-center gap-1 py-1.5 pr-2 text-[13px] group"
:style="{ paddingLeft: `${8 + row.depth * 16}px` }"
:class="[taskRowClass(row.task.id), isDraggingItem(row.task.id) ? 'opacity-50' : '']"
@mousedown="handleDragMouseDown($event, row.task.id, 'task')"
@mousemove="updateDropTarget($event, row.task.id, 'task')"
@mouseleave="clearDropTarget(row.task.id)"
@click="onTaskClick(row.task)"
@contextmenu.capture="contextTarget = row.task"
@contextmenu.prevent="
contextTarget = row.task;
onContextMenu($event);
"
>
<div v-if="showDropBefore(row.task.id)" class="absolute left-2 right-2 top-0 border-t-2 border-primary" />
<div v-if="showDropAfter(row.task.id)" class="absolute left-2 right-2 bottom-0 border-b-2 border-primary" />
<ArrowRightLeft class="h-3.5 w-3.5 text-blue-400 shrink-0" />
<template v-if="isRenamingTask(row.task.id)">
<input
:ref="setRenameInputRef"
v-model="renameValue"
data-no-drag="true"
class="min-w-0 flex-1 rounded border border-primary/50 bg-transparent px-1 text-[13px] outline-none"
@keydown.enter.prevent="confirmRename"
@keydown.escape.prevent="cancelRename"
@blur="confirmRename"
@mousedown.stop
@click.stop
/>
</template>
<span v-else class="dbx-transfer-task-drag-label min-w-0 flex-1 truncate" :title="`${row.task.config.sourceDatabase} → ${row.task.config.targetDatabase}`">{{ row.task.name }}</span>
</div>
</div>
<div v-if="visibleRootTasks.length > 0 || dragState.draggedType === 'task'">
<div
v-if="dragState.draggedType === 'task' && (visibleFolderRows.length > 0 || searchQuery)"
class="relative px-2 py-1 text-[10px] font-medium uppercase text-muted-foreground"
:class="showDropInside(UNFILED_DROP_TARGET_ID) ? 'ring-1 ring-primary/50 bg-primary/5' : ''"
@mousemove="updateDropTarget($event, UNFILED_DROP_TARGET_ID, 'unfiled')"
@mouseleave="clearDropTarget(UNFILED_DROP_TARGET_ID)"
>
{{ t("transfer.tasks.ungrouped") }}
</div>
<div
v-for="task in visibleRootTasks"
:key="task.id"
class="relative flex cursor-default items-center gap-1 px-2 py-1.5 text-[13px] group"
:class="[taskRowClass(task.id), isDraggingItem(task.id) ? 'opacity-50' : '']"
@mousedown="handleDragMouseDown($event, task.id, 'task')"
@mousemove="updateDropTarget($event, task.id, 'task')"
@mouseleave="clearDropTarget(task.id)"
@click="onTaskClick(task)"
@contextmenu.capture="contextTarget = task"
@contextmenu.prevent="
contextTarget = task;
onContextMenu($event);
"
>
<div v-if="showDropBefore(task.id)" class="absolute left-2 right-2 top-0 border-t-2 border-primary" />
<div v-if="showDropAfter(task.id)" class="absolute left-2 right-2 bottom-0 border-b-2 border-primary" />
<ArrowRightLeft class="h-3.5 w-3.5 text-blue-400 shrink-0" />
<template v-if="isRenamingTask(task.id)">
<input
:ref="setRenameInputRef"
v-model="renameValue"
data-no-drag="true"
class="min-w-0 flex-1 rounded border border-primary/50 bg-transparent px-1 text-[13px] outline-none"
@keydown.enter.prevent="confirmRename"
@keydown.escape.prevent="cancelRename"
@blur="confirmRename"
@mousedown.stop
@click.stop
/>
</template>
<span v-else class="dbx-transfer-task-drag-label min-w-0 flex-1 truncate" :title="`${task.config.sourceDatabase} → ${task.config.targetDatabase}`">{{ task.name }}</span>
</div>
</div>
<div v-if="!hasAnyVisibleItem" class="flex h-full flex-col items-center justify-center gap-2 text-muted-foreground">
<ArrowRightLeft class="h-8 w-8 opacity-30" />
<p class="text-[13px]">{{ t("transfer.tasks.empty") }}</p>
</div>
</div>
</template>
</CustomContextMenu>
</div>
<Dialog v-model:open="showDeleteConfirm">
<DialogContent class="sm:max-w-[380px]">
<DialogHeader>
<DialogTitle v-if="deleteTarget?.type === 'folder'">{{ t("transfer.tasks.deleteFolderTitle") }}</DialogTitle>
<DialogTitle v-else>{{ t("transfer.tasks.deleteTaskTitle") }}</DialogTitle>
<DialogDescription v-if="deleteTarget?.type === 'folder'">
{{ t("transfer.tasks.deleteFolderConfirm", { name: deleteTarget?.name || "" }) }}
</DialogDescription>
<DialogDescription v-else>
{{ t("transfer.tasks.deleteTaskConfirm", { name: deleteTarget?.name || "" }) }}
</DialogDescription>
</DialogHeader>
<DialogFooter>
<Button variant="outline" size="sm" @click="showDeleteConfirm = false">{{ t("dangerDialog.cancel") }}</Button>
<Button variant="destructive" size="sm" @click="executeDelete">{{ t("dangerDialog.confirm") }}</Button>
</DialogFooter>
</DialogContent>
</Dialog>
</div>
</template>
@@ -7,7 +7,7 @@ describe("DataTransferDialog layout", () => {
it("keeps the header and footer outside the shrinking content region", () => {
expect(dialogSource).toContain('<DialogHeader class="shrink-0">');
expect(dialogSource).toContain('<DialogFooter class="shrink-0">');
expect(dialogSource).toContain('class="min-h-0 flex-1 overflow-y-auto pr-1 scrollbar-thin"');
expect(dialogSource).toContain('class="min-h-0 flex-1 overflow-y-auto pl-4 pr-1 scrollbar-thin"');
});
it("allows layout scrolling on short viewports to prevent content clipping", () => {
@@ -43,7 +43,7 @@ describe("DataTransferDialog transfer prefill", () => {
it("loads non-table object groups per source database kind", () => {
expect(dialogSource).toContain("transferObjectKindsForDatabase");
expect(dialogSource).toContain("api.listObjects(sourceConnectionId.value, sourceDatabase.value, schema, [kind]");
expect(dialogSource).toContain("api.listObjects(connectionId, database, schema, [kind]");
expect(dialogSource).toContain("groups[kind] = objects.map((o) => o.name)");
});
@@ -51,15 +51,15 @@ describe("DataTransferDialog transfer prefill", () => {
expect(dialogSource).toContain("const kinds = transferObjectKindsForDatabase(config?.db_type)");
expect(dialogSource).toContain("for (const kind of kinds)");
expect(dialogSource).toContain('if (kind === "TABLE")');
expect(dialogSource).toContain("await api.listTables(sourceConnectionId.value, sourceDatabase.value, schema");
expect(dialogSource).toContain("await api.listTables(connectionId, database, schema");
});
it("keeps MongoDB collection loading ahead of the generic object-kind path", () => {
const mongoCollectionBranch = dialogSource.indexOf("if (isMongoConnection(sourceConnectionId.value))");
const mongoCollectionBranch = dialogSource.indexOf("if (isMongoConnection(connectionId))");
const genericObjectKinds = dialogSource.indexOf("const kinds = transferObjectKindsForDatabase(config?.db_type)");
expect(mongoCollectionBranch).toBeGreaterThan(-1);
expect(dialogSource).toContain("await api.mongoListCollections(sourceConnectionId.value, sourceDatabase.value)");
expect(dialogSource).toContain("await api.mongoListCollections(connectionId, database)");
expect(mongoCollectionBranch).toBeLessThan(genericObjectKinds);
});
@@ -79,4 +79,13 @@ describe("DataTransferDialog transfer prefill", () => {
expect(dialogSource).toContain("fetchCatalogNamespaceOptions(connectionId, catalog, config)");
expect(dialogSource).toContain("store.refreshObjectListTreeNode(request.targetConnectionId, request.targetDatabase, request.targetSchema, request.targetCatalog)");
});
it("discards stale async results after the connection changes", () => {
// 竞态防护:切换连接后旧请求的回调必须丢弃,不能覆盖新选择的下拉选项
expect(dialogSource).toContain("if (isStale()) return;");
expect(dialogSource).toContain("sourceConnectionId.value !== connectionId");
expect(dialogSource).toContain("targetConnectionId.value !== connectionId");
expect(dialogSource).toContain("if (sourceConnectionId.value !== id) return;");
expect(dialogSource).toContain("if (targetConnectionId.value !== id) return;");
});
});
@@ -0,0 +1,39 @@
import { describe, expect, it } from "vitest";
import { createTaskLoadTracker } from "@/components/transfer/taskLoadTracker";
function deferred() {
let resolve!: () => void;
const promise = new Promise<void>((resolvePromise) => {
resolve = resolvePromise;
});
return { promise, resolve };
}
describe("taskLoadTracker", () => {
it("keeps task B active when task A resolves after it", async () => {
const tracker = createTaskLoadTracker();
const taskAGate = deferred();
const taskBGate = deferred();
const applied: string[] = [];
let activeTaskId: string | null = "task-a";
const taskAToken = tracker.begin("task-a");
const taskALoad = (async () => {
await taskAGate.promise;
if (tracker.isCurrent(taskAToken, activeTaskId)) applied.push("task-a");
})();
activeTaskId = "task-b";
const taskBToken = tracker.begin("task-b");
const taskBLoad = (async () => {
await taskBGate.promise;
if (tracker.isCurrent(taskBToken, activeTaskId)) applied.push("task-b");
})();
taskBGate.resolve();
await taskBLoad;
taskAGate.resolve();
await taskALoad;
expect(applied).toEqual(["task-b"]);
});
});
@@ -0,0 +1,21 @@
export interface TaskLoadToken {
generation: number;
taskId: string;
}
export function createTaskLoadTracker() {
let generation = 0;
return {
begin(taskId: string): TaskLoadToken {
generation += 1;
return { generation, taskId };
},
cancel() {
generation += 1;
},
isCurrent(token: TaskLoadToken, activeTaskId: string | null) {
return token.generation === generation && token.taskId === activeTaskId;
},
};
}
@@ -270,7 +270,13 @@ onBeforeUnmount(() => {
<slot :onContextMenu="onContextMenu" :isOpen="show" />
<!-- Main menu -->
<Teleport to="body">
<div v-if="show" ref="menuRef" data-dbx-context-menu :style="{ position: 'fixed', left: x + 'px', top: y + 'px', zIndex: 9999 }" class="bg-popover text-popover-foreground min-w-40 w-max max-w-[calc(100vw-16px)] rounded-md p-1 overflow-y-auto ring-1 ring-foreground/10 shadow-lg">
<div
v-if="show"
ref="menuRef"
data-dbx-context-menu
:style="{ position: 'fixed', left: x + 'px', top: y + 'px', zIndex: 9999 }"
class="pointer-events-auto bg-popover text-popover-foreground min-w-40 w-max max-w-[calc(100vw-16px)] rounded-md p-1 overflow-y-auto ring-1 ring-foreground/10 shadow-lg"
>
<template v-for="(item, index) in activeItems" :key="index">
<template v-if="item.visible !== false">
<div v-if="item.separator" class="-mx-1 my-1 flex items-center px-1">
@@ -298,7 +304,7 @@ onBeforeUnmount(() => {
ref="subRef"
data-dbx-context-menu
:style="{ position: 'fixed', left: subX + 'px', top: subY + 'px', zIndex: 10000, maxHeight: 'min(420px, calc(100vh - 16px))' }"
class="bg-popover text-popover-foreground min-w-56 w-max max-w-[calc(100vw-16px)] rounded-md p-1 overflow-y-auto ring-1 ring-foreground/10 shadow-lg"
class="pointer-events-auto bg-popover text-popover-foreground min-w-56 w-max max-w-[calc(100vw-16px)] rounded-md p-1 overflow-y-auto ring-1 ring-foreground/10 shadow-lg"
@mouseenter="onSubMouseEnter"
@mouseleave="onSubMouseLeave"
>
+32
View File
@@ -4734,6 +4734,8 @@ export default {
tableNameCaseUpper: "Uppercase",
batchSize: "Batch size",
start: "Start Transfer",
startConfirmTitle: "Confirm transfer",
startConfirmMessage: "Transfer {count} selected objects from {source} to {target}. Start now?",
cancel: "Cancel",
progress: "Transferring...",
transferring: "Transferring {table}...",
@@ -4768,6 +4770,36 @@ export default {
searchConnection: "Search connections...",
searchDatabase: "Search databases...",
searchSchema: "Search schemas...",
saveConfig: "Save config",
saveAsNewTask: "Save as task",
taskSaved: "Task saved",
unsavedTitle: "Unsaved changes",
unsavedMessage: "The current configuration has unsaved changes. Discard them?",
unsavedDiscard: "Discard",
tasks: {
title: "Transfer tasks",
searchTasks: "Search tasks...",
newTask: "New task",
newFolder: "New folder",
newSubfolder: "New subfolder",
rename: "Rename",
duplicate: "Duplicate",
delete: "Delete",
open: "Open",
moveToFolder: "Move to folder",
ungrouped: "Ungrouped",
empty: "No saved transfer tasks",
newFolderDefault: "New folder",
newTaskDefault: "New transfer task",
deleteFolderTitle: "Delete folder",
deleteFolderConfirm: "Delete “{name}” and all transfer tasks inside it?",
deleteTaskTitle: "Delete task",
deleteTaskConfirm: "Delete transfer task “{name}”?",
nameConflict: "“{name}” already exists in this location",
moveFailed: "Move failed: {message}",
saveFailed: "Save failed: {message}",
deleteFailed: "Delete failed: {message}",
},
},
tableImport: {
title: "Import Table Data",
+32
View File
@@ -4512,6 +4512,8 @@ export default withEnglishFallback({
tableNameCaseUpper: "Convertir a mayúsculas",
batchSize: "Tamaño de lote",
start: "Iniciar transferencia",
startConfirmTitle: "Confirmar transferencia",
startConfirmMessage: "Se transferirán {count} objetos seleccionados de {source} a {target}. ¿Iniciar ahora?",
cancel: "Cancelar",
progress: "Transfiriendo...",
transferring: "Transfiriendo {table}...",
@@ -4552,6 +4554,36 @@ export default withEnglishFallback({
targetCatalog: "Catalog de destino",
selectCatalog: "Seleccione Catalog...",
searchCatalog: "Buscar Catalog...",
saveConfig: "Guardar configuración",
saveAsNewTask: "Guardar como tarea",
taskSaved: "Tarea guardada",
unsavedTitle: "Cambios sin guardar",
unsavedMessage: "La configuración actual tiene cambios sin guardar. ¿Descartarlos?",
unsavedDiscard: "Descartar",
tasks: {
title: "Tareas de transferencia",
searchTasks: "Buscar tareas...",
newTask: "Nueva tarea",
newFolder: "Nueva carpeta",
newSubfolder: "Nueva subcarpeta",
rename: "Renombrar",
duplicate: "Duplicar",
delete: "Eliminar",
open: "Abrir",
moveToFolder: "Mover a carpeta",
ungrouped: "Sin agrupar",
empty: "No hay tareas de transferencia guardadas",
newFolderDefault: "Nueva carpeta",
newTaskDefault: "Nueva tarea de transferencia",
deleteFolderTitle: "Eliminar carpeta",
deleteFolderConfirm: "¿Eliminar “{name}” y todas las tareas de transferencia que contiene?",
deleteTaskTitle: "Eliminar tarea",
deleteTaskConfirm: "¿Eliminar la tarea de transferencia “{name}”?",
nameConflict: "“{name}” ya existe en esta ubicación",
moveFailed: "Error al mover: {message}",
saveFailed: "Error al guardar: {message}",
deleteFailed: "Error al eliminar: {message}",
},
},
tableImport: {
title: "Importar datos a tabla",
+32
View File
@@ -4512,6 +4512,8 @@ export default withEnglishFallback({
tableNameCaseUpper: "Converti in maiuscolo",
batchSize: "Dimensione lotto",
start: "Avvia Trasferimento",
startConfirmTitle: "Conferma trasferimento",
startConfirmMessage: "Verranno trasferiti {count} oggetti selezionati da {source} a {target}. Avviare ora?",
cancel: "Annulla",
progress: "Trasferimento in corso...",
transferring: "Trasferimento di {table}...",
@@ -4550,6 +4552,36 @@ export default withEnglishFallback({
targetCatalog: "Catalogo destinazione",
selectCatalog: "Seleziona Catalogo...",
searchCatalog: "Cerca Catalogo...",
saveConfig: "Salva configurazione",
saveAsNewTask: "Salva come attività",
taskSaved: "Attività salvata",
unsavedTitle: "Modifiche non salvate",
unsavedMessage: "La configurazione corrente ha modifiche non salvate. Scartarle?",
unsavedDiscard: "Scarta",
tasks: {
title: "Attività di trasferimento",
searchTasks: "Cerca attività...",
newTask: "Nuova attività",
newFolder: "Nuova cartella",
newSubfolder: "Nuova sottocartella",
rename: "Rinomina",
duplicate: "Duplica",
delete: "Elimina",
open: "Apri",
moveToFolder: "Sposta in cartella",
ungrouped: "Non raggruppate",
empty: "Nessuna attività di trasferimento salvata",
newFolderDefault: "Nuova cartella",
newTaskDefault: "Nuova attività di trasferimento",
deleteFolderTitle: "Elimina cartella",
deleteFolderConfirm: "Eliminare “{name}” e tutte le attività di trasferimento al suo interno?",
deleteTaskTitle: "Elimina attività",
deleteTaskConfirm: "Eliminare l'attività di trasferimento “{name}”?",
nameConflict: "“{name}” esiste già in questa posizione",
moveFailed: "Spostamento fallito: {message}",
saveFailed: "Salvataggio fallito: {message}",
deleteFailed: "Eliminazione fallita: {message}",
},
},
tableImport: {
title: "Importa Dati Tabella",
+32
View File
@@ -4542,6 +4542,8 @@ export default withEnglishFallback({
tableNameCaseUpper: "大文字に変換",
batchSize: "バッチサイズ",
start: "転送開始",
startConfirmTitle: "転送の開始確認",
startConfirmMessage: "{source} で選択した {count} 個のオブジェクトを {target} に転送します。開始しますか?",
cancel: "キャンセル",
progress: "転送中...",
transferring: "{table} を転送中...",
@@ -4580,6 +4582,36 @@ export default withEnglishFallback({
targetCatalog: "ターゲットCatalog",
selectCatalog: "Catalogを選択してください...",
searchCatalog: "Catalogを検索...",
saveConfig: "設定を保存",
saveAsNewTask: "タスクとして保存",
taskSaved: "タスクを保存しました",
unsavedTitle: "未保存の変更",
unsavedMessage: "現在の設定には未保存の変更があります。破棄しますか?",
unsavedDiscard: "変更を破棄",
tasks: {
title: "転送タスク",
searchTasks: "タスクを検索...",
newTask: "新規タスク",
newFolder: "新規フォルダ",
newSubfolder: "新規サブフォルダ",
rename: "名前を変更",
duplicate: "複製",
delete: "削除",
open: "開く",
moveToFolder: "フォルダへ移動",
ungrouped: "未分類",
empty: "保存された転送タスクはありません",
newFolderDefault: "新規フォルダ",
newTaskDefault: "新規転送タスク",
deleteFolderTitle: "フォルダを削除",
deleteFolderConfirm: "「{name}」とその中のすべての転送タスクを削除しますか?",
deleteTaskTitle: "タスクを削除",
deleteTaskConfirm: "転送タスク「{name}」を削除しますか?",
nameConflict: "「{name}」はこの場所に既に存在します",
moveFailed: "移動に失敗しました:{message}",
saveFailed: "保存に失敗しました:{message}",
deleteFailed: "削除に失敗しました:{message}",
},
},
tableImport: {
title: "テーブルデータをインポート",
+32
View File
@@ -4225,6 +4225,8 @@ export default withEnglishFallback({
tableNameCaseUpper: "대문자",
batchSize: "배치 크기",
start: "이전 시작",
startConfirmTitle: "전송 시작 확인",
startConfirmMessage: "{source}에서 선택한 개체 {count}개를 {target}(으)로 전송합니다. 시작하시겠습니까?",
cancel: "취소",
progress: "이전 중...",
transferring: "{table} 이전 중...",
@@ -4259,6 +4261,36 @@ export default withEnglishFallback({
searchConnection: "연결 검색...",
searchDatabase: "데이터베이스 검색...",
searchSchema: "스키마 검색...",
saveConfig: "구성 저장",
saveAsNewTask: "작업으로 저장",
taskSaved: "작업이 저장되었습니다",
unsavedTitle: "저장되지 않은 변경 사항",
unsavedMessage: "현재 구성에 저장되지 않은 변경 사항이 있습니다. 무시하시겠습니까?",
unsavedDiscard: "변경 사항 무시",
tasks: {
title: "전송 작업",
searchTasks: "작업 검색...",
newTask: "새 작업",
newFolder: "새 폴더",
newSubfolder: "새 하위 폴더",
rename: "이름 바꾸기",
duplicate: "복제",
delete: "삭제",
open: "열기",
moveToFolder: "폴더로 이동",
ungrouped: "그룹 없음",
empty: "저장된 전송 작업이 없습니다",
newFolderDefault: "새 폴더",
newTaskDefault: "새 전송 작업",
deleteFolderTitle: "폴더 삭제",
deleteFolderConfirm: "“{name}”과(와) 그 안의 모든 전송 작업을 삭제하시겠습니까?",
deleteTaskTitle: "작업 삭제",
deleteTaskConfirm: "전송 작업 “{name}”을(를) 삭제하시겠습니까?",
nameConflict: "“{name}”이(가) 이 위치에 이미 존재합니다",
moveFailed: "이동 실패: {message}",
saveFailed: "저장 실패: {message}",
deleteFailed: "삭제 실패: {message}",
},
},
tableImport: {
title: "테이블 데이터 가져오기",
+32
View File
@@ -4514,6 +4514,8 @@ export default withEnglishFallback({
tableNameCaseUpper: "Converter para maiúsculas",
batchSize: "Tamanho do lote",
start: "Iniciar transferência",
startConfirmTitle: "Confirmar transferência",
startConfirmMessage: "{count} objetos selecionados de {source} serão transferidos para {target}. Iniciar agora?",
cancel: "Cancelar",
progress: "Transferindo...",
transferring: "Transferindo {table}...",
@@ -4552,6 +4554,36 @@ export default withEnglishFallback({
targetCatalog: "Catalog de destino",
selectCatalog: "Por favor, selecione Catalog...",
searchCatalog: "Buscar Catalog...",
saveConfig: "Salvar configuração",
saveAsNewTask: "Salvar como tarefa",
taskSaved: "Tarefa salva",
unsavedTitle: "Alterações não salvas",
unsavedMessage: "A configuração atual tem alterações não salvas. Descartá-las?",
unsavedDiscard: "Descartar",
tasks: {
title: "Tarefas de transferência",
searchTasks: "Pesquisar tarefas...",
newTask: "Nova tarefa",
newFolder: "Nova pasta",
newSubfolder: "Nova subpasta",
rename: "Renomear",
duplicate: "Duplicar",
delete: "Excluir",
open: "Abrir",
moveToFolder: "Mover para pasta",
ungrouped: "Sem grupo",
empty: "Nenhuma tarefa de transferência salva",
newFolderDefault: "Nova pasta",
newTaskDefault: "Nova tarefa de transferência",
deleteFolderTitle: "Excluir pasta",
deleteFolderConfirm: "Excluir “{name}” e todas as tarefas de transferência dentro dela?",
deleteTaskTitle: "Excluir tarefa",
deleteTaskConfirm: "Excluir a tarefa de transferência “{name}”?",
nameConflict: "“{name}” já existe neste local",
moveFailed: "Falha ao mover: {message}",
saveFailed: "Falha ao salvar: {message}",
deleteFailed: "Falha ao excluir: {message}",
},
},
tableImport: {
title: "Importar Dados da Tabela",
+32
View File
@@ -4722,6 +4722,8 @@ export default withEnglishFallback({
tableNameCaseUpper: "转为大写",
batchSize: "每批行数",
start: "开始传输",
startConfirmTitle: "确认开始传输",
startConfirmMessage: "将把 {source} 中选中的 {count} 个对象传输到 {target},确定开始吗?",
cancel: "取消",
progress: "传输中...",
transferring: "正在传输 {table}...",
@@ -4756,6 +4758,36 @@ export default withEnglishFallback({
searchConnection: "搜索连接...",
searchDatabase: "搜索数据库...",
searchSchema: "搜索模式...",
saveConfig: "保存配置",
saveAsNewTask: "保存为任务",
taskSaved: "任务已保存",
unsavedTitle: "未保存的更改",
unsavedMessage: "当前配置有未保存的更改,确定要放弃吗?",
unsavedDiscard: "放弃更改",
tasks: {
title: "传输任务",
searchTasks: "搜索任务...",
newTask: "新建任务",
newFolder: "新建文件夹",
newSubfolder: "新建子文件夹",
rename: "重命名",
duplicate: "创建副本",
delete: "删除",
open: "打开",
moveToFolder: "移动到文件夹",
ungrouped: "未分组",
empty: "暂无已保存的传输任务",
newFolderDefault: "新建文件夹",
newTaskDefault: "新建传输任务",
deleteFolderTitle: "删除文件夹",
deleteFolderConfirm: "确定删除“{name}”及其中的所有传输任务吗?",
deleteTaskTitle: "删除任务",
deleteTaskConfirm: "确定删除传输任务“{name}”吗?",
nameConflict: "“{name}”在此位置已存在",
moveFailed: "移动失败:{message}",
saveFailed: "保存失败:{message}",
deleteFailed: "删除失败:{message}",
},
},
tableImport: {
title: "导入表数据",
+32
View File
@@ -3852,6 +3852,8 @@ export default withEnglishFallback({
modeUpsert: "更新插入 (按主鍵 INSERT 或 UPDATE)",
batchSize: "每批筆數",
start: "開始傳輸",
startConfirmTitle: "確認開始傳輸",
startConfirmMessage: "將把 {source} 中選中的 {count} 個物件傳輸到 {target},確定開始嗎?",
cancel: "取消",
progress: "傳輸中……",
transferring: "正在傳輸 {table}……",
@@ -3894,6 +3896,36 @@ export default withEnglishFallback({
targetCatalog: "目標 Catalog",
selectCatalog: "請選擇 Catalog...",
searchCatalog: "搜尋 Catalog...",
saveConfig: "儲存配置",
saveAsNewTask: "儲存為任務",
taskSaved: "任務已儲存",
unsavedTitle: "未儲存的變更",
unsavedMessage: "目前配置有未儲存的變更,確定要放棄嗎?",
unsavedDiscard: "放棄變更",
tasks: {
title: "傳輸任務",
searchTasks: "搜尋任務...",
newTask: "新增任務",
newFolder: "新增資料夾",
newSubfolder: "新增子資料夾",
rename: "重新命名",
duplicate: "建立副本",
delete: "刪除",
open: "開啟",
moveToFolder: "移動到資料夾",
ungrouped: "未分組",
empty: "尚無已儲存的傳輸任務",
newFolderDefault: "新增資料夾",
newTaskDefault: "新增傳輸任務",
deleteFolderTitle: "刪除資料夾",
deleteFolderConfirm: "確定刪除「{name}」及其中的所有傳輸任務嗎?",
deleteTaskTitle: "刪除任務",
deleteTaskConfirm: "確定刪除傳輸任務「{name}」嗎?",
nameConflict: "「{name}」在此位置已存在",
moveFailed: "移動失敗:{message}",
saveFailed: "儲存失敗:{message}",
deleteFailed: "刪除失敗:{message}",
},
},
tableImport: {
title: "匯入資料表資料",
@@ -0,0 +1,88 @@
import { afterEach, describe, expect, it, vi } from "vitest";
function installIndexedDb(transactionOutcome: "complete" | "abort") {
const transaction: Record<string, any> = {
error: null,
oncomplete: null,
onabort: null,
onerror: null,
};
const store = {
put(_value: unknown, key: IDBValidKey) {
const request: Record<string, any> = { result: undefined, error: null, onsuccess: null, onerror: null };
queueMicrotask(() => {
request.result = key;
request.onsuccess?.();
queueMicrotask(() => {
if (transactionOutcome === "complete") {
transaction.oncomplete?.();
} else {
transaction.error = new Error("transaction aborted");
transaction.onabort?.();
}
});
});
return request as IDBRequest<IDBValidKey>;
},
};
transaction.objectStore = () => store;
const database = {
transaction: () => transaction as IDBTransaction,
};
const indexedDB = {
open() {
const request: Record<string, any> = { result: database, error: null, onsuccess: null, onerror: null, onblocked: null, onupgradeneeded: null };
queueMicrotask(() => request.onsuccess?.());
return request as IDBOpenDBRequest;
},
};
vi.stubGlobal("indexedDB", indexedDB);
}
function installLocalStorage(setItem: (key: string, value: string) => void) {
vi.stubGlobal("localStorage", {
getItem: vi.fn(() => null),
setItem: vi.fn(setItem),
removeItem: vi.fn(),
});
}
afterEach(() => {
vi.restoreAllMocks();
vi.unstubAllGlobals();
vi.resetModules();
});
describe("saveBrowserAppState", () => {
it("waits for transaction commit and falls back after an IndexedDB abort", async () => {
installIndexedDb("abort");
installLocalStorage(() => undefined);
const { saveBrowserAppState } = await import("@/lib/backend/browserAppStateStorage");
await expect(saveBrowserAppState("transfer_task_library", { version: 1 })).resolves.toBeUndefined();
expect(globalThis.localStorage.setItem).toHaveBeenCalledWith("dbx-app-state:transfer_task_library", JSON.stringify({ version: 1 }));
});
it("throws when localStorage quota prevents the fallback write", async () => {
vi.stubGlobal("indexedDB", undefined);
installLocalStorage(() => {
throw new Error("quota exceeded");
});
vi.spyOn(console, "warn").mockImplementation(() => undefined);
const { saveBrowserAppState } = await import("@/lib/backend/browserAppStateStorage");
await expect(saveBrowserAppState("transfer_task_library", { version: 1 })).rejects.toThrow("Failed to persist browser app state: transfer_task_library");
});
it("throws when both IndexedDB and localStorage fail", async () => {
installIndexedDb("abort");
installLocalStorage(() => {
throw new Error("storage disabled");
});
vi.spyOn(console, "warn").mockImplementation(() => undefined);
const { saveBrowserAppState } = await import("@/lib/backend/browserAppStateStorage");
await expect(saveBrowserAppState("transfer_task_library", { version: 1 })).rejects.toThrow("Failed to persist browser app state: transfer_task_library");
});
});
+2
View File
@@ -300,6 +300,8 @@ export const loadOpenTabsState = forward("loadOpenTabsState");
export const saveOpenTabsState = forward("saveOpenTabsState");
export const loadSavedSqlEditorPositions = forward("loadSavedSqlEditorPositions");
export const saveSavedSqlEditorPositions = forward("saveSavedSqlEditorPositions");
export const loadTransferTaskLibrary = forward("loadTransferTaskLibrary");
export const saveTransferTaskLibrary = forward("saveTransferTaskLibrary");
export const webdavSyncTest = forward("webdavSyncTest");
export const webdavPasswordStatus = forward("webdavPasswordStatus");
export const saveWebdavSavedPassword = forward("saveWebdavSavedPassword");
@@ -16,6 +16,14 @@ function requestToPromise<T>(request: IDBRequest<T>): Promise<T> {
});
}
function transactionToPromise(transaction: IDBTransaction): Promise<void> {
return new Promise((resolve, reject) => {
transaction.oncomplete = () => resolve();
transaction.onabort = () => reject(transaction.error ?? new Error("IndexedDB transaction aborted"));
transaction.onerror = () => reject(transaction.error ?? new Error("IndexedDB transaction failed"));
});
}
let dbPromise: Promise<IDBDatabase | null> | undefined;
function openDb(): Promise<IDBDatabase | null> {
@@ -44,7 +52,13 @@ async function withStore<T>(mode: IDBTransactionMode, run: (store: IDBObjectStor
const db = await openDb();
if (!db) return null;
try {
return await requestToPromise(run(db.transaction(STORE_NAME, mode).objectStore(STORE_NAME)));
const transaction = db.transaction(STORE_NAME, mode);
const request = requestToPromise(run(transaction.objectStore(STORE_NAME)));
if (mode === "readwrite") {
const [result] = await Promise.all([request, transactionToPromise(transaction)]);
return result;
}
return await request;
} catch {
return null;
}
@@ -66,5 +80,6 @@ export async function loadBrowserAppState(key: string): Promise<unknown | null>
export async function saveBrowserAppState(key: string, value: unknown): Promise<void> {
const result = await withStore("readwrite", (store) => store.put(value, key));
if (result !== null) return;
safeLocalStorageSet(fallbackKey(key), JSON.stringify(value));
if (safeLocalStorageSet(fallbackKey(key), JSON.stringify(value))) return;
throw new Error(`Failed to persist browser app state: ${key}`);
}
+8
View File
@@ -1678,6 +1678,14 @@ export async function saveSavedSqlEditorPositions(positions: unknown[]): Promise
await saveBrowserAppState("saved_sql_editor_positions", positions);
}
export async function loadTransferTaskLibrary(): Promise<unknown | null> {
return loadBrowserAppState("transfer_task_library");
}
export async function saveTransferTaskLibrary(library: unknown): Promise<void> {
await saveBrowserAppState("transfer_task_library", library);
}
export async function completeAppClose(_action: "quit" | "hide"): Promise<void> {
return undefined;
}
+6 -2
View File
@@ -11,11 +11,15 @@ export function safeLocalStorageGet(key: string): string | null {
}
}
export function safeLocalStorageSet(key: string, value: string) {
export function safeLocalStorageSet(key: string, value: string): boolean {
try {
globalThis.localStorage?.setItem(key, value);
const storage = globalThis.localStorage;
if (!storage) return false;
storage.setItem(key, value);
return true;
} catch (error) {
storageWarn("set", key, error);
return false;
}
}
+8
View File
@@ -613,6 +613,14 @@ export async function saveSavedSqlEditorPositions(positions: unknown[]): Promise
return invoke("save_saved_sql_editor_positions", { positions });
}
export async function loadTransferTaskLibrary(): Promise<unknown | null> {
return invoke("load_transfer_task_library");
}
export async function saveTransferTaskLibrary(library: unknown): Promise<void> {
return invoke("save_transfer_task_library", { library });
}
export async function completeAppClose(action: "quit" | "hide"): Promise<void> {
return invoke("complete_app_close", { action });
}
@@ -0,0 +1,289 @@
import { createPinia, setActivePinia } from "pinia";
import { beforeEach, describe, expect, it, vi } from "vitest";
import * as api from "@/lib/backend/api";
import { nextTransferTaskCopyName, TransferTaskNameConflictError, useTransferTaskStore } from "@/stores/transferTaskStore";
import type { TransferTask, TransferTaskConfig, TransferTaskLibrary } from "@/types/database";
vi.mock("@/lib/backend/api", () => ({
loadTransferTaskLibrary: vi.fn(),
saveTransferTaskLibrary: vi.fn(),
}));
function deferred<T>() {
let resolve!: (value: T | PromiseLike<T>) => void;
let reject!: (reason?: unknown) => void;
const promise = new Promise<T>((resolvePromise, rejectPromise) => {
resolve = resolvePromise;
reject = rejectPromise;
});
return { promise, resolve, reject };
}
function makeConfig(overrides: Partial<TransferTaskConfig> = {}): TransferTaskConfig {
return {
sourceConnectionId: "conn-a",
sourceDatabase: "app_db",
sourceSchema: "public",
targetConnectionId: "conn-b",
targetDatabase: "warehouse",
targetSchema: "public",
objects: { TABLE: ["users", "orders"] },
content: "structureAndData",
mode: "append",
targetTableNameCase: "preserve",
batchSize: 1000,
...overrides,
};
}
async function createTask(store: ReturnType<typeof useTransferTaskStore>, name: string, folderId?: string): Promise<TransferTask> {
const input: { name: string; folderId?: string; config: TransferTaskConfig } = { name, config: makeConfig() };
if (folderId !== undefined) input.folderId = folderId;
return store.saveTask(input);
}
describe("transferTaskStore", () => {
beforeEach(() => {
setActivePinia(createPinia());
vi.clearAllMocks();
vi.mocked(api.loadTransferTaskLibrary).mockResolvedValue(null);
vi.mocked(api.saveTransferTaskLibrary).mockResolvedValue(undefined);
});
it("loads and normalizes the persisted library", async () => {
const library: TransferTaskLibrary = {
version: 1,
folders: [{ id: "folder-1", name: "ETL", orderIndex: 0, createdAt: "2026-01-01T00:00:00.000Z", updatedAt: "2026-01-01T00:00:00.000Z" }],
tasks: [{ id: "task-1", folderId: "folder-1", name: "daily sync", orderIndex: 0, config: makeConfig(), createdAt: "2026-01-01T00:00:00.000Z", updatedAt: "2026-01-01T00:00:00.000Z" }],
};
vi.mocked(api.loadTransferTaskLibrary).mockResolvedValue(library);
const store = useTransferTaskStore();
await store.initFromStorage();
expect(store.isLoaded).toBe(true);
expect(store.folders).toHaveLength(1);
expect(store.tasks).toHaveLength(1);
expect(store.listTasks("folder-1").map((task) => task.id)).toEqual(["task-1"]);
});
it("refuses to overwrite a persisted library with invalid entries", async () => {
vi.mocked(api.loadTransferTaskLibrary).mockResolvedValue({
version: 1,
folders: [{ id: "folder-1", name: "ETL" }, { name: "missing id" }],
tasks: [{ id: "task-1", name: "ok", config: makeConfig() }],
});
const store = useTransferTaskStore();
await store.initFromStorage();
expect(store.isLoaded).toBe(false);
await expect(store.createFolder("new folder")).rejects.toThrow("Invalid transfer task library entry");
expect(api.saveTransferTaskLibrary).not.toHaveBeenCalled();
});
it("refuses unknown persisted versions without replacing them", async () => {
vi.mocked(api.loadTransferTaskLibrary).mockResolvedValue({ version: 2, folders: [], tasks: [] });
const store = useTransferTaskStore();
await expect(store.createFolder("new folder")).rejects.toThrow("Unsupported transfer task library version: 2");
expect(store.isLoaded).toBe(false);
expect(api.saveTransferTaskLibrary).not.toHaveBeenCalled();
});
it("loads the legacy unversioned shape and migrates it on the next save", async () => {
vi.mocked(api.loadTransferTaskLibrary).mockResolvedValue({
folders: [{ id: "folder-1", name: "ETL", createdAt: "2026-01-01T00:00:00.000Z", updatedAt: "2026-01-01T00:00:00.000Z" }],
tasks: [],
});
const store = useTransferTaskStore();
await store.createFolder("reporting");
expect(api.saveTransferTaskLibrary).toHaveBeenCalledWith(
expect.objectContaining({
version: 1,
folders: expect.arrayContaining([expect.objectContaining({ name: "ETL" }), expect.objectContaining({ name: "reporting" })]),
}),
);
});
it("waits for delayed initialization before applying an immediate mutation", async () => {
const loadGate = deferred<TransferTaskLibrary>();
vi.mocked(api.loadTransferTaskLibrary).mockReturnValue(loadGate.promise);
const store = useTransferTaskStore();
const initialization = store.initFromStorage();
const creation = store.createFolder("reporting");
await Promise.resolve();
expect(api.saveTransferTaskLibrary).not.toHaveBeenCalled();
loadGate.resolve({
version: 1,
folders: [{ id: "folder-1", name: "ETL", createdAt: "2026-01-01T00:00:00.000Z", updatedAt: "2026-01-01T00:00:00.000Z" }],
tasks: [],
});
await initialization;
await creation;
expect(store.folders.map((folder) => folder.name)).toEqual(["ETL", "reporting"]);
expect(api.saveTransferTaskLibrary).toHaveBeenCalledWith(expect.objectContaining({ version: 1 }));
});
it("serializes whole-library saves so later mutations cannot finish first", async () => {
const firstSave = deferred<void>();
vi.mocked(api.saveTransferTaskLibrary)
.mockImplementationOnce(() => firstSave.promise)
.mockResolvedValueOnce(undefined);
const store = useTransferTaskStore();
const first = store.createFolder("first");
await vi.waitFor(() => expect(api.saveTransferTaskLibrary).toHaveBeenCalledTimes(1));
const second = store.createFolder("second");
await Promise.resolve();
expect(api.saveTransferTaskLibrary).toHaveBeenCalledTimes(1);
firstSave.resolve();
await Promise.all([first, second]);
const secondPayload = vi.mocked(api.saveTransferTaskLibrary).mock.calls[1]?.[0] as TransferTaskLibrary;
expect(secondPayload.folders.map((folder) => folder.name)).toEqual(["first", "second"]);
});
it("keeps a later successful mutation when the first queued save fails", async () => {
vi.mocked(api.saveTransferTaskLibrary).mockRejectedValueOnce(new Error("disk full")).mockResolvedValueOnce(undefined);
const store = useTransferTaskStore();
const first = store.createFolder("first");
const second = store.createFolder("second");
await expect(first).rejects.toThrow("disk full");
await expect(second).resolves.toEqual(expect.objectContaining({ name: "second" }));
expect(store.folders.map((folder) => folder.name)).toEqual(["second"]);
const secondPayload = vi.mocked(api.saveTransferTaskLibrary).mock.calls[1]?.[0] as TransferTaskLibrary;
expect(secondPayload.folders.map((folder) => folder.name)).toEqual(["second"]);
});
it("creates folders and rejects duplicate names within the same parent", async () => {
const store = useTransferTaskStore();
await store.initFromStorage();
await store.createFolder("ETL");
await expect(store.createFolder("etl")).rejects.toBeInstanceOf(TransferTaskNameConflictError);
const child = await store.createFolder("etl", store.folders[0]?.id);
expect(child.parentFolderId).toBe(store.folders[0]?.id);
expect(api.saveTransferTaskLibrary).toHaveBeenCalled();
});
it("creates and updates tasks, keeping the folder on config-only updates", async () => {
const store = useTransferTaskStore();
await store.initFromStorage();
const folder = await store.createFolder("ETL");
const task = await store.saveTask({ name: "sync", folderId: folder.id, config: makeConfig() });
const updated = await store.saveTask({ id: task.id, name: "sync", config: makeConfig({ mode: "overwrite" }) });
expect(updated.folderId).toBe(folder.id);
expect(updated.config.mode).toBe("overwrite");
expect(store.tasks).toHaveLength(1);
});
it("rejects duplicate task names within the same folder", async () => {
const store = useTransferTaskStore();
await store.initFromStorage();
await createTask(store, "sync");
await expect(createTask(store, "SYNC")).rejects.toBeInstanceOf(TransferTaskNameConflictError);
const folder = await store.createFolder("ETL");
await expect(createTask(store, "sync", folder.id)).resolves.toBeTruthy();
});
it("duplicates a task with an incrementing copy name", async () => {
const store = useTransferTaskStore();
await store.initFromStorage();
const task = await createTask(store, "sync");
const first = await store.duplicateTask(task.id);
const second = await store.duplicateTask(task.id);
expect(first?.name).toBe("sync_copy1");
expect(second?.name).toBe("sync_copy2");
expect(first?.config).toEqual(task.config);
expect(first?.id).not.toBe(task.id);
});
it("cascades folder deletion to descendant folders and tasks", async () => {
const store = useTransferTaskStore();
await store.initFromStorage();
const parent = await store.createFolder("parent");
const child = await store.createFolder("child", parent.id);
await createTask(store, "in-parent", parent.id);
await createTask(store, "in-child", child.id);
await createTask(store, "root-task");
await store.deleteFolder(parent.id);
expect(store.folders).toHaveLength(0);
expect(store.tasks.map((task) => task.name)).toEqual(["root-task"]);
});
it("moves a task into a folder and reindexes both groups", async () => {
const store = useTransferTaskStore();
await store.initFromStorage();
const folder = await store.createFolder("ETL");
const first = await createTask(store, "first");
const second = await createTask(store, "second");
await store.moveTaskToFolder(first.id, folder.id);
expect(store.getTask(first.id)?.folderId).toBe(folder.id);
expect(store.listTasks(undefined).map((task) => task.name)).toEqual(["second"]);
expect(store.listTasks(folder.id).map((task) => task.name)).toEqual(["first"]);
expect(store.listTasks(undefined)[0]?.orderIndex).toBe(0);
expect(second.folderId).toBeUndefined();
});
it("reorders tasks within a folder", async () => {
const store = useTransferTaskStore();
await store.initFromStorage();
const first = await createTask(store, "first");
await createTask(store, "second");
const third = await createTask(store, "third");
await store.reorderTasks(third.id, first.id, "before");
expect(store.listTasks(undefined).map((task) => task.name)).toEqual(["third", "first", "second"]);
expect(store.listTasks(undefined).map((task) => task.orderIndex)).toEqual([0, 1, 2]);
});
it("moves a folder into another folder and prevents cycles", async () => {
const store = useTransferTaskStore();
await store.initFromStorage();
const parent = await store.createFolder("parent");
const child = await store.createFolder("child", parent.id);
// Moving a folder into its own descendant must be rejected (cycle).
await store.moveFolderToFolder(parent.id, child.id);
expect(store.folders.find((folder) => folder.id === parent.id)?.parentFolderId).toBeUndefined();
await store.moveFolderToFolder(child.id, undefined);
expect(store.folders.find((folder) => folder.id === child.id)?.parentFolderId).toBeUndefined();
});
it("rolls back state when persistence fails", async () => {
const store = useTransferTaskStore();
await store.initFromStorage();
await store.createFolder("ETL");
vi.mocked(api.saveTransferTaskLibrary).mockRejectedValueOnce(new Error("disk full"));
await expect(store.createFolder("fail")).rejects.toThrow("disk full");
expect(store.folders.map((folder) => folder.name)).toEqual(["ETL"]);
});
});
describe("nextTransferTaskCopyName", () => {
it("increments the copy suffix and reuses a stripped copy base", () => {
expect(nextTransferTaskCopyName("sync", new Set())).toBe("sync_copy1");
expect(nextTransferTaskCopyName("sync", new Set(["sync_copy1"]))).toBe("sync_copy2");
expect(nextTransferTaskCopyName("sync_copy1", new Set(["sync_copy1"]))).toBe("sync_copy2");
});
});
@@ -0,0 +1,447 @@
import { computed, ref } from "vue";
import { defineStore } from "pinia";
import { uuid } from "@/lib/common/utils";
import * as api from "@/lib/backend/api";
import type { TransferTask, TransferTaskConfig, TransferTaskFolder, TransferTaskLibrary } from "@/types/database";
export class TransferTaskNameConflictError extends Error {
readonly code = "TRANSFER_TASK_NAME_CONFLICT";
constructor(readonly entryName: string) {
super(`"${entryName}" already exists in this location.`);
this.name = "TransferTaskNameConflictError";
}
}
function nowIso() {
return new Date().toISOString();
}
function sortFoldersByOrder(items: TransferTaskFolder[]) {
return [...items].sort((a, b) => {
const orderDiff = (a.orderIndex ?? 0) - (b.orderIndex ?? 0);
if (orderDiff !== 0) return orderDiff;
return a.name.localeCompare(b.name, undefined, { numeric: true, sensitivity: "base" });
});
}
function sortTasksByOrder(items: TransferTask[]) {
return [...items].sort((a, b) => {
const orderDiff = (a.orderIndex ?? 0) - (b.orderIndex ?? 0);
if (orderDiff !== 0) return orderDiff;
return a.name.localeCompare(b.name, undefined, { numeric: true, sensitivity: "base" });
});
}
function reindexFolders(items: TransferTaskFolder[]) {
return items.map((folder, index) => ({ ...folder, orderIndex: index }));
}
function reindexTasks(items: TransferTask[], folderId?: string) {
return items.map((task, index) => ({ ...task, folderId, orderIndex: index }));
}
function maxOrderIndex(values: Array<{ orderIndex?: number }>) {
return values.reduce((max, item) => Math.max(max, item.orderIndex ?? -1), -1);
}
function normalizeFolder(raw: unknown): TransferTaskFolder | null {
if (!raw || typeof raw !== "object") return null;
const candidate = raw as Partial<TransferTaskFolder>;
if (!candidate.id || !candidate.name) return null;
return {
id: String(candidate.id),
parentFolderId: candidate.parentFolderId || undefined,
name: String(candidate.name),
orderIndex: typeof candidate.orderIndex === "number" ? candidate.orderIndex : 0,
createdAt: candidate.createdAt || nowIso(),
updatedAt: candidate.updatedAt || nowIso(),
};
}
function normalizeTask(raw: unknown): TransferTask | null {
if (!raw || typeof raw !== "object") return null;
const candidate = raw as Partial<TransferTask>;
const config = candidate.config;
if (!candidate.id || !candidate.name || !config || typeof config !== "object") return null;
if (!config.sourceConnectionId || !config.sourceDatabase || !config.targetConnectionId || !config.targetDatabase) return null;
const objects: TransferTaskConfig["objects"] = {};
if (config.objects && typeof config.objects === "object") {
for (const [kind, names] of Object.entries(config.objects)) {
if (Array.isArray(names)) objects[kind as keyof typeof objects] = names.filter((name): name is string => typeof name === "string");
}
}
return {
id: String(candidate.id),
folderId: candidate.folderId || undefined,
name: String(candidate.name),
orderIndex: typeof candidate.orderIndex === "number" ? candidate.orderIndex : 0,
config: {
sourceConnectionId: String(config.sourceConnectionId),
sourceCatalog: config.sourceCatalog || undefined,
sourceDatabase: String(config.sourceDatabase),
sourceSchema: config.sourceSchema || undefined,
targetConnectionId: String(config.targetConnectionId),
targetCatalog: config.targetCatalog || undefined,
targetDatabase: String(config.targetDatabase),
targetSchema: config.targetSchema || undefined,
objects,
content: config.content ?? "structureAndData",
mode: config.mode ?? "append",
targetTableNameCase: config.targetTableNameCase ?? "preserve",
batchSize: typeof config.batchSize === "number" && config.batchSize > 0 ? config.batchSize : 1000,
},
createdAt: candidate.createdAt || nowIso(),
updatedAt: candidate.updatedAt || nowIso(),
};
}
function normalizeLibrary(raw: unknown): TransferTaskLibrary {
if (raw === null || raw === undefined) return { version: 1, folders: [], tasks: [] };
if (!raw || typeof raw !== "object" || Array.isArray(raw)) throw new Error("Invalid transfer task library");
const candidate = raw as { version?: unknown; folders?: unknown; tasks?: unknown };
if (candidate.version !== undefined && candidate.version !== 1) {
throw new Error(`Unsupported transfer task library version: ${String(candidate.version)}`);
}
if (!Array.isArray(candidate.folders) || !Array.isArray(candidate.tasks)) {
throw new Error("Invalid transfer task library");
}
const normalizedFolders = candidate.folders.map(normalizeFolder);
const normalizedTasks = candidate.tasks.map(normalizeTask);
if (normalizedFolders.some((folder) => !folder) || normalizedTasks.some((task) => !task)) {
throw new Error("Invalid transfer task library entry");
}
return {
version: 1,
folders: normalizedFolders as TransferTaskFolder[],
tasks: normalizedTasks as TransferTask[],
};
}
/** Copy name for duplicated tasks: "name_copy1", "name_copy2", ... */
export function nextTransferTaskCopyName(sourceName: string, takenNames: ReadonlySet<string>): string {
const copyBase = sourceName.replace(/_copy\d+$/i, "") || sourceName;
const normalizedTaken = new Set([...takenNames].map((name) => name.toLocaleLowerCase()));
let index = 1;
while (normalizedTaken.has(`${copyBase}_copy${index}`.toLocaleLowerCase())) index++;
return `${copyBase}_copy${index}`;
}
export const useTransferTaskStore = defineStore("transferTasks", () => {
const folders = ref<TransferTaskFolder[]>([]);
const tasks = ref<TransferTask[]>([]);
const isLoaded = ref(false);
let initPromise: Promise<void> | null = null;
let initError: unknown = null;
let mutationQueue: Promise<void> = Promise.resolve();
async function initFromStorage() {
if (isLoaded.value) return;
if (!initPromise) {
initPromise = (async () => {
try {
const library = normalizeLibrary(await api.loadTransferTaskLibrary());
folders.value = library.folders;
tasks.value = library.tasks;
initError = null;
isLoaded.value = true;
} catch (error) {
initError = error;
// Backend unavailable or persisted data is invalid: keep empty state and retry on next init.
}
})().finally(() => {
initPromise = null;
});
}
await initPromise;
}
async function ensureInitialized() {
await initFromStorage();
if (!isLoaded.value) {
throw initError instanceof Error ? initError : new Error("Failed to load transfer task library");
}
}
function queueMutation<Args extends unknown[], Result>(mutation: (...args: Args) => Promise<Result>) {
return (...args: Args): Promise<Result> => {
const result = mutationQueue.then(async () => {
await ensureInitialized();
return mutation(...args);
});
mutationQueue = result.then(
() => undefined,
() => undefined,
);
return result;
};
}
/** Whole-library persistence with rollback; public mutations run serially. */
async function persist(nextFolders: TransferTaskFolder[], nextTasks: TransferTask[]) {
const previousFolders = folders.value;
const previousTasks = tasks.value;
folders.value = nextFolders;
tasks.value = nextTasks;
try {
const library: TransferTaskLibrary = { version: 1, folders: nextFolders, tasks: nextTasks };
await api.saveTransferTaskLibrary(JSON.parse(JSON.stringify(library)));
} catch (error) {
folders.value = previousFolders;
tasks.value = previousTasks;
throw error;
}
}
function listChildFolders(parentFolderId?: string) {
return sortFoldersByOrder(folders.value.filter((folder) => (folder.parentFolderId || "") === (parentFolderId || "")));
}
function listTasks(folderId?: string) {
return sortTasksByOrder(tasks.value.filter((task) => (task.folderId || "") === (folderId || "")));
}
function getTask(id: string) {
return tasks.value.find((task) => task.id === id);
}
function descendantFolderIds(folderId: string) {
const ids = new Set<string>([folderId]);
let changed = true;
while (changed) {
changed = false;
for (const folder of folders.value) {
if (folder.parentFolderId && ids.has(folder.parentFolderId) && !ids.has(folder.id)) {
ids.add(folder.id);
changed = true;
}
}
}
return ids;
}
function ensureFolderNameAvailable(name: string, parentFolderId: string | undefined, excludeId?: string) {
const key = name.trim().toLocaleLowerCase();
const conflict = folders.value.some((folder) => folder.id !== excludeId && (folder.parentFolderId || "") === (parentFolderId || "") && folder.name.trim().toLocaleLowerCase() === key);
if (conflict) throw new TransferTaskNameConflictError(name);
}
function ensureTaskNameAvailable(name: string, folderId: string | undefined, excludeId?: string) {
const key = name.trim().toLocaleLowerCase();
const conflict = tasks.value.some((task) => task.id !== excludeId && (task.folderId || "") === (folderId || "") && task.name.trim().toLocaleLowerCase() === key);
if (conflict) throw new TransferTaskNameConflictError(name);
}
async function createFolder(name: string, parentFolderId?: string) {
const trimmed = name.trim();
if (!trimmed) throw new TransferTaskNameConflictError(name);
ensureFolderNameAvailable(trimmed, parentFolderId);
const timestamp = nowIso();
const folder: TransferTaskFolder = {
id: uuid(),
parentFolderId: parentFolderId || undefined,
name: trimmed,
orderIndex: maxOrderIndex(folders.value.filter((item) => (item.parentFolderId || "") === (parentFolderId || ""))) + 1,
createdAt: timestamp,
updatedAt: timestamp,
};
await persist([...folders.value, folder], tasks.value);
return folder;
}
async function renameFolder(id: string, name: string) {
const existing = folders.value.find((folder) => folder.id === id);
if (!existing) return;
const trimmed = name.trim();
if (!trimmed || trimmed === existing.name) return;
ensureFolderNameAvailable(trimmed, existing.parentFolderId, id);
await persist(
folders.value.map((folder) => (folder.id === id ? { ...folder, name: trimmed, updatedAt: nowIso() } : folder)),
tasks.value,
);
}
/** Deletes the folder, its descendant folders and all contained tasks. */
async function deleteFolder(id: string) {
const removedIds = descendantFolderIds(id);
await persist(
folders.value.filter((folder) => !removedIds.has(folder.id)),
tasks.value.filter((task) => !task.folderId || !removedIds.has(task.folderId)),
);
}
async function saveTask(input: { id?: string; folderId?: string; name: string; config: TransferTaskConfig }) {
const trimmed = input.name.trim();
if (!trimmed) throw new TransferTaskNameConflictError(input.name);
const existing = input.id ? getTask(input.id) : undefined;
const folderId = Object.prototype.hasOwnProperty.call(input, "folderId") ? input.folderId || undefined : existing?.folderId;
ensureTaskNameAvailable(trimmed, folderId, input.id);
const timestamp = nowIso();
const task: TransferTask = existing
? { ...existing, folderId, name: trimmed, config: input.config, updatedAt: timestamp }
: {
id: uuid(),
folderId,
name: trimmed,
orderIndex: maxOrderIndex(tasks.value.filter((item) => (item.folderId || "") === (folderId || ""))) + 1,
config: input.config,
createdAt: timestamp,
updatedAt: timestamp,
};
await persist(folders.value, [...tasks.value.filter((item) => item.id !== task.id), task]);
return task;
}
async function renameTask(id: string, name: string) {
const existing = getTask(id);
if (!existing) return;
const trimmed = name.trim();
if (!trimmed || trimmed === existing.name) return;
ensureTaskNameAvailable(trimmed, existing.folderId, id);
await persist(
folders.value,
tasks.value.map((task) => (task.id === id ? { ...task, name: trimmed, updatedAt: nowIso() } : task)),
);
}
async function duplicateTask(id: string) {
const source = getTask(id);
if (!source) return undefined;
const takenNames = new Set(tasks.value.filter((task) => (task.folderId || "") === (source.folderId || "")).map((task) => task.name));
const timestamp = nowIso();
const copy: TransferTask = {
...JSON.parse(JSON.stringify(source)),
id: uuid(),
name: nextTransferTaskCopyName(source.name, takenNames),
orderIndex: maxOrderIndex(tasks.value.filter((item) => (item.folderId || "") === (source.folderId || ""))) + 1,
createdAt: timestamp,
updatedAt: timestamp,
};
await persist(folders.value, [...tasks.value, copy]);
return copy;
}
async function deleteTask(id: string) {
await persist(
folders.value,
tasks.value.filter((task) => task.id !== id),
);
}
async function moveTaskToFolder(taskId: string, folderId?: string) {
const target = getTask(taskId);
if (!target) return;
const targetFolderId = folderId || undefined;
if ((target.folderId || undefined) === targetFolderId) return;
const timestamp = nowIso();
const sourceGroup = sortTasksByOrder(tasks.value.filter((task) => (task.folderId || "") === (target.folderId || "") && task.id !== taskId));
const destinationGroup = sortTasksByOrder(tasks.value.filter((task) => task.id !== taskId && (task.folderId || "") === (targetFolderId || "")));
const movedTask: TransferTask = { ...target, folderId: targetFolderId, updatedAt: timestamp };
const nextSource = reindexTasks(sourceGroup, target.folderId || undefined).map((task) => ({ ...task, updatedAt: timestamp }));
const nextDestination = reindexTasks([...destinationGroup, movedTask], targetFolderId).map((task) => ({ ...task, updatedAt: timestamp }));
const untouched = tasks.value.filter((task) => task.id !== taskId && (task.folderId || "") !== (target.folderId || "") && (task.folderId || "") !== (targetFolderId || ""));
await persist(folders.value, [...untouched, ...nextSource, ...nextDestination]);
}
async function moveFolderToFolder(folderId: string, parentFolderId?: string) {
const target = folders.value.find((folder) => folder.id === folderId);
if (!target) return;
const nextParentFolderId = parentFolderId || undefined;
if ((target.parentFolderId || undefined) === nextParentFolderId) return;
if (nextParentFolderId && descendantFolderIds(folderId).has(nextParentFolderId)) return;
const timestamp = nowIso();
const previousParentFolderId = target.parentFolderId || undefined;
const sourceGroup = reindexFolders(sortFoldersByOrder(folders.value.filter((folder) => folder.id !== folderId && (folder.parentFolderId || "") === (previousParentFolderId || "")))).map((folder) => ({
...folder,
updatedAt: timestamp,
}));
const destinationGroup = reindexFolders([...sortFoldersByOrder(folders.value.filter((folder) => folder.id !== folderId && (folder.parentFolderId || "") === (nextParentFolderId || ""))), { ...target, parentFolderId: nextParentFolderId, updatedAt: timestamp }]).map((folder) => ({
...folder,
parentFolderId: nextParentFolderId,
updatedAt: timestamp,
}));
const untouched = folders.value.filter((folder) => folder.id !== folderId && (folder.parentFolderId || "") !== (previousParentFolderId || "") && (folder.parentFolderId || "") !== (nextParentFolderId || ""));
await persist([...untouched, ...sourceGroup, ...destinationGroup], tasks.value);
}
async function reorderFolders(draggedId: string, targetId: string, position: "before" | "after") {
const dragged = folders.value.find((folder) => folder.id === draggedId);
const target = folders.value.find((folder) => folder.id === targetId);
if (!dragged || !target || dragged.id === target.id) return;
if (descendantFolderIds(draggedId).has(targetId)) return;
const timestamp = nowIso();
const targetParentFolderId = target.parentFolderId || undefined;
const previousParentFolderId = dragged.parentFolderId || undefined;
const ordered = sortFoldersByOrder(folders.value.filter((folder) => (folder.parentFolderId || "") === (targetParentFolderId || "")));
const remaining = ordered.filter((folder) => folder.id !== draggedId);
const targetIndex = remaining.findIndex((folder) => folder.id === targetId);
const insertIndex = position === "before" ? targetIndex : targetIndex + 1;
remaining.splice(insertIndex, 0, { ...dragged, parentFolderId: targetParentFolderId, updatedAt: timestamp });
const updatedTargetGroup = reindexFolders(remaining).map((folder) => ({ ...folder, parentFolderId: targetParentFolderId, updatedAt: timestamp }));
const updatedSourceGroup =
previousParentFolderId === targetParentFolderId
? []
: reindexFolders(sortFoldersByOrder(folders.value.filter((folder) => folder.id !== draggedId && (folder.parentFolderId || "") === (previousParentFolderId || "")))).map((folder) => ({
...folder,
updatedAt: timestamp,
}));
const untouched = folders.value.filter((folder) => folder.id !== draggedId && (folder.parentFolderId || "") !== (targetParentFolderId || "") && (folder.parentFolderId || "") !== (previousParentFolderId || ""));
await persist([...untouched, ...updatedSourceGroup, ...updatedTargetGroup], tasks.value);
}
async function reorderTasks(draggedId: string, targetId: string, position: "before" | "after") {
const dragged = getTask(draggedId);
const target = getTask(targetId);
if (!dragged || !target || dragged.id === target.id) return;
const targetFolderId = target.folderId || undefined;
const groupTasks = sortTasksByOrder(tasks.value.filter((task) => (task.folderId || "") === (targetFolderId || "")));
const remainingGroup = groupTasks.filter((task) => task.id !== draggedId);
const draggedNext: TransferTask = { ...dragged, folderId: targetFolderId, updatedAt: nowIso() };
const targetIndex = remainingGroup.findIndex((task) => task.id === targetId);
const insertIndex = position === "before" ? targetIndex : targetIndex + 1;
remainingGroup.splice(insertIndex, 0, draggedNext);
const updatedGroup = reindexTasks(remainingGroup, targetFolderId).map((task) => ({ ...task, updatedAt: draggedNext.updatedAt }));
const previousGroupId = dragged.folderId || undefined;
const sourceGroup = previousGroupId === targetFolderId ? [] : reindexTasks(sortTasksByOrder(tasks.value.filter((task) => task.id !== draggedId && (task.folderId || "") === (previousGroupId || ""))), previousGroupId).map((task) => ({ ...task, updatedAt: draggedNext.updatedAt }));
const untouched = tasks.value.filter((task) => task.id !== draggedId && (task.folderId || "") !== (targetFolderId || "") && (task.folderId || "") !== (previousGroupId || ""));
await persist(folders.value, [...untouched, ...sourceGroup, ...updatedGroup]);
}
const allFolders = computed(() => sortFoldersByOrder(folders.value));
const allTasks = computed(() => sortTasksByOrder(tasks.value));
return {
folders,
tasks,
isLoaded,
initFromStorage,
listChildFolders,
listTasks,
getTask,
createFolder: queueMutation(createFolder),
renameFolder: queueMutation(renameFolder),
deleteFolder: queueMutation(deleteFolder),
saveTask: queueMutation(saveTask),
renameTask: queueMutation(renameTask),
duplicateTask: queueMutation(duplicateTask),
deleteTask: queueMutation(deleteTask),
moveTaskToFolder: queueMutation(moveTaskToFolder),
moveFolderToFolder: queueMutation(moveFolderToFolder),
reorderFolders: queueMutation(reorderFolders),
reorderTasks: queueMutation(reorderTasks),
allFolders,
allTasks,
};
});
+45
View File
@@ -1,4 +1,5 @@
import type { BackendError } from "@/lib/backend/errorUtils";
import type { TransferContent, TransferMode, TransferObjectKind, TransferTableNameCase } from "@/lib/backend/tauri";
import type { MultiDbResultRunExecution } from "@/types/sqlExecution";
export type DatabaseType =
@@ -1322,6 +1323,50 @@ export interface SavedSqlLibrary {
files: SavedSqlFile[];
}
/** Serializable configuration of a saved data-transfer task. */
export interface TransferTaskConfig {
sourceConnectionId: string;
/** Undefined means the connection's built-in/default catalog. */
sourceCatalog?: string;
sourceDatabase: string;
sourceSchema?: string;
targetConnectionId: string;
targetCatalog?: string;
targetDatabase: string;
targetSchema?: string;
/** Selected object names grouped by object kind (TABLE, VIEW, ...). */
objects: Partial<Record<TransferObjectKind, string[]>>;
content: TransferContent;
mode: TransferMode;
targetTableNameCase: TransferTableNameCase;
batchSize: number;
}
export interface TransferTask {
id: string;
folderId?: string;
name: string;
orderIndex?: number;
config: TransferTaskConfig;
createdAt: string;
updatedAt: string;
}
export interface TransferTaskFolder {
id: string;
parentFolderId?: string;
name: string;
orderIndex?: number;
createdAt: string;
updatedAt: string;
}
export interface TransferTaskLibrary {
version: 1;
folders: TransferTaskFolder[];
tasks: TransferTask[];
}
export interface VectorCollectionMeta {
dimension?: number;
collectionId?: string;