feat: multi-row selection with batch clone, delete, and copy support

This commit is contained in:
t8y2
2026-05-13 16:25:02 +08:00
parent d50da7f1cd
commit b82c7f9274
8 changed files with 240 additions and 20 deletions
+1 -1
View File
@@ -246,7 +246,7 @@ onMounted(() => store.load());
<DialogHeader>
<DialogTitle>{{ selectedEntry ? entryTitle(selectedEntry) : t("history.viewDetails") }}</DialogTitle>
</DialogHeader>
<div v-if="selectedEntry" class="space-y-4">
<div v-if="selectedEntry" class="space-y-4 overflow-y-auto max-h-[60vh]">
<div class="grid grid-cols-[120px_1fr] gap-x-3 gap-y-2 text-sm">
<template v-for="[label, value] in detailsRows(selectedEntry)" :key="label">
<div class="text-muted-foreground">{{ label }}</div>
+90 -19
View File
@@ -927,6 +927,9 @@ const {
requestDeleteRow,
confirmDeleteRow,
restoreRow,
pendingDeleteRowIds,
requestDeleteRows,
cloneRows,
saveChanges,
discardChanges,
rowDataWithChanges,
@@ -1040,10 +1043,6 @@ const deleteRowDetails = computed(() =>
: t("dangerDialog.deleteRowDetailsNoTable"),
);
function deleteSelectedRow() {
if (!contextCell.value) return;
requestDeleteRow(contextCell.value.rowId);
}
const hasVisibleRows = computed(() => displayItems.value.length > 0);
const hasActiveFilter = computed(
() => !!deferredClientSearchText.value || rowStatusFilter.value !== "all" || hasLocalColumnFilters.value,
@@ -1074,17 +1073,27 @@ const {
hasCellSelection,
clearCellSelection,
selectSingleCell,
selectRow,
finishCellSelection,
beginCellSelection,
extendCellSelection,
cellIsSelected,
selectedRangeStart,
selectedRowIds,
hasRowSelection,
selectedRowCount,
clearRowSelection,
handleRowClick,
handleDataCellMousedown,
isRowSelected,
} = selection;
const selectionSummary = computed(() => t("grid.selectedCells", { count: selectedCellCount.value }));
const selectionSummary = computed(() => {
if (hasRowSelection.value) return t("grid.selectedRows", { count: selectedRowCount.value });
return t("grid.selectedCells", { count: selectedCellCount.value });
});
function isRowActive(index: number): boolean {
const item = displayItems.value[index];
if (item && isRowSelected(item.id)) return true;
const range = selectedRange.value;
if (!range) return false;
return index >= range.startRow && index <= range.endRow;
@@ -1407,6 +1416,8 @@ const {
getRowItem: (rowId: number) => visibleDisplayItems.value.find((item) => item.id === rowId),
quoteIdent,
escapeVal,
selectedRowIds,
hasRowSelection,
});
// --- Cell selection and detail ---
@@ -1541,6 +1552,7 @@ watch(
resetGridVerticalScroll();
}
clearCellSelection();
clearRowSelection();
showCellDetail.value = false;
detailCell.value = null;
showTranspose.value = false;
@@ -1552,6 +1564,10 @@ watch(
// --- Context menu handlers ---
function onCellContext(rowId: number, rowIndex: number, colIdx: number, visibleColIdx: number) {
contextCell.value = { rowId, rowIndex, col: colIdx };
if (hasRowSelection.value && isRowSelected(rowId)) {
return;
}
clearRowSelection();
if (!cellIsSelected(rowIndex, visibleColIdx)) {
selectSingleCell(rowIndex, visibleColIdx);
}
@@ -1559,7 +1575,11 @@ function onCellContext(rowId: number, rowIndex: number, colIdx: number, visibleC
function onRowContext(rowId: number, rowIndex: number) {
contextCell.value = { rowId, rowIndex, col: -1 };
selectRow(rowIndex);
if (!isRowSelected(rowId)) {
clearCellSelection();
selectedRowIds.value = new Set([rowId]);
selection.lastClickedRowIndex.value = rowIndex;
}
}
const sqlOneLiner = computed(() => props.sql?.replace(/\s+/g, " ").trim() || "");
@@ -2229,9 +2249,18 @@ defineExpose({
>
<div
class="shrink-0 px-2 py-1 border-r border-border text-center select-none cursor-default hover:bg-accent/50"
:class="rowNumberStatusClass(item)"
:class="[
rowNumberStatusClass(item),
{
'text-primary font-semibold !bg-primary/15':
isRowSelected(item.id) &&
item.status !== 'new' &&
item.status !== 'edited' &&
item.status !== 'deleted',
},
]"
:style="{ width: 'var(--row-num-w)' }"
@click="selectRow(index)"
@click="handleRowClick(index, item.id, $event)"
@contextmenu="onRowContext(item.id, index)"
>
{{ index + 1 }}
@@ -2249,7 +2278,7 @@ defineExpose({
'cursor-text hover:bg-accent/50': editable && !item.isDeleted,
'line-through': item.isDeleted,
}"
@mousedown="beginCellSelection(index, visibleColIdx, $event)"
@mousedown="handleDataCellMousedown(index, visibleColIdx, item.id, $event)"
@mouseenter="extendCellSelection(index, visibleColIdx)"
@dblclick="editable && !item.isDeleted && startEdit(item.id, actualColIdx)"
@contextmenu="onCellContext(item.id, index, actualColIdx, visibleColIdx)"
@@ -2540,8 +2569,20 @@ defineExpose({
<ContextMenuSubTrigger> <Copy class="w-3.5 h-3.5 mr-2" /> {{ t("grid.copy") }} </ContextMenuSubTrigger>
<ContextMenuSubContent class="w-max max-w-[min(80vw,18rem)]">
<ContextMenuItem v-if="contextColumn" @click="copyCell">{{ t("grid.copyCell") }}</ContextMenuItem>
<ContextMenuItem @click="copyRow">{{ t("grid.copyRow") }}</ContextMenuItem>
<ContextMenuItem @click="copyRowAsInsert">{{ t("grid.copyRowInsert") }}</ContextMenuItem>
<ContextMenuItem @click="copyRow">
{{
hasRowSelection && selectedRowCount > 1
? t("grid.copyRows", { count: selectedRowCount })
: t("grid.copyRow")
}}
</ContextMenuItem>
<ContextMenuItem @click="copyRowAsInsert">
{{
hasRowSelection && selectedRowCount > 1
? t("grid.copyRowsInsert", { count: selectedRowCount })
: t("grid.copyRowInsert")
}}
</ContextMenuItem>
<ContextMenuItem @click="copyAll">{{ t("grid.copyAll") }}</ContextMenuItem>
</ContextMenuSubContent>
</ContextMenuSub>
@@ -2563,14 +2604,36 @@ defineExpose({
</ContextMenuSub>
<ContextMenuSeparator />
<template v-if="editable && contextRowItem">
<ContextMenuItem @click="cloneRow(contextRowItem.id)">
<CopyPlus class="w-3.5 h-3.5 mr-2" /> {{ t("grid.cloneRow") }}
<ContextMenuItem
@click="
hasRowSelection && selectedRowCount > 1 ? cloneRows([...selectedRowIds]) : cloneRow(contextRowItem.id)
"
>
<CopyPlus class="w-3.5 h-3.5 mr-2" />
{{
hasRowSelection && selectedRowCount > 1
? t("grid.cloneRows", { count: selectedRowCount })
: t("grid.cloneRow")
}}
</ContextMenuItem>
<ContextMenuItem v-if="contextRowItem.isDeleted" @click="restoreRow(contextRowItem.id)">
<Undo2 class="w-3.5 h-3.5 mr-2" /> {{ t("grid.restoreRow") }}
</ContextMenuItem>
<ContextMenuItem v-else class="text-destructive" @click="deleteSelectedRow">
<Trash2 class="w-3.5 h-3.5 mr-2" /> {{ t("grid.deleteRow") }}
<ContextMenuItem
v-else
class="text-destructive"
@click="
hasRowSelection && selectedRowCount > 1
? requestDeleteRows([...selectedRowIds])
: requestDeleteRow(contextRowItem.id)
"
>
<Trash2 class="w-3.5 h-3.5 mr-2" />
{{
hasRowSelection && selectedRowCount > 1
? t("grid.deleteRows", { count: selectedRowCount })
: t("grid.deleteRow")
}}
</ContextMenuItem>
<ContextMenuSeparator />
</template>
@@ -2676,9 +2739,17 @@ defineExpose({
<DangerConfirmDialog
v-model:open="showDeleteRowConfirm"
:message="t('dangerDialog.deleteRowMessage')"
:message="
pendingDeleteRowIds.length > 1
? t('dangerDialog.deleteRowsMessage', { count: pendingDeleteRowIds.length })
: t('dangerDialog.deleteRowMessage')
"
:details="deleteRowDetails"
:confirm-label="t('grid.deleteRow')"
:confirm-label="
pendingDeleteRowIds.length > 1
? t('grid.deleteRows', { count: pendingDeleteRowIds.length })
: t('grid.deleteRow')
"
@confirm="confirmDeleteRow"
/>
</div>
+31
View File
@@ -384,6 +384,21 @@ export function useDataGridEditor(options: UseDataGridEditorOptions) {
});
}
function cloneRows(rowIds: number[]) {
const primaryKeys = tableMeta.value?.primaryKeys ?? [];
const columns = result.value.columns;
rowStatusFilter.value = rowStatusFilterAfterAddingRow(rowStatusFilter.value);
for (const rowId of rowIds) {
const item = getRowItem(rowId);
if (!item) continue;
const clonedData = item.data.map((val, i) => (primaryKeys.includes(columns[i]) ? null : val));
newRows.value.push(clonedData);
}
if (useTransaction.value && !transactionActive.value) {
enterTransaction();
}
}
function applyDeleteRow(rowId: number) {
const item = getRowItem(rowId);
if (!item) return;
@@ -401,13 +416,26 @@ export function useDataGridEditor(options: UseDataGridEditorOptions) {
const showDeleteRowConfirm = ref(false);
const pendingDeleteRowId = ref<number | null>(null);
const pendingDeleteRowIds = ref<number[]>([]);
function requestDeleteRow(rowId: number) {
pendingDeleteRowId.value = rowId;
showDeleteRowConfirm.value = true;
}
function requestDeleteRows(rowIds: number[]) {
pendingDeleteRowIds.value = rowIds;
showDeleteRowConfirm.value = true;
}
function confirmDeleteRow() {
if (pendingDeleteRowIds.value.length > 0) {
for (const rowId of pendingDeleteRowIds.value) {
applyDeleteRow(rowId);
}
pendingDeleteRowIds.value = [];
return;
}
if (pendingDeleteRowId.value === null) return;
applyDeleteRow(pendingDeleteRowId.value);
pendingDeleteRowId.value = null;
@@ -642,10 +670,13 @@ export function useDataGridEditor(options: UseDataGridEditorOptions) {
onEditKeydown,
addRow,
cloneRow,
cloneRows,
applyDeleteRow,
showDeleteRowConfirm,
pendingDeleteRowId,
pendingDeleteRowIds,
requestDeleteRow,
requestDeleteRows,
confirmDeleteRow,
restoreRow,
deleteSelectedRow,
+28
View File
@@ -39,6 +39,8 @@ export interface UseDataGridExportOptions {
getRowItem: (rowId: number) => RowItem | undefined;
quoteIdent: (name: string) => string;
escapeVal: (value: CellValue) => string;
selectedRowIds: Ref<Set<number>> | ComputedRef<Set<number>>;
hasRowSelection: ComputedRef<boolean>;
}
export function useDataGridExport(options: UseDataGridExportOptions) {
@@ -57,6 +59,8 @@ export function useDataGridExport(options: UseDataGridExportOptions) {
getRowItem,
quoteIdent,
escapeVal,
selectedRowIds,
hasRowSelection,
} = options;
function copyText(text: string) {
@@ -94,6 +98,18 @@ export function useDataGridExport(options: UseDataGridExportOptions) {
}
function copyRow() {
if (hasRowSelection.value && selectedRowIds.value.size > 0) {
const items = displayItems.value.filter((item) => selectedRowIds.value.has(item.id));
const objects = items.map((item) => {
const obj: Record<string, unknown> = {};
columns.value.forEach((col, i) => {
obj[col] = item.data[i];
});
return obj;
});
copyText(JSON.stringify(objects, null, 2));
return;
}
if (!contextCell.value) return;
const item = getRowItem(contextCell.value.rowId);
if (!item) return;
@@ -109,6 +125,17 @@ export function useDataGridExport(options: UseDataGridExportOptions) {
? (tableMeta.value.schema ? `${quoteIdent(tableMeta.value.schema)}.` : "") + quoteIdent(tableMeta.value.tableName)
: "table_name";
const cols = columns.value.map((c) => quoteIdent(c)).join(", ");
if (hasRowSelection.value && selectedRowIds.value.size > 0) {
const items = displayItems.value.filter((item) => selectedRowIds.value.has(item.id));
const statements = items.map((item) => {
const vals = item.data.map((v) => escapeVal(v)).join(", ");
return `INSERT INTO ${table} (${cols}) VALUES (${vals});`;
});
copyText(statements.join("\n"));
return;
}
const range = selectedRange.value;
if (range && range.startRow !== range.endRow) {
const items = displayItems.value.slice(range.startRow, range.endRow + 1);
@@ -119,6 +146,7 @@ export function useDataGridExport(options: UseDataGridExportOptions) {
copyText(statements.join("\n"));
return;
}
if (!contextCell.value) return;
const item = getRowItem(contextCell.value.rowId);
if (!item) return;
+70
View File
@@ -37,6 +37,11 @@ export function useDataGridSelection(options: UseDataGridSelectionOptions) {
const selectionFocus = ref<CellPosition | null>(null);
const isSelectingCells = ref(false);
const selectedRowIds = ref<Set<number>>(new Set());
const lastClickedRowIndex = ref<number | null>(null);
const hasRowSelection = computed(() => selectedRowIds.value.size > 0);
const selectedRowCount = computed(() => selectedRowIds.value.size);
const selectedRange = computed<CellSelectionRange | null>(() => {
if (!selectionAnchor.value || !selectionFocus.value) return null;
return normalizeSelectionRange(selectionAnchor.value, selectionFocus.value);
@@ -57,6 +62,11 @@ export function useDataGridSelection(options: UseDataGridSelectionOptions) {
isSelectingCells.value = false;
}
function clearRowSelection() {
selectedRowIds.value = new Set();
lastClickedRowIndex.value = null;
}
function selectSingleCell(rowIndex: number, colIndex: number) {
const cell = { rowIndex, colIndex };
selectionAnchor.value = cell;
@@ -69,6 +79,36 @@ export function useDataGridSelection(options: UseDataGridSelectionOptions) {
selectionFocus.value = { rowIndex, colIndex: columns.value.length - 1 };
}
function handleRowClick(rowIndex: number, rowId: number, event: MouseEvent) {
const isMeta = event.metaKey || event.ctrlKey;
const isShift = event.shiftKey;
clearCellSelection();
if (isMeta) {
const next = new Set(selectedRowIds.value);
if (next.has(rowId)) {
next.delete(rowId);
} else {
next.add(rowId);
}
selectedRowIds.value = next;
lastClickedRowIndex.value = rowIndex;
} else if (isShift && lastClickedRowIndex.value !== null) {
const start = Math.min(lastClickedRowIndex.value, rowIndex);
const end = Math.max(lastClickedRowIndex.value, rowIndex);
const next = new Set(selectedRowIds.value);
for (let i = start; i <= end; i++) {
const item = displayItems.value[i];
if (item) next.add(item.id);
}
selectedRowIds.value = next;
} else {
selectedRowIds.value = new Set([rowId]);
lastClickedRowIndex.value = rowIndex;
}
}
function finishCellSelection() {
isSelectingCells.value = false;
document.removeEventListener("mouseup", finishCellSelection);
@@ -94,6 +134,28 @@ export function useDataGridSelection(options: UseDataGridSelectionOptions) {
selectionFocus.value = { rowIndex, colIndex };
}
function handleDataCellMousedown(rowIndex: number, colIndex: number, rowId: number, event: MouseEvent) {
if (event.button !== 0) return;
if (editingCell.value) return;
const isMeta = event.metaKey || event.ctrlKey;
const isShift = event.shiftKey;
if (isMeta || isShift) {
event.preventDefault();
focusGridWithoutScrolling();
handleRowClick(rowIndex, rowId, event);
return;
}
clearRowSelection();
beginCellSelection(rowIndex, colIndex, event);
}
function isRowSelected(rowId: number): boolean {
return selectedRowIds.value.has(rowId);
}
function cellIsSelected(rowIndex: number, colIndex: number): boolean {
return isCellInSelection(rowIndex, colIndex, selectedRange.value);
}
@@ -120,5 +182,13 @@ export function useDataGridSelection(options: UseDataGridSelectionOptions) {
extendCellSelection,
cellIsSelected,
selectedRangeStart,
selectedRowIds,
lastClickedRowIndex,
hasRowSelection,
selectedRowCount,
clearRowSelection,
handleRowClick,
handleDataCellMousedown,
isRowSelected,
};
}
+7
View File
@@ -286,6 +286,11 @@ export default {
addRow: "Add Row",
cloneRow: "Clone Row",
deleteRow: "Delete Row",
deleteRows: "Delete {count} Rows",
cloneRows: "Clone {count} Rows",
copyRows: "Copy {count} Rows (JSON)",
copyRowsInsert: "Copy {count} Rows as INSERT",
selectedRows: "{count} rows selected",
restoreRow: "Restore Row",
statusClean: "Clean",
statusNew: "New Items",
@@ -800,6 +805,8 @@ export default {
deleteMessage: "This delete operation may be irreversible. Continue?",
deleteConfirm: "Confirm Delete",
deleteRowMessage: "This row will be marked for deletion and removed from the database after saving. Continue?",
deleteRowsMessage:
"These {count} rows will be marked for deletion and removed from the database after saving. Continue?",
deleteRowDetails: "Table: {table}",
deleteRowDetailsNoTable: "Current result row",
redisKeyDetails: "Redis key: {key}",
+7
View File
@@ -287,6 +287,11 @@ export default {
addRow: "Agregar fila",
cloneRow: "Clonar fila",
deleteRow: "Eliminar fila",
deleteRows: "Eliminar {count} filas",
cloneRows: "Clonar {count} filas",
copyRows: "Copiar {count} filas (JSON)",
copyRowsInsert: "Copiar {count} filas como INSERT",
selectedRows: "{count} filas seleccionadas",
restoreRow: "Restaurar fila",
statusClean: "Sin cambios",
statusNew: "Elementos nuevos",
@@ -803,6 +808,8 @@ export default {
deleteConfirm: "Confirmar eliminación",
deleteRowMessage:
"Esta fila quedará marcada para eliminación y se borrará de la base de datos al guardar. ¿Continuar?",
deleteRowsMessage:
"Estas {count} filas se marcarán para eliminación y se eliminarán de la base de datos después de guardar. ¿Continuar?",
deleteRowDetails: "Tabla: {table}",
deleteRowDetailsNoTable: "Fila actual del resultado",
redisKeyDetails: "Clave Redis: {key}",
+6
View File
@@ -285,6 +285,11 @@ export default {
addRow: "新增行",
cloneRow: "克隆行",
deleteRow: "删除行",
deleteRows: "删除 {count} 行",
cloneRows: "克隆 {count} 行",
copyRows: "复制 {count} 行 (JSON)",
copyRowsInsert: "复制 {count} 行为 INSERT",
selectedRows: "已选 {count} 行",
restoreRow: "恢复行",
statusClean: "未改",
statusNew: "新增项",
@@ -786,6 +791,7 @@ export default {
deleteMessage: "此删除操作可能不可逆,确认要继续吗?",
deleteConfirm: "确认删除",
deleteRowMessage: "此行将被标记为删除,保存后会从数据库删除,确认要继续吗?",
deleteRowsMessage: "这 {count} 行将被标记为删除,保存后会从数据库删除,确认要继续吗?",
deleteRowDetails: "数据表:{table}",
deleteRowDetailsNoTable: "当前结果集中的行",
redisKeyDetails: "Redis key:{key}",