From ffde91499a626502473d7d3a88f6df299fabd6dd Mon Sep 17 00:00:00 2001 From: zhangsan Date: Thu, 30 Jul 2026 00:27:54 +0800 Subject: [PATCH] feat(grid): support batch pasting into new rows --- apps/desktop/src/components/grid/DataGrid.vue | 65 ++++- .../__tests__/useDataGridEditor.spec.ts | 222 ++++++++++++++++++ .../src/composables/useDataGridEditor.ts | 64 +++++ apps/desktop/src/i18n/locales/en.ts | 5 + apps/desktop/src/i18n/locales/es.ts | 5 + apps/desktop/src/i18n/locales/it.ts | 5 + apps/desktop/src/i18n/locales/ja.ts | 5 + apps/desktop/src/i18n/locales/pt-BR.ts | 5 + apps/desktop/src/i18n/locales/zh-CN.ts | 5 + apps/desktop/src/i18n/locales/zh-TW.ts | 5 + 10 files changed, 380 insertions(+), 6 deletions(-) create mode 100644 apps/desktop/src/composables/__tests__/useDataGridEditor.spec.ts diff --git a/apps/desktop/src/components/grid/DataGrid.vue b/apps/desktop/src/components/grid/DataGrid.vue index d09670d9e..6c5b4252c 100644 --- a/apps/desktop/src/components/grid/DataGrid.vue +++ b/apps/desktop/src/components/grid/DataGrid.vue @@ -2826,6 +2826,7 @@ const { cancelEdit, onEditKeydown, addRow: addEditorRow, + appendPastedRowsToNewRow, cloneRow, showDeleteRowConfirm, requestDeleteRow, @@ -2854,6 +2855,7 @@ const { previewChanges, } = editor; const pendingQuickEntryDraftCellFocus = ref<{ rowId: number; col: number } | null>(null); +const batchAppendPasteRowId = ref(null); const showSqlPreview = ref(false); const previewSqlText = ref(""); @@ -4819,7 +4821,7 @@ function onCanvasMouseDown(event: MouseEvent) { commitHiddenCanvasEditBeforeCellInteraction(); if (!item) return; if (hit.rowNumber) { - beginRowSelection(item.displayIndex, item.id, event); + onRowNumberMouseDown(item, event); } else { handleDataCellMousedown(item.displayIndex, hit.visibleColIdx, item.id, event); } @@ -5544,7 +5546,51 @@ async function pasteClipboardIntoSelection() { const operation = dataGridResultLifecycle.beginOperation(); const text = await readTextFromClipboard(); if (!dataGridResultLifecycle.isCurrent(operation)) return; - pasteTextIntoSelection(text); + pasteTextIntoGrid(text); +} + +function batchAppendPasteTargetRowId(): number | null { + const rowId = batchAppendPasteRowId.value; + if (rowId === null) return null; + const item = getRowItem(rowId); + if ((!item?.isNew && !item?.isDraft) || item.isDeleted || selectedRowIds.value.size !== 1 || !selectedRowIds.value.has(rowId)) { + batchAppendPasteRowId.value = null; + return null; + } + return rowId; +} + +function canAppendPastedRows(): boolean { + return !!props.editable && batchAppendPasteTargetRowId() !== null; +} + +function batchAppendPasteError(reason: string): string { + const messages: Record = { + "not-editable": "grid.batchAppendPasteNotEditable", + "invalid-target": "grid.batchAppendPasteInvalidTarget", + "target-not-empty": "grid.batchAppendPasteTargetNotEmpty", + "empty-paste": "grid.batchAppendPasteEmpty", + "readonly-column": "grid.batchAppendPasteReadonlyColumn", + }; + return t(messages[reason] ?? "grid.batchAppendPasteInvalidTarget"); +} + +function pasteTextIntoGrid(text: string): boolean { + const targetRowId = batchAppendPasteTargetRowId(); + if (targetRowId !== null) { + const result = appendPastedRowsToNewRow(targetRowId, parseDataGridClipboard(text), visibleColumnIndexes.value); + if (!result.ok) { + if (result.reason === "invalid-target" || result.reason === "target-not-empty") { + batchAppendPasteRowId.value = null; + } + toast(batchAppendPasteError(result.reason), 5000); + return false; + } + batchAppendPasteRowId.value = null; + toast(t("grid.pasted")); + return true; + } + return pasteTextIntoSelection(text); } function pasteTextIntoSelection(text: string): boolean { @@ -5570,12 +5616,12 @@ function pasteTextIntoSelection(text: string): boolean { } function onGridPaste(event: ClipboardEvent) { - const intent = claimDataGridPaste(event, props.editable, !!selectedRange.value || hasColumnSelection.value); + const intent = claimDataGridPaste(event, props.editable, !!selectedRange.value || hasColumnSelection.value || canAppendPastedRows()); if (intent === "native") return; if (intent === "block") return; const text = event.clipboardData?.getData("text/plain"); if (text === undefined) return; - pasteTextIntoSelection(text); + pasteTextIntoGrid(text); } function pasteStartCell() { @@ -5984,7 +6030,13 @@ async function commitEditFromCellBlur() { await commitEditFromBlur(); } +function onRowNumberMouseDown(item: RowItem, event: MouseEvent) { + beginRowSelection(item.displayIndex, item.id, event); + batchAppendPasteRowId.value = item.isNew || item.isDraft ? item.id : null; +} + function prepareDataCellMouseDown(item: RowItem, actualColIdx: number) { + batchAppendPasteRowId.value = null; const editing = editingCell.value; if (editing?.rowId === quickEntryDraftRowId && item.isDraft && item.id === quickEntryDraftRowId && editing.col !== actualColIdx) { pendingQuickEntryDraftCellFocus.value = { rowId: item.id, col: actualColIdx }; @@ -6146,7 +6198,7 @@ async function onGridKeydown(event: KeyboardEvent) { return; } if (clipboardShortcut(event, "v")) { - const intent = claimDataGridPaste(event, props.editable, !!selectedRange.value || hasColumnSelection.value); + const intent = claimDataGridPaste(event, props.editable, !!selectedRange.value || hasColumnSelection.value || canAppendPastedRows()); if (intent === "native") return; // A focused grid owns the shortcut even when read-only; otherwise the webview may paste into the previously focused SQL editor. if (intent === "block") return; @@ -6860,6 +6912,7 @@ watch(editingCell, (cell) => { watch(editValue, scheduleActiveCellEditTextareaResize); function onRowContext(rowId: number, rowIndex: number) { + batchAppendPasteRowId.value = null; contextHeaderColumn.value = null; contextHeaderColumnIndex.value = null; contextHeaderVisibleColIdx.value = null; @@ -8661,7 +8714,7 @@ const gridContextMenuItems = computed(() => {
diff --git a/apps/desktop/src/composables/__tests__/useDataGridEditor.spec.ts b/apps/desktop/src/composables/__tests__/useDataGridEditor.spec.ts new file mode 100644 index 000000000..3bbaa972a --- /dev/null +++ b/apps/desktop/src/composables/__tests__/useDataGridEditor.spec.ts @@ -0,0 +1,222 @@ +import { computed, ref } from "vue"; +import { beforeEach, describe, expect, it, vi } from "vitest"; +import { DATA_GRID_QUICK_ENTRY_DRAFT_ROW_ID, useDataGridEditor } from "@/composables/useDataGridEditor"; +import type { CellValue } from "@/lib/dataGrid/cellValue"; + +const mocks = vi.hoisted(() => ({ + getConfig: vi.fn(), +})); + +vi.mock("@/lib/backend/api", () => ({})); +vi.mock("@/stores/connectionStore", () => ({ + useConnectionStore: () => ({ getConfig: mocks.getConfig }), +})); +vi.mock("@/stores/historyStore", () => ({ + useHistoryStore: () => ({}), +})); +vi.mock("@/stores/productionSafetyStore", () => ({ + useProductionSafetyStore: () => ({}), +})); + +function createEditor(sourceColumns?: Array) { + let editor: ReturnType; + const result = ref<{ columns: string[]; rows: CellValue[][] }>({ + columns: ["first", "hidden", "last"], + rows: [], + }); + + editor = useDataGridEditor({ + result: computed(() => result.value), + editable: computed(() => true), + databaseType: computed(() => "postgres"), + connectionId: computed(() => "connection-1"), + database: computed(() => "app"), + tableMeta: computed(() => ({ + tableName: "people", + columns: [ + { name: "first", data_type: "varchar" }, + { name: "hidden", data_type: "varchar" }, + { name: "last", data_type: "varchar" }, + ], + primaryKeys: [], + })), + sourceColumns: computed(() => sourceColumns), + onExecuteSql: computed(() => undefined), + sql: computed(() => undefined), + searchText: ref(""), + whereFilterInput: ref(""), + currentWhereInput: computed(() => undefined), + orderByInput: ref(""), + rowStatusFilter: ref("all"), + pageSize: ref(100), + currentPage: ref(1), + getRowItem: (rowId) => { + if (rowId === DATA_GRID_QUICK_ENTRY_DRAFT_ROW_ID) { + return { + id: rowId, + data: editor.quickEntryDraftRow.value, + isNew: false, + isDraft: true, + isDeleted: false, + isDirtyCol: [false, false, false], + status: "draft", + }; + } + const newIndex = -rowId - 1; + const row = editor.newRows.value[newIndex]; + if (!row) return undefined; + return { + id: rowId, + newIndex, + data: row, + isNew: true, + isDeleted: false, + isDirtyCol: [false, false, false], + status: "new", + }; + }, + emit: vi.fn(), + }); + + editor.newRows.value = [[null, null, null]]; + return editor; +} + +describe("useDataGridEditor appendPastedRowsToNewRow", () => { + beforeEach(() => { + mocks.getConfig.mockReturnValue({ id: "connection-1", db_type: "postgres" }); + }); + + it("fills the selected blank new row and appends remaining rows using visible columns", () => { + const editor = createEditor(); + + const result = editor.appendPastedRowsToNewRow( + -1, + [ + ["Ada", "Lovelace"], + ["Grace", "Hopper"], + ], + [0, 2], + ); + + expect(result).toEqual({ ok: true, rowCount: 2 }); + expect(editor.newRows.value).toEqual([ + ["Ada", null, "Lovelace"], + ["Grace", null, "Hopper"], + ]); + expect(editor.hasPendingChanges.value).toBe(true); + }); + + it("fills following blank new rows before adding more rows", () => { + const editor = createEditor(); + editor.newRows.value = [ + [null, null, null], + [null, null, null], + ]; + + const result = editor.appendPastedRowsToNewRow(-1, [["Ada"], ["Grace"]], [0, 2]); + + expect(result).toEqual({ ok: true, rowCount: 2 }); + expect(editor.newRows.value).toEqual([ + ["Ada", null, null], + ["Grace", null, null], + ]); + }); + + it("turns rows pasted into the terminal new-row draft into pending rows", () => { + const editor = createEditor(); + editor.newRows.value = []; + + const result = editor.appendPastedRowsToNewRow( + DATA_GRID_QUICK_ENTRY_DRAFT_ROW_ID, + [ + ["Ada", "Lovelace"], + ["Grace", "Hopper"], + ], + [0, 2], + ); + + expect(result).toEqual({ ok: true, rowCount: 2 }); + expect(editor.newRows.value).toEqual([ + ["Ada", null, "Lovelace"], + ["Grace", null, "Hopper"], + ]); + expect(editor.quickEntryDraftRow.value).toEqual([null, null, null]); + expect(editor.hasPendingChanges.value).toBe(true); + + editor.undoPendingChange(); + expect(editor.newRows.value).toEqual([]); + expect(editor.quickEntryDraftRow.value).toEqual([null, null, null]); + + editor.redoPendingChange(); + expect(editor.newRows.value).toEqual([ + ["Ada", null, "Lovelace"], + ["Grace", null, "Hopper"], + ]); + }); + + it("rejects a non-empty terminal new-row draft", () => { + const editor = createEditor(); + editor.newRows.value = []; + editor.quickEntryDraftRow.value = ["already", null, null]; + + const result = editor.appendPastedRowsToNewRow(DATA_GRID_QUICK_ENTRY_DRAFT_ROW_ID, [["Ada"]], [0, 2]); + + expect(result).toEqual({ ok: false, reason: "target-not-empty" }); + expect(editor.newRows.value).toEqual([]); + expect(editor.quickEntryDraftRow.value).toEqual(["already", null, null]); + }); + + it("truncates pasted columns that exceed the visible table columns", () => { + const editor = createEditor(); + + const result = editor.appendPastedRowsToNewRow(-1, [["Ada", "Byron", "Lovelace"]], [0, 2]); + + expect(result).toEqual({ ok: true, rowCount: 1 }); + expect(editor.newRows.value).toEqual([["Ada", null, "Byron"]]); + expect(editor.canUndoPendingChange.value).toBe(true); + }); + + it("rejects an empty textual clipboard payload without changing pending rows", () => { + const editor = createEditor(); + + const result = editor.appendPastedRowsToNewRow(-1, [[""]], [0, 2]); + + expect(result).toEqual({ ok: false, reason: "empty-paste" }); + expect(editor.newRows.value).toEqual([[null, null, null]]); + expect(editor.canUndoPendingChange.value).toBe(false); + }); + + it("rejects a paste that targets a read-only visible column", () => { + const editor = createEditor(["first", undefined, "last"]); + + const result = editor.appendPastedRowsToNewRow(-1, [["Ada"]], [1]); + + expect(result).toEqual({ ok: false, reason: "readonly-column" }); + expect(editor.newRows.value).toEqual([[null, null, null]]); + }); + + it("does not overwrite an existing new row selected as the append target", () => { + const editor = createEditor(); + editor.newRows.value = [["already", null, null]]; + + const result = editor.appendPastedRowsToNewRow(-1, [["Ada"]], [0, 2]); + + expect(result).toEqual({ ok: false, reason: "target-not-empty" }); + expect(editor.newRows.value).toEqual([["already", null, null]]); + }); + + it("treats a batch append as one undoable change", () => { + const editor = createEditor(); + + editor.appendPastedRowsToNewRow(-1, [["Ada"], ["Grace"]], [0, 2]); + editor.undoPendingChange(); + expect(editor.newRows.value).toEqual([[null, null, null]]); + + editor.redoPendingChange(); + expect(editor.newRows.value).toEqual([ + ["Ada", null, null], + ["Grace", null, null], + ]); + }); +}); diff --git a/apps/desktop/src/composables/useDataGridEditor.ts b/apps/desktop/src/composables/useDataGridEditor.ts index 0c0ba5aeb..4e93626aa 100644 --- a/apps/desktop/src/composables/useDataGridEditor.ts +++ b/apps/desktop/src/composables/useDataGridEditor.ts @@ -31,6 +31,8 @@ export const DATA_GRID_QUICK_ENTRY_DRAFT_ROW_ID = Number.MIN_SAFE_INTEGER; type RowKind = "none" | "existing" | "new" | "draft"; +export type DataGridAppendPastedRowsResult = { ok: true; rowCount: number } | { ok: false; reason: "not-editable" | "invalid-target" | "target-not-empty" | "empty-paste" | "readonly-column" }; + type CommitEditResult = | { changed: false; @@ -944,6 +946,67 @@ export function useDataGridEditor(options: UseDataGridEditorOptions) { }); } + function isBlankNewRow(row: readonly CellValue[]): boolean { + return row.every((value) => value === null || (typeof value === "string" && value.trim() === "")); + } + + function appendPastedRowsToNewRow(targetRowId: number, pastedRows: readonly (readonly (string | null)[])[], columnIndexes: readonly number[]): DataGridAppendPastedRowsResult { + if (!editable.value) return { ok: false, reason: "not-editable" }; + if (pastedRows.every((row) => row.every((value) => value === ""))) { + return { ok: false, reason: "empty-paste" }; + } + + const target = getRowItem(targetRowId); + if ((!target?.isNew && !target?.isDraft) || target.isDeleted || isSavingNewRow(target)) { + return { ok: false, reason: "invalid-target" }; + } + + const targetIsDraft = target.isDraft === true; + if (targetIsDraft) ensureQuickEntryDraftRow(); + const targetNewIndex = target.newIndex; + const targetRow = targetIsDraft ? quickEntryDraftRow.value : targetNewIndex === undefined ? undefined : newRows.value[targetNewIndex]; + if (!targetRow || !isBlankNewRow(targetRow)) return { ok: false, reason: "target-not-empty" }; + + const pastedColumnCount = Math.max(...pastedRows.map((row) => row.length)); + if (pastedColumnCount <= 0) return { ok: false, reason: "empty-paste" }; + + const targetColumns = columnIndexes.slice(0, pastedColumnCount); + if (targetColumns.some((columnIndex) => !canEditColumn(columnIndex))) return { ok: false, reason: "readonly-column" }; + + const nextRows = newRows.value.map((row) => [...row]); + let reusableNewRowCount = 0; + if (!targetIsDraft) { + for (let rowIndex = targetNewIndex!; rowIndex < nextRows.length && reusableNewRowCount < pastedRows.length; rowIndex++) { + if (!isBlankNewRow(nextRows[rowIndex]!)) break; + reusableNewRowCount++; + } + } + const mappedRows = pastedRows.map((pastedRow, rowIndex) => { + const nextRow = rowIndex < reusableNewRowCount ? nextRows[targetNewIndex! + rowIndex]! : emptyDraftRow(); + for (let columnOffset = 0; columnOffset < Math.min(pastedRow.length, targetColumns.length); columnOffset++) { + const columnIndex = targetColumns[columnOffset]!; + const value = pastedRow[columnOffset]; + nextRow[columnIndex] = value === null ? null : coerceCellValue(value, nextRow[columnIndex], columnIndex); + } + return nextRow; + }); + + pushUndoSnapshot(); + if (targetIsDraft) { + nextRows.push(...mappedRows); + quickEntryDraftRow.value = emptyDraftRow(); + } else { + nextRows.splice(targetNewIndex!, reusableNewRowCount, ...mappedRows); + } + newRows.value = nextRows; + rowStatusFilter.value = rowStatusFilterAfterAddingRow(rowStatusFilter.value); + touchPendingChanges(); + if (useTransaction.value && !transactionActive.value) { + enterTransaction(); + } + return { ok: true, rowCount: mappedRows.length }; + } + function clonedRowData(item: RowItem): CellValue[] { const columnInfoByName = new Map((tableMeta.value?.columns ?? []).map((column) => [column.name.toLowerCase(), column])); return item.data.map((val, i) => { @@ -1566,6 +1629,7 @@ export function useDataGridEditor(options: UseDataGridEditorOptions) { cancelEdit, onEditKeydown, addRow, + appendPastedRowsToNewRow, cloneRow, cloneRows, applyDeleteRows, diff --git a/apps/desktop/src/i18n/locales/en.ts b/apps/desktop/src/i18n/locales/en.ts index a59ca9ae2..9aeb41447 100644 --- a/apps/desktop/src/i18n/locales/en.ts +++ b/apps/desktop/src/i18n/locales/en.ts @@ -1227,6 +1227,11 @@ export default { freezeSelectedColumns: "Freeze Selected Columns", unfreezeColumns: "Unfreeze Columns", pasted: "Pasted!", + batchAppendPasteNotEditable: "This result cannot be edited.", + batchAppendPasteInvalidTarget: "Select the row number of an empty new row before pasting.", + batchAppendPasteTargetNotEmpty: "The selected new row already contains data.", + batchAppendPasteEmpty: "The clipboard does not contain rows to add.", + batchAppendPasteReadonlyColumn: "The pasted data includes a column that cannot be edited.", search: "Search...", searchOrWhere: "Search, or enter a WHERE clause...", applyWhere: "Apply WHERE", diff --git a/apps/desktop/src/i18n/locales/es.ts b/apps/desktop/src/i18n/locales/es.ts index d62b6c87e..51df5f418 100644 --- a/apps/desktop/src/i18n/locales/es.ts +++ b/apps/desktop/src/i18n/locales/es.ts @@ -1343,6 +1343,11 @@ export default withEnglishFallback({ numericColumnAlign: "Alineación de columna numérica", numericColumnAlignLeft: "Alineación izquierda", numericColumnAlignRight: "Alineación derecha", + batchAppendPasteNotEditable: "El resultado actual no se puede editar.", + batchAppendPasteInvalidTarget: "Primero seleccione una celda de número de fila de una nueva fila vacía y luego pegue.", + batchAppendPasteTargetNotEmpty: "La nueva fila seleccionada ya contiene datos.", + batchAppendPasteEmpty: "No hay filas nuevas en el portapapeles.", + batchAppendPasteReadonlyColumn: "Los datos pegados contienen columnas no editables.", xlsxHeaderTitle: "Formato de encabezado", xlsxHeaderPrompt: "Seleccione el formato de encabezado al exportar a Excel:", xlsxHeaderOriginal: "Encabezado usando nombres de campos", diff --git a/apps/desktop/src/i18n/locales/it.ts b/apps/desktop/src/i18n/locales/it.ts index 9fed14dc0..b7d7b6931 100644 --- a/apps/desktop/src/i18n/locales/it.ts +++ b/apps/desktop/src/i18n/locales/it.ts @@ -1341,6 +1341,11 @@ export default withEnglishFallback({ numericColumnAlign: "Allineamento colonna numerica", numericColumnAlignLeft: "Allineamento a sinistra", numericColumnAlignRight: "Allineamento a destra", + batchAppendPasteNotEditable: "Il risultato corrente non è modificabile.", + batchAppendPasteInvalidTarget: "Seleziona prima una cella vuota della riga nuova, poi incolla.", + batchAppendPasteTargetNotEmpty: "La riga nuova selezionata contiene già dati.", + batchAppendPasteEmpty: "Negli appunti non ci sono righe da aggiungere.", + batchAppendPasteReadonlyColumn: "I dati incollati contengono colonne non modificabili.", xlsxHeaderTitle: "Formato intestazione", xlsxHeaderPrompt: "Seleziona il formato dell'intestazione da utilizzare per l'esportazione in Excel:", xlsxHeaderOriginal: "Intestazione con nome campo", diff --git a/apps/desktop/src/i18n/locales/ja.ts b/apps/desktop/src/i18n/locales/ja.ts index 7f0159eb4..e70272e1e 100644 --- a/apps/desktop/src/i18n/locales/ja.ts +++ b/apps/desktop/src/i18n/locales/ja.ts @@ -1342,6 +1342,11 @@ export default withEnglishFallback({ numericColumnAlign: "数値列の配置", numericColumnAlignLeft: "左揃え", numericColumnAlignRight: "右揃え", + batchAppendPasteNotEditable: "現在の結果は編集できません。", + batchAppendPasteInvalidTarget: "空の新規行の行番号セルを選択してから貼り付けてください。", + batchAppendPasteTargetNotEmpty: "選択した新規行には既にデータが含まれています。", + batchAppendPasteEmpty: "クリップボードに追加可能な行がありません。", + batchAppendPasteReadonlyColumn: "貼り付けたデータに編集不可能な列が含まれています。", xlsxHeaderTitle: "ヘッダー形式", xlsxHeaderPrompt: "Excel エクスポート時に使用するヘッダー形式を選択してください:", xlsxHeaderOriginal: "ヘッダーにフィールド名を使用", diff --git a/apps/desktop/src/i18n/locales/pt-BR.ts b/apps/desktop/src/i18n/locales/pt-BR.ts index 41d96d117..83d6d8824 100644 --- a/apps/desktop/src/i18n/locales/pt-BR.ts +++ b/apps/desktop/src/i18n/locales/pt-BR.ts @@ -1343,6 +1343,11 @@ export default withEnglishFallback({ numericColumnAlign: "Alinhamento de colunas numéricas", numericColumnAlignLeft: "Alinhamento à esquerda", numericColumnAlignRight: "Alinhamento à direita", + batchAppendPasteNotEditable: "O resultado atual não é editável.", + batchAppendPasteInvalidTarget: "Selecione primeiro o número da linha de uma nova linha vazia e depois cole.", + batchAppendPasteTargetNotEmpty: "A nova linha selecionada já contém dados.", + batchAppendPasteEmpty: "Não há linhas para adicionar na área de transferência.", + batchAppendPasteReadonlyColumn: "Os dados colados contêm colunas não editáveis.", xlsxHeaderTitle: "Formato do cabeçalho", xlsxHeaderPrompt: "Selecione o formato do cabeçalho a ser usado ao exportar Excel:", xlsxHeaderOriginal: "Cabeçalho usa nome do campo", diff --git a/apps/desktop/src/i18n/locales/zh-CN.ts b/apps/desktop/src/i18n/locales/zh-CN.ts index 1488dc821..943f704cf 100644 --- a/apps/desktop/src/i18n/locales/zh-CN.ts +++ b/apps/desktop/src/i18n/locales/zh-CN.ts @@ -1228,6 +1228,11 @@ export default withEnglishFallback({ freezeSelectedColumns: "冻结选中列", unfreezeColumns: "取消冻结", pasted: "已粘贴!", + batchAppendPasteNotEditable: "当前结果不可编辑。", + batchAppendPasteInvalidTarget: "请先选中一个空新增行的行号单元格,再进行粘贴。", + batchAppendPasteTargetNotEmpty: "选中的新增行已包含数据。", + batchAppendPasteEmpty: "剪贴板中没有可新增的行。", + batchAppendPasteReadonlyColumn: "粘贴的数据包含不可编辑的列。", search: "搜索...", searchOrWhere: "搜索,或输入 WHERE 条件...", applyWhere: "应用 WHERE", diff --git a/apps/desktop/src/i18n/locales/zh-TW.ts b/apps/desktop/src/i18n/locales/zh-TW.ts index 2934d6cad..a0716e911 100644 --- a/apps/desktop/src/i18n/locales/zh-TW.ts +++ b/apps/desktop/src/i18n/locales/zh-TW.ts @@ -1342,6 +1342,11 @@ export default withEnglishFallback({ numericColumnAlign: "數值列對齊", numericColumnAlignLeft: "左對齊", numericColumnAlignRight: "右對齊", + batchAppendPasteNotEditable: "目前結果不可編輯。", + batchAppendPasteInvalidTarget: "請先選取一個空白新增行的行號儲存格,再進行貼上。", + batchAppendPasteTargetNotEmpty: "選取的新增行已包含資料。", + batchAppendPasteEmpty: "剪貼簿中沒有可新增的行。", + batchAppendPasteReadonlyColumn: "貼上的資料包含不可編輯的列。", xlsxHeaderTitle: "表頭格式", xlsxHeaderPrompt: "請選擇匯出 Excel 時使用的表頭格式:", xlsxHeaderOriginal: "表頭使用欄位名稱",