feat(transfer): batch-select transfer objects from a pasted name list

This commit is contained in:
高桥凉介 authored and GitHub committed 2026-09-23 19:24:44 +08:00
1 parent 642651f97a
commit 8cfac35990
16 files changed
+605 -7

No files matched your search

@@ -142,6 +142,10 @@ const showCrossFamilyViewHint = computed(() => {
if (!allowed.includes("VIEW")) return false;
return !isSameTransferFamily(transferDatabaseTypeForConnection(sourceConfig), transferDatabaseTypeForConnection(targetConfig)) && (selectedObjects.value.VIEW?.size ?? 0) > 0;
});
// 「批量录入」允许的 schema.table / db.table 前缀:取当前源已选的 catalog / 库 / schema,
// 便于用户从其他工具粘贴带前缀的对象名;全为空时前端退化为只按对象名匹配。
const objectQualifiers = computed(() => [sourceCatalog.value, sourceDatabase.value, sourceSchema.value].filter((value) => value.trim().length > 0));
const pendingSourceSchemaPrefill = ref("");
const pendingSelectedTablesPrefill = ref<string[] | null>(null);
// Pending object selection for saved-task loading (covers all object kinds,
@@ -1320,7 +1324,7 @@ async function saveConfigTask() {
<div v-if="(!loadingObjects && !sourceConnectionId) || !sourceDatabase" class="text-xs text-muted-foreground py-4 text-center">
{{ t("transfer.selectSourceFirst") }}
</div>
<ObjectSelectionTree v-model="treeSelection" :groups="treeGroups" :disabled-groups="treeDisabledGroups" :disabled-hints="treeDisabledHints" v-model:search="objectSearch" :loading="loadingObjects" class="min-h-0 flex-1" />
<ObjectSelectionTree v-model="treeSelection" :groups="treeGroups" :disabled-groups="treeDisabledGroups" :disabled-hints="treeDisabledHints" :qualifiers="objectQualifiers" v-model:search="objectSearch" :loading="loadingObjects" class="min-h-0 flex-1" />
<div v-if="showCrossFamilyViewHint" class="mt-1.5 rounded-md border border-amber-300/40 bg-amber-50 px-2 py-1.5 text-xs text-amber-700">
{{ t("transfer.crossFamilyViewHint") }}
</div>
@@ -4,7 +4,8 @@ import { useI18n } from "vue-i18n";
import { RecycleScroller } from "vue-virtual-scroller";
import "vue-virtual-scroller/dist/vue-virtual-scroller.css";
import type { TransferObjectKind } from "@/lib/backend/api";
import { Square, CheckSquare, MinusSquare, Search, ChevronRight } from "@lucide/vue";
import { Square, CheckSquare, MinusSquare, Search, ChevronRight, X } from "@lucide/vue";
import { matchBulkObjectNames } from "./transferSelections";
const { t } = useI18n();
@@ -29,6 +30,11 @@ const props = defineProps<{
modelValue: Record<string, string[]>;
search?: string;
loading?: boolean;
/**
* 「批量录入」可接受的名称前缀(当前源的 catalog / 库 / schema)。
* 用于识别粘贴内容里的 schema.table 前缀,留空时放宽为只按对象名匹配。
*/
qualifiers?: string[];
}>();
const emit = defineEmits<{
@@ -215,6 +221,54 @@ function deselectAll() {
emit("update:modelValue", next);
}
// 「批量录入」面板状态:showBulkInput 控制面板展开,bulkText 存放粘贴内容,
// bulkFeedback 保存最近一次匹配结果,用于反馈「已勾选多少项 / 哪些名称未匹配」。
const showBulkInput = ref(false);
const bulkText = ref("");
const bulkFeedback = ref<{ matchedCount: number; unmatchedNames: string[] } | null>(null);
function toggleBulkInput() {
showBulkInput.value = !showBulkInput.value;
if (showBulkInput.value) {
// 每次重新打开都清空上一次的输入与结果,避免误用旧文本
bulkText.value = "";
bulkFeedback.value = null;
}
}
/**
* 执行批量勾选。匹配始终基于完整对象清单(不受当前搜索过滤影响),
* 结果与已有选择合并(只追加勾选,不清除手工勾选或被搜索隐藏的选项)。
*/
function applyBulkSelection() {
const result = matchBulkObjectNames(bulkText.value, props.groups, props.disabledGroups, props.qualifiers ?? []);
if (result.matchedCount > 0) {
const next: Record<string, string[]> = { ...props.modelValue };
for (const [kind, names] of Object.entries(result.matched)) {
if (names.length === 0) continue;
next[kind] = [...new Set([...(next[kind] ?? []), ...names])];
}
emit("update:modelValue", next);
revealMatchedGroups(result.matched);
}
bulkFeedback.value = { matchedCount: result.matchedCount, unmatchedNames: result.unmatchedNames };
}
// 批量勾选后展开命中的分组:大清单分组默认折叠,展开后用户才能看到勾选结果
function revealMatchedGroups(matched: Record<string, string[]>) {
const next = new Set(expanded.value);
let changed = false;
for (const [kind, names] of Object.entries(matched)) {
if (names.length === 0 || next.has(kind)) continue;
next.add(kind);
changed = true;
}
if (changed) expanded.value = next;
}
// 未匹配名称拼成一行展示,并用 title 提供完整内容(列表可能被截断)
const bulkUnmatchedNames = computed(() => bulkFeedback.value?.unmatchedNames.join(", ") ?? "");
// The bulk button flips between “select all” and “deselect all”. It shows
// “deselect all” only when every visible, enabled item is already selected
// (selecting more is then a no-op); otherwise “select all” is shown so the
@@ -261,6 +315,9 @@ function groupSelectionState(kind: TransferObjectKind, items: string[]): "none"
class="pl-8 pr-2.5 h-8 w-full rounded-md border border-input bg-transparent text-xs outline-none focus-visible:ring-1 focus-visible:ring-ring focus-visible:border-ring transition-colors placeholder:text-muted-foreground/70"
/>
</div>
<Button data-test="bulk-open" variant="outline" size="sm" @click="toggleBulkInput">
{{ t("transfer.bulkSelectObjects") }}
</Button>
<Button v-if="allVisibleEnabledSelected" variant="outline" size="sm" @click="deselectAll">
{{ t("transfer.deselectAll") }}
</Button>
@@ -269,6 +326,42 @@ function groupSelectionState(kind: TransferObjectKind, items: string[]): "none"
</Button>
</div>
<!-- 批量录入面板:粘贴多个对象名,一键勾选清单里匹配到的项。
放在列表上方(而不是浮层)以避免与传输对话框的滚动/层级互相干扰。 -->
<div v-if="showBulkInput" data-test="bulk-panel" class="shrink-0 rounded-md border border-border/80 bg-muted/10 p-2">
<div class="mb-1 flex items-center gap-2">
<span class="flex-1 text-xs font-semibold text-foreground/90">{{ t("transfer.bulkSelectTitle") }}</span>
<button data-test="bulk-close" type="button" class="shrink-0 p-0.5 text-muted-foreground hover:text-foreground" @click="toggleBulkInput">
<X class="h-3.5 w-3.5" />
</button>
</div>
<p class="mb-1.5 text-[11px] leading-snug text-muted-foreground">{{ t("transfer.bulkSelectHint") }}</p>
<textarea
data-test="bulk-input"
v-model="bulkText"
rows="4"
:placeholder="t('transfer.bulkSelectPlaceholder')"
autocapitalize="off"
autocomplete="off"
autocorrect="off"
spellcheck="false"
class="mb-1.5 min-h-16 w-full resize-y rounded-md border border-input bg-transparent px-2 py-1.5 font-mono text-xs outline-none transition-colors focus-visible:border-ring focus-visible:ring-1 focus-visible:ring-ring placeholder:text-muted-foreground/70"
/>
<div class="flex items-end gap-2">
<div data-test="bulk-feedback" class="min-w-0 flex-1 text-[11px] leading-snug">
<p v-if="bulkFeedback && bulkFeedback.matchedCount > 0" class="text-foreground/80">
{{ t("transfer.bulkSelectMatched", { count: bulkFeedback.matchedCount }) }}
</p>
<p v-if="bulkFeedback && bulkFeedback.unmatchedNames.length > 0" class="truncate text-amber-700" :title="bulkUnmatchedNames">
{{ t("transfer.bulkSelectUnmatched", { count: bulkFeedback.unmatchedNames.length, names: bulkUnmatchedNames }) }}
</p>
</div>
<Button data-test="bulk-confirm" variant="outline" size="sm" class="shrink-0" @click="applyBulkSelection">
{{ t("transfer.bulkSelectConfirm") }}
</Button>
</div>
</div>
<div v-if="loading" class="py-4 text-center text-sm text-muted-foreground">
{{ t("common.loading") }}
</div>
@@ -306,7 +399,7 @@ function groupSelectionState(kind: TransferObjectKind, items: string[]): "none"
<input type="checkbox" class="h-3.5 w-3.5" :checked="selectedSet(group.kind).has(item)" :disabled="isGroupDisabled(group.kind)" @change="toggleItem(group.kind, item)" />
<span class="truncate text-foreground/80">{{ item }}</span>
</label>
<div v-if="group.items.length === 0" class="px-1 py-1 text-xs text-muted-foreground">无匹配</div>
<div v-if="group.items.length === 0" class="px-1 py-1 text-xs text-muted-foreground">{{ t("transfer.noMatchingObjects") }}</div>
</div>
<div v-else class="pl-6 pr-1 mt-1" :style="{ height: `${OBJECT_LIST_HEIGHT}px` }">
<RecycleScroller v-slot="{ item }" class="h-full" :items="group.items" :item-size="OBJECT_ITEM_HEIGHT" :buffer="OBJECT_LIST_BUFFER">
@@ -22,6 +22,7 @@ vi.mock("@lucide/vue", () => {
MinusSquare: Icon,
Search: Icon,
ChevronRight: Icon,
X: Icon,
};
});
vi.mock("vue-virtual-scroller", () => ({
@@ -67,8 +68,8 @@ const LARGE_TABLES: TreeGroup = {
items: Array.from({ length: 40_000 }, (_, index) => (index === 20_000 ? "customer_order_special" : `table_${String(index).padStart(5, "0")}`)),
};
function mountTree(init: { groups?: TreeGroup[]; disabledGroups?: Kind[]; disabledHints?: Record<string, string>; selection?: Record<string, string[]>; search?: string }) {
const { groups = [VIEWS, FUNCTIONS], disabledGroups = [], disabledHints = {}, selection = {}, search = "" } = init;
function mountTree(init: { groups?: TreeGroup[]; disabledGroups?: Kind[]; disabledHints?: Record<string, string>; selection?: Record<string, string[]>; search?: string; qualifiers?: string[] }) {
const { groups = [VIEWS, FUNCTIONS], disabledGroups = [], disabledHints = {}, selection = {}, search = "", qualifiers } = init;
const state = reactive({ selection, search, groups });
const Wrapper = defineComponent({
setup() {
@@ -85,6 +86,7 @@ function mountTree(init: { groups?: TreeGroup[]; disabledGroups?: Kind[]; disabl
"onUpdate:search": (v: string) => {
state.search = v;
},
qualifiers,
});
},
});
@@ -216,7 +218,7 @@ describe("ObjectSelectionTree interaction", () => {
expect(container.querySelector('label[data-test="item-FUNCTION-box"]')).toBeNull();
expect(container.querySelector('label[data-test="item-SEQUENCE-biz_banner_id_seq"]')).not.toBeNull();
expect(container.querySelector('[data-test="group-FUNCTION"]')?.textContent).toContain("无匹配");
expect(container.querySelector('[data-test="group-FUNCTION"]')?.textContent).toContain("transfer.noMatchingObjects");
});
it("search ranks exact-prefix matches before mid-name matches", async () => {
@@ -374,3 +376,143 @@ describe("ObjectSelectionTree interaction", () => {
expect(groupToggle(container).dataset.state).toBe("all");
});
});
async function openBulkPanel(container: HTMLElement) {
container.querySelector<HTMLButtonElement>('button[data-test="bulk-open"]')!.click();
await nextTick();
}
async function fillBulkInput(container: HTMLElement, value: string) {
const textarea = container.querySelector<HTMLTextAreaElement>('textarea[data-test="bulk-input"]')!;
textarea.value = value;
textarea.dispatchEvent(new Event("input"));
await nextTick();
}
async function confirmBulkSelection(container: HTMLElement) {
container.querySelector<HTMLButtonElement>('button[data-test="bulk-confirm"]')!.click();
await nextTick();
}
function bulkFeedbackText(container: HTMLElement): string {
return container.querySelector('[data-test="bulk-feedback"]')?.textContent ?? "";
}
// i18n 在测试里被 mock 成直接返回 key,因此未匹配的名称通过 title 属性断言
function bulkUnmatchedTitle(container: HTMLElement): string {
return container.querySelector('[data-test="bulk-feedback"] p[title]')?.getAttribute("title") ?? "";
}
describe("ObjectSelectionTree bulk input", () => {
it("stays collapsed until the toolbar button is clicked", async () => {
const { container, app } = mountTree({});
cleanup = () => app.unmount();
expect(container.querySelector('[data-test="bulk-panel"]')).toBeNull();
await openBulkPanel(container);
await nextTick();
expect(container.querySelector('[data-test="bulk-panel"]')).not.toBeNull();
expect(container.querySelector('textarea[data-test="bulk-input"]')).not.toBeNull();
container.querySelector<HTMLButtonElement>('button[data-test="bulk-close"]')!.click();
await nextTick();
expect(container.querySelector('[data-test="bulk-panel"]')).toBeNull();
});
it("selects the pasted names across groups and reports the match count", async () => {
const { state, container, app } = mountTree({ groups: [TABLES, VIEWS] });
cleanup = () => app.unmount();
await openBulkPanel(container);
await fillBulkInput(container, "T1\nv3, v1");
await confirmBulkSelection(container);
expect(state.selection.TABLE).toEqual(["t1"]);
expect(state.selection.VIEW).toEqual(["v1", "v3"]);
expect(bulkFeedbackText(container)).toContain("transfer.bulkSelectMatched");
});
it("merges the matches with the existing selection", async () => {
const { state, container, app } = mountTree({ selection: { VIEW: ["v1"] } });
cleanup = () => app.unmount();
await openBulkPanel(container);
await fillBulkInput(container, "v2");
await confirmBulkSelection(container);
expect(state.selection.VIEW).toEqual(["v1", "v2"]);
});
it("reports names that are missing from the catalog", async () => {
const { state, container, app } = mountTree({ groups: [TABLES, VIEWS] });
cleanup = () => app.unmount();
await openBulkPanel(container);
await fillBulkInput(container, "t1\nmissing_table");
await confirmBulkSelection(container);
expect(state.selection.TABLE).toEqual(["t1"]);
expect(bulkFeedbackText(container)).toContain("transfer.bulkSelectUnmatched");
expect(bulkUnmatchedTitle(container)).toContain("missing_table");
});
it("matches the full list even while a search filter is active", async () => {
const { state, container, app } = mountTree({ groups: [TABLES, VIEWS] });
cleanup = () => app.unmount();
await typeSearch(container, "v1");
await openBulkPanel(container);
await fillBulkInput(container, "t2");
await confirmBulkSelection(container);
expect(state.selection.TABLE).toEqual(["t2"]);
});
it("accepts schema-qualified names only for the current schema", async () => {
const { state, container, app } = mountTree({ groups: [TABLES, VIEWS], qualifiers: ["public"] });
cleanup = () => app.unmount();
await openBulkPanel(container);
await fillBulkInput(container, "public.t1\nother.t2");
await confirmBulkSelection(container);
expect(state.selection.TABLE).toEqual(["t1"]);
expect(bulkUnmatchedTitle(container)).toContain("other.t2");
});
it("skips disabled groups", async () => {
const { state, container, app } = mountTree({ groups: [TABLES, VIEWS], disabledGroups: ["VIEW"] });
cleanup = () => app.unmount();
await openBulkPanel(container);
await fillBulkInput(container, "t1\nv1");
await confirmBulkSelection(container);
expect(state.selection.TABLE).toEqual(["t1"]);
expect(state.selection.VIEW ?? []).toEqual([]);
});
it("expands the group that received matches", async () => {
const { state, container, app } = mountTree({ groups: [LARGE_TABLES] });
cleanup = () => app.unmount();
// 大清单默认折叠,批量勾选后应自动展开,否则用户看不到勾选结果
expect(container.querySelectorAll('label[data-test^="item-TABLE-"]')).toHaveLength(0);
await openBulkPanel(container);
await fillBulkInput(container, "customer_order_special");
await confirmBulkSelection(container);
expect(state.selection.TABLE).toEqual(["customer_order_special"]);
expect(container.querySelectorAll('label[data-test^="item-TABLE-"]').length).toBeGreaterThan(0);
});
it("clears the previous input and feedback when reopened", async () => {
const { container, app } = mountTree({ groups: [TABLES, VIEWS] });
cleanup = () => app.unmount();
await openBulkPanel(container);
await fillBulkInput(container, "t1");
await confirmBulkSelection(container);
expect(bulkFeedbackText(container)).toContain("transfer.bulkSelectMatched");
await openBulkPanel(container);
await nextTick();
await openBulkPanel(container);
await nextTick();
expect(container.querySelector<HTMLTextAreaElement>('textarea[data-test="bulk-input"]')!.value).toBe("");
expect(bulkFeedbackText(container)).toBe("");
});
});
@@ -1,5 +1,5 @@
import { describe, expect, it } from "vitest";
import { buildTransferObjectSelections } from "../transferSelections";
import { buildTransferObjectSelections, matchBulkObjectNames, parseBulkObjectNames } from "../transferSelections";
function setOf(names: string[]): Set<string> {
return new Set(names);
@@ -42,3 +42,77 @@ describe("buildTransferObjectSelections", () => {
expect(buildTransferObjectSelections({}, [])).toEqual([]);
});
});
describe("parseBulkObjectNames", () => {
it("splits on newlines, commas, semicolons and tabs, dropping blanks", () => {
expect(parseBulkObjectNames("orders, users;\npayments\t\tlogs\n\n ")).toEqual(["orders", "users", "payments", "logs"]);
});
it("returns an empty list for blank input", () => {
expect(parseBulkObjectNames(" \n\t ")).toEqual([]);
});
});
describe("matchBulkObjectNames", () => {
const groups = [
{ kind: "TABLE", items: ["orders", "users", "Order_Items"] },
{ kind: "VIEW", items: ["v_orders"] },
];
it("matches case-insensitively and ignores surrounding quotes", () => {
const result = matchBulkObjectNames('"ORDERS"\n`users`', groups);
expect(result.matched).toEqual({ TABLE: ["orders", "users"] });
expect(result.matchedCount).toBe(2);
expect(result.unmatchedNames).toEqual([]);
});
it("keeps the catalog spelling of matched items", () => {
expect(matchBulkObjectNames("order_items", groups).matched).toEqual({ TABLE: ["Order_Items"] });
});
it("collects names missing from the catalog", () => {
const result = matchBulkObjectNames("orders, missing_table", groups);
expect(result.matchedCount).toBe(1);
expect(result.unmatchedNames).toEqual(["missing_table"]);
});
it("deduplicates repeated names across separators and case", () => {
const result = matchBulkObjectNames("orders\nORDERS, orders", groups);
expect(result.matched).toEqual({ TABLE: ["orders"] });
expect(result.matchedCount).toBe(1);
expect(result.unmatchedNames).toEqual([]);
});
it("accepts schema-qualified names for the current schema only", () => {
const result = matchBulkObjectNames("public.orders\nother.orders\ncatalog.public.users", groups, [], ["public"]);
expect(result.matched).toEqual({ TABLE: ["orders", "users"] });
expect(result.unmatchedNames).toEqual(["other.orders"]);
});
it("falls back to plain name matching when no qualifier is available", () => {
expect(matchBulkObjectNames("public.orders", groups).matched).toEqual({ TABLE: ["orders"] });
});
it("treats a fully quoted dotted name as a literal name", () => {
const result = matchBulkObjectNames('"my.table"', [{ kind: "TABLE", items: ["my.table"] }]);
expect(result.matched).toEqual({ TABLE: ["my.table"] });
});
it("skips disabled object kinds and reports their names as unmatched", () => {
const result = matchBulkObjectNames("orders\nv_orders", groups, ["VIEW"]);
expect(result.matched).toEqual({ TABLE: ["orders"] });
expect(result.unmatchedNames).toEqual(["v_orders"]);
});
it("selects a duplicated name in the first matching group only", () => {
const duplicated = [
{ kind: "TABLE", items: ["orders"] },
{ kind: "VIEW", items: ["orders"] },
];
expect(matchBulkObjectNames("orders", duplicated).matched).toEqual({ TABLE: ["orders"] });
});
it("returns no matches for blank input", () => {
expect(matchBulkObjectNames(" \n", groups)).toEqual({ matched: {}, matchedCount: 0, unmatchedNames: [] });
});
});
@@ -20,3 +20,161 @@ export function buildTransferObjectSelections(selectedObjects: Partial<Record<Tr
names: [...(selectedObjects[kind] ?? [])],
}));
}
/**
* 「批量录入」可匹配的对象分组:kind 为对象类型,items 是该类型下的全部对象名。
* 与 ObjectSelectionTree 的 ObjectTreeGroup 结构保持一致,只取匹配需要的字段。
*/
export interface BulkObjectNameGroup {
kind: string;
items: string[];
}
/** 「批量录入」的匹配结果 */
export interface BulkObjectNameMatchResult {
/** 命中的对象:kind -> 名称列表(使用清单中的原始拼写,顺序与清单一致) */
matched: Record<string, string[]>;
/** 实际勾选的对象数量,即 matched 中所有名称之和 */
matchedCount: number;
/** 未匹配上的名称,保留用户输入的原始拼写,按输入顺序去重 */
unmatchedNames: string[];
}
// 批量录入支持的分隔符:换行、逗号、分号、制表符
const BULK_NAME_SEPARATOR = /[\r\n,;\t]+/;
// 成对包裹标识符的引号,兼容 "orders" / 'orders' / `orders` / [orders]
const IDENTIFIER_WRAPPERS: Array<[string, string]> = [
['"', '"'],
["'", "'"],
["`", "`"],
["[", "]"],
];
function identifierWrapper(value: string): [string, string] | undefined {
return IDENTIFIER_WRAPPERS.find(([open, close]) => value.length >= 2 && value.startsWith(open) && value.endsWith(close));
}
/**
* 去掉标识符两侧成对的引号。只有首尾引号成对匹配时才剥离,
* 避免把 a."b 这类不完整输入误处理成别的名称。
*/
function stripIdentifierQuotes(value: string): string {
const trimmed = value.trim();
const wrapper = identifierWrapper(trimmed);
return wrapper ? trimmed.slice(1, -1).trim() : trimmed;
}
/**
* 按引号与大小写无关的方式归一化名称,用于批量匹配的比较。
*/
function normalizeBulkName(value: string): string {
return stripIdentifierQuotes(value).toLowerCase();
}
/**
* 把「批量录入」文本切分为名称列表:支持换行、逗号、分号、制表符分隔,
* 自动忽略空行与首尾空白。
*/
export function parseBulkObjectNames(text: string): string[] {
return text
.split(BULK_NAME_SEPARATOR)
.map((entry) => entry.trim())
.filter((entry) => entry.length > 0);
}
interface BulkNameToken {
/** 用户输入的原始拼写(trim 后),用于未匹配提示 */
raw: string;
/** 归一化后的对象名;前缀 schema 不被接受时为 null(视为未匹配) */
key: string | null;
}
/**
* 解析单个输入项。支持 schema.table / catalog.schema.table 前缀:
* 取最后一段作为对象名,前缀需与当前源库/schema 匹配(忽略大小写与引号);
* 若调用方没有提供任何可用的前缀(例如源 schema 为空),则放宽为按对象名匹配。
* 整体被引号包裹时(如 "my.table")视为字面名称,不再拆分前缀。
*/
function resolveBulkToken(token: string, qualifiers: readonly string[]): BulkNameToken {
const raw = token.trim();
if (!raw) return { raw, key: null };
if (identifierWrapper(raw)) {
const name = normalizeBulkName(raw);
return { raw, key: name.length > 0 ? name : null };
}
if (raw.includes(".")) {
const segments = raw.split(".");
const namePart = segments.pop() ?? "";
const accepted = qualifiers.length === 0 || segments.some((segment) => qualifierMatches(segment, qualifiers));
if (!accepted) return { raw, key: null };
const name = normalizeBulkName(namePart);
return { raw, key: name.length > 0 ? name : null };
}
const name = normalizeBulkName(raw);
return { raw, key: name.length > 0 ? name : null };
}
/**
* 判断前缀段(db / schema / catalog)是否与当前源库信息一致。
*/
function qualifierMatches(segment: string, qualifiers: readonly string[]): boolean {
const normalized = normalizeBulkName(segment);
if (!normalized) return false;
return qualifiers.some((qualifier) => normalizeBulkName(qualifier) === normalized);
}
/**
* 把「批量录入」的文本匹配到对象清单上,得到需要勾选的对象。
*
* 匹配规则:
* - 忽略大小写与成对引号;
* - 支持 schema.table / catalog.schema.table 前缀,前缀与当前源库/schema 不一致的条目按未匹配处理;
* - 同名对象只在清单中首个命中的分组里勾选(避免表与视图重名时被重复勾选);
* - 禁用(当前传输不支持)的对象类型不参与匹配。
*
* @param text 用户粘贴的原始文本
* @param groups 完整的对象清单(不受当前搜索过滤影响)
* @param disabledKinds 当前传输不支持、需要跳过的对象类型
* @param qualifiers 可接受的对象名前缀(当前源的 catalog / 库 / schema)
*/
export function matchBulkObjectNames(text: string, groups: BulkObjectNameGroup[], disabledKinds: readonly string[] = [], qualifiers: readonly string[] = []): BulkObjectNameMatchResult {
const tokens = parseBulkObjectNames(text).map((token) => resolveBulkToken(token, qualifiers));
// 归一化名称 -> 输入中的首个原始拼写;匹配到即从 pending 中移除,用于去重与判定未匹配
const pending = new Map<string, string>();
for (const token of tokens) {
if (token.key !== null && !pending.has(token.key)) pending.set(token.key, token.raw);
}
const matched: Record<string, string[]> = {};
let matchedCount = 0;
for (const group of groups) {
if (disabledKinds.includes(group.kind)) continue;
for (const item of group.items) {
const key = normalizeBulkName(item);
if (!pending.has(key)) continue;
pending.delete(key);
if (!matched[group.kind]) matched[group.kind] = [];
matched[group.kind].push(item);
matchedCount += 1;
}
}
// 未匹配 = 前缀被拒绝的条目 + 清单中找不到的条目,按输入顺序去重
const unmatchedNames: string[] = [];
const seenUnmatched = new Set<string>();
for (const token of tokens) {
const isUnmatched = token.key === null || pending.has(token.key);
if (!isUnmatched) continue;
const dedupeKey = token.key ?? `#${token.raw.toLowerCase()}`;
if (seenUnmatched.has(dedupeKey)) continue;
seenUnmatched.add(dedupeKey);
unmatchedNames.push(token.raw);
}
return { matched, matchedCount, unmatchedNames };
}
@@ -0,0 +1,47 @@
import { describe, expect, it } from "vitest";
import az from "../locales/az";
import en from "../locales/en";
import es from "../locales/es";
import it_ from "../locales/it";
import ja from "../locales/ja";
import ko from "../locales/ko";
import ptBR from "../locales/pt-BR";
import tr from "../locales/tr";
import zhCN from "../locales/zh-CN";
import zhTW from "../locales/zh-TW";
// 数据传输「批量录入对象」相关文案,需要所有语言齐全,避免回落到英文
const TRANSFER_BULK_SELECT_KEYS = ["bulkSelectObjects", "bulkSelectTitle", "bulkSelectHint", "bulkSelectPlaceholder", "bulkSelectConfirm", "bulkSelectMatched", "bulkSelectUnmatched", "noMatchingObjects"] as const;
const locales: Array<[string, Record<string, unknown>]> = [
["en", en as Record<string, unknown>],
["az", az as Record<string, unknown>],
["es", es],
["it", it_],
["ja", ja],
["ko", ko],
["pt-BR", ptBR],
["tr", tr],
["zh-CN", zhCN],
["zh-TW", zhTW],
];
function transferEntry(locale: Record<string, unknown>, key: string): unknown {
const transfer = locale["transfer"];
if (!transfer || typeof transfer !== "object") return undefined;
return (transfer as Record<string, unknown>)[key];
}
describe("transfer bulk-select i18n messages", () => {
it.each(TRANSFER_BULK_SELECT_KEYS)("en resolves transfer.%s to text", (key) => {
expect(transferEntry(en as Record<string, unknown>, key), `transfer.${key} missing from locales/en.ts`).toBeTypeOf("string");
});
it.each(locales.slice(1))("%s translates the bulk-select labels instead of using the English fallback", (_name, locale) => {
for (const key of TRANSFER_BULK_SELECT_KEYS) {
const value = transferEntry(locale, key);
expect(value, `${_name} transfer.${key}`).toBeTypeOf("string");
expect(value, `${_name} transfer.${key} should not reuse the English text`).not.toBe(transferEntry(en as Record<string, unknown>, key));
}
});
});
+8
View File
@@ -5965,6 +5965,14 @@ export default withEnglishFallback({
objects: "Obyektlər",
searchObjects: "Obyekt adlarını axtar...",
noObjects: "Obyekt yoxdur",
bulkSelectObjects: "Toplu giriş",
bulkSelectTitle: "Obyektlərin toplu seçimi",
bulkSelectHint: "Obyekt adlarını yapışdırın (hər sətirdə bir ad və ya vergül, nöqtəli vergül, tab ilə ayırın); uyğun elementlər avtomatik seçilir: hərf registri və dırnaqlar nəzərə alınmır, schema.table prefiksləri dəstəklənir.",
bulkSelectPlaceholder: "məs.: orders, users, public.payments",
bulkSelectConfirm: "Uyğunları seç",
bulkSelectMatched: "{count} uyğun obyekt seçildi",
bulkSelectUnmatched: "{count} ad uyğun gəlmədi: {names}",
noMatchingObjects: "Uyğunluq yoxdur",
objectTypeTable: "Cədvəllər",
objectTypeView: "Görünüşlər",
objectTypeMaterializedView: "Materiallaşdırılmış görünüşlər",
+8
View File
@@ -6431,6 +6431,14 @@ export default {
objects: "Objects",
searchObjects: "Search object names...",
noObjects: "No objects",
bulkSelectObjects: "Bulk input",
bulkSelectTitle: "Bulk select objects",
bulkSelectHint: "Paste object names (one per line, or separated by commas, semicolons or tabs) to select the matching items automatically: case and quotes are ignored, schema.table prefixes are supported.",
bulkSelectPlaceholder: "e.g. orders, users, public.payments",
bulkSelectConfirm: "Select matches",
bulkSelectMatched: "Selected {count} matching objects",
bulkSelectUnmatched: "{count} names did not match: {names}",
noMatchingObjects: "No matches",
objectTypeTable: "Tables",
objectTypeView: "Views",
objectTypeMaterializedView: "Materialized views",
+8
View File
@@ -5897,6 +5897,14 @@ export default withEnglishFallback({
objects: "Objetos",
searchObjects: "Buscar nombre de objeto...",
noObjects: "Sin objetos",
bulkSelectObjects: "Entrada masiva",
bulkSelectTitle: "Selección masiva de objetos",
bulkSelectHint: "Pega nombres de objetos (uno por línea, o separados por comas, punto y coma o tabulaciones) para seleccionar las coincidencias automáticamente: se ignoran mayúsculas y comillas, y se admiten prefijos schema.table.",
bulkSelectPlaceholder: "p. ej.: orders, users, public.payments",
bulkSelectConfirm: "Seleccionar coincidencias",
bulkSelectMatched: "Se seleccionaron {count} objetos coincidentes",
bulkSelectUnmatched: "{count} nombres no coincidieron: {names}",
noMatchingObjects: "Sin coincidencias",
objectTypeTable: "Tablas",
objectTypeView: "Vistas",
objectTypeMaterializedView: "Vistas materializadas",
+8
View File
@@ -5894,6 +5894,14 @@ export default withEnglishFallback({
objects: "Oggetti",
searchObjects: "Cerca nome oggetto...",
noObjects: "Nessun oggetto",
bulkSelectObjects: "Inserimento massivo",
bulkSelectTitle: "Selezione massiva oggetti",
bulkSelectHint: "Incolla i nomi degli oggetti (uno per riga, oppure separati da virgole, punti e virgola o tabulazioni) per selezionare automaticamente le corrispondenze: maiuscole/minuscole e virgolette vengono ignorate, sono supportati i prefissi schema.table.",
bulkSelectPlaceholder: "es.: orders, users, public.payments",
bulkSelectConfirm: "Seleziona corrispondenze",
bulkSelectMatched: "Selezionati {count} oggetti corrispondenti",
bulkSelectUnmatched: "{count} nomi non corrispondono: {names}",
noMatchingObjects: "Nessuna corrispondenza",
objectTypeTable: "Tabelle",
objectTypeView: "Viste",
objectTypeMaterializedView: "Viste materializzate",
+8
View File
@@ -5922,6 +5922,14 @@ export default withEnglishFallback({
objects: "オブジェクト",
searchObjects: "オブジェクト名で検索...",
noObjects: "オブジェクトなし",
bulkSelectObjects: "一括入力",
bulkSelectTitle: "オブジェクトの一括入力",
bulkSelectHint: "オブジェクト名を貼り付けてください(1行に1つ、カンマ・セミコロン・タブ区切りも可)。一致する項目を自動で選択します。大文字小文字と引用符は無視し、schema.table プレフィックスにも対応します。",
bulkSelectPlaceholder: "例:orders、users、public.payments",
bulkSelectConfirm: "一致項目を選択",
bulkSelectMatched: "{count} 件の一致項目を選択しました",
bulkSelectUnmatched: "{count} 件の名前が一致しませんでした:{names}",
noMatchingObjects: "一致なし",
objectTypeTable: "テーブル",
objectTypeView: "ビュー",
objectTypeMaterializedView: "マテリアライズドビュー",
+8
View File
@@ -5616,6 +5616,14 @@ export default withEnglishFallback({
objects: "개체",
searchObjects: "개체 이름 검색...",
noObjects: "개체 없음",
bulkSelectObjects: "일괄 입력",
bulkSelectTitle: "개체 일괄 입력",
bulkSelectHint: "개체 이름을 붙여넣으세요(한 줄에 하나, 쉼표·세미콜론·탭으로 구분 가능). 일치하는 항목을 자동으로 선택합니다. 대소문자와 따옴표는 무시하며 schema.table 접두사를 지원합니다.",
bulkSelectPlaceholder: "예: orders, users, public.payments",
bulkSelectConfirm: "일치 항목 선택",
bulkSelectMatched: "일치하는 개체 {count}개를 선택했습니다",
bulkSelectUnmatched: "{count}개 이름이 일치하지 않습니다: {names}",
noMatchingObjects: "일치 항목 없음",
objectTypeTable: "테이블",
objectTypeView: "뷰",
objectTypeMaterializedView: "구체화된 뷰",
+8
View File
@@ -5896,6 +5896,14 @@ export default withEnglishFallback({
objects: "Objetos",
searchObjects: "Buscar nome do objeto...",
noObjects: "Nenhum objeto",
bulkSelectObjects: "Entrada em massa",
bulkSelectTitle: "Seleção em massa de objetos",
bulkSelectHint: "Cole nomes de objetos (um por linha, ou separados por vírgulas, ponto e vírgula ou tabulações) para selecionar as correspondências automaticamente: maiúsculas/minúsculas e aspas são ignoradas, e prefixos schema.table são aceitos.",
bulkSelectPlaceholder: "ex.: orders, users, public.payments",
bulkSelectConfirm: "Selecionar correspondências",
bulkSelectMatched: "{count} objetos correspondentes selecionados",
bulkSelectUnmatched: "{count} nomes não corresponderam: {names}",
noMatchingObjects: "Nenhuma correspondência",
objectTypeTable: "Tabelas",
objectTypeView: "Views",
objectTypeMaterializedView: "Views materializadas",
+8
View File
@@ -5858,6 +5858,14 @@ export default withEnglishFallback({
objects: "Nesneler",
searchObjects: "Nesne adlarında ara...",
noObjects: "Nesne yok",
bulkSelectObjects: "Toplu giriş",
bulkSelectTitle: "Toplu nesne seçimi",
bulkSelectHint: "Nesne adlarını yapıştırın (her satıra bir tane veya virgül, noktalı virgül, sekme ile ayırın); eşleşen öğeler otomatik seçilir: büyük/küçük harf ve tırnaklar yok sayılır, schema.table önekleri desteklenir.",
bulkSelectPlaceholder: "örn.: orders, users, public.payments",
bulkSelectConfirm: "Eşleşenleri seç",
bulkSelectMatched: "{count} eşleşen nesne seçildi",
bulkSelectUnmatched: "{count} ad eşleşmedi: {names}",
noMatchingObjects: "Eşleşme yok",
objectTypeTable: "Tablolar",
objectTypeView: "Görünümler",
objectTypeMaterializedView: "Materyalleştirilmiş görünümler",
+8
View File
@@ -6404,6 +6404,14 @@ export default withEnglishFallback({
objects: "传输对象",
searchObjects: "搜索对象名...",
noObjects: "暂无对象",
bulkSelectObjects: "批量录入",
bulkSelectTitle: "批量录入对象",
bulkSelectHint: "粘贴多个对象名称(每行一个,也可用逗号、分号或制表符分隔),自动勾选匹配项:忽略大小写与引号,支持 schema.table 前缀。",
bulkSelectPlaceholder: "例如:orders、users、public.payments",
bulkSelectConfirm: "勾选匹配项",
bulkSelectMatched: "已勾选 {count} 个匹配项",
bulkSelectUnmatched: "{count} 个名称未匹配:{names}",
noMatchingObjects: "无匹配",
objectTypeTable: "表",
objectTypeView: "视图",
objectTypeMaterializedView: "物化视图",
+8
View File
@@ -5211,6 +5211,14 @@ export default withEnglishFallback({
objects: "傳輸物件",
searchObjects: "搜尋物件名稱...",
noObjects: "暫無物件",
bulkSelectObjects: "批次錄入",
bulkSelectTitle: "批次錄入物件",
bulkSelectHint: "貼上多個物件名稱(每行一個,也可用逗號、分號或定位字元分隔),自動勾選符合的項目:忽略大小寫與引號,支援 schema.table 前綴。",
bulkSelectPlaceholder: "例如:orders、users、public.payments",
bulkSelectConfirm: "勾選符合項目",
bulkSelectMatched: "已勾選 {count} 個符合項目",
bulkSelectUnmatched: "{count} 個名稱未符合:{names}",
noMatchingObjects: "無符合",
objectTypeTable: "表",
objectTypeView: "檢視",
objectTypeMaterializedView: "具體化檢視",