fix(generate): 确保保存/加载配置覆盖所有生成器参数,并修复参数被意外清空的问题

* feat: highlight selected SQL matches

* fix(core): detect column extra changes (auto_increment, on_update) in table structure SQL generation

- Add has_column_extra_change check to build_column_sql skip condition
- Add is_column_extra_empty to handle None/Some(false)/empty ColumnExtra
- Fix false positives when extra is parsed but no effective change

* feat(generate): fix date range persistence, add i18n for data generation

* fix:Remove invalid references

* fix(generate): ensure config save/load covers all generator params

- Remove auto-reset watch in GeneratorParamsPanel that cleared params
  when loading a profile with different generatorKey
- Extend defaultGeneratorParams() to provide explicit defaults for all
  35+ generators (full_name, phone, email, barcode, url, file_path,
  foreign_key, image, regex, product_name, etc.) so profiles persist
  and restore correctly through JSON serialize/deserialize round-trip

---------

Co-authored-by: t8y2 <1156263951@qq.com>
This commit is contained in:
dbin0123andt8y2 authored and GitHub committed 2026-06-17 00:21:35 +08:00
1 parent 0830e03f89
commit bc91fbdfa1
11 files changed
+1167 -53

No files matched your search

@@ -4,8 +4,9 @@ import { useI18n } from "vue-i18n";
import { useConnectionStore } from "@/stores/connectionStore";
import * as api from "@/lib/api";
import type { TableGenerateConfig } from "@/lib/dataGenerate";
import { findGeneratorKey, generateTableData } from "@/lib/dataGenerate";
import { findGeneratorKey, generateTableData, defaultGeneratorParams } from "@/lib/dataGenerate";
import { quoteTableIdentifier } from "@/lib/tableSelectSql";
import { isTauriRuntime } from "@/lib/tauriRuntime";
import { effectiveDatabaseTypeForConnection } from "@/lib/jdbcDialect";
import GeneratorParamsPanel from "./params/GeneratorParamsPanel.vue";
import type { ColumnInfo, TableInfo } from "@/types/database";
@@ -111,14 +112,29 @@ async function loadSchemas() {
schema: targetSchema,
database: db,
rowCount: 1000,
columns: cols.map((c: ColumnInfo) => ({
columnName: c.name,
dataType: c.data_type,
rowCount: 1000,
generatorKey: findGeneratorKey(c.name, c.data_type),
generatorParams: {},
isAutoIncrement: c.extra === "auto_increment" || (c.column_default?.includes("nextval") ?? false),
})),
columns: cols.map((c: ColumnInfo) => {
const isAI = c.extra === "auto_increment" || (c.column_default?.includes("nextval") ?? false);
const gKey = findGeneratorKey(c.name, c.data_type, isAI);
return {
columnName: c.name,
dataType: c.data_type,
rowCount: 1000,
generatorKey: gKey,
generatorParams: defaultGeneratorParams(
c.name,
{
dataType: c.data_type,
isAutoIncrement: isAI,
columnDefault: c.column_default,
numericPrecision: c.numeric_precision,
numericScale: c.numeric_scale,
characterMaximumLength: c.character_maximum_length,
},
gKey,
),
isAutoIncrement: isAI,
};
}),
};
checkedTables[key] = true;
for (const c of cols) {
@@ -188,14 +204,29 @@ async function loadColumns(schema: string, table: string) {
schema,
database: props.prefillDatabase,
rowCount: 1000,
columns: cols.map((c: ColumnInfo) => ({
columnName: c.name,
dataType: c.data_type,
rowCount: 1000,
generatorKey: findGeneratorKey(c.name, c.data_type),
generatorParams: {},
isAutoIncrement: c.extra === "auto_increment" || (c.column_default?.includes("nextval") ?? false),
})),
columns: cols.map((c: ColumnInfo) => {
const isAI = c.extra === "auto_increment" || (c.column_default?.includes("nextval") ?? false);
const gKey = findGeneratorKey(c.name, c.data_type, isAI);
return {
columnName: c.name,
dataType: c.data_type,
rowCount: 1000,
generatorKey: gKey,
generatorParams: defaultGeneratorParams(
c.name,
{
dataType: c.data_type,
isAutoIncrement: isAI,
columnDefault: c.column_default,
numericPrecision: c.numeric_precision,
numericScale: c.numeric_scale,
characterMaximumLength: c.character_maximum_length,
},
gKey,
),
isAutoIncrement: isAI,
};
}),
};
configs[key] = cfg;
for (const c of cols) {
@@ -523,6 +554,154 @@ watch(open, (val) => {
void loadSchemas();
}
});
interface GenerateProfileJson {
version: 1;
connectionId?: string;
database?: string;
savedAt: string;
configs: Record<string, TableGenerateConfig>;
checkedTables: Record<string, boolean>;
checkedColumns: Record<string, boolean>;
expandedTables: Record<string, boolean>;
expandedSchemas: Record<string, boolean>;
tableOrder: string[];
}
const fileInputRef = ref<HTMLInputElement | null>(null);
function buildProfilePayload(): GenerateProfileJson {
return {
version: 1,
connectionId: props.prefillConnectionId,
database: props.prefillDatabase,
savedAt: new Date().toISOString(),
configs: JSON.parse(JSON.stringify(configs)),
checkedTables: { ...checkedTables },
checkedColumns: { ...checkedColumns },
expandedTables: { ...expandedTables },
expandedSchemas: { ...expandedSchemas },
tableOrder: [...tableOrder.value],
};
}
function defaultProfileFilename(): string {
const payload = buildProfilePayload();
const tableNames = Object.keys(payload.configs);
const firstTableName = tableNames[0]?.split(".").pop() || "profile";
const timestamp = new Date().toISOString().replace(/[:.]/g, "-").slice(0, 19);
return tableNames.length === 1 ? `${firstTableName}-${timestamp}.json` : `data-generate-${timestamp}.json`;
}
function downloadJsonFallback(data: GenerateProfileJson, filename: string) {
const json = JSON.stringify(data, null, 2);
const blob = new Blob([json], { type: "application/json" });
const url = URL.createObjectURL(blob);
const a = document.createElement("a");
a.href = url;
a.download = filename;
document.body.appendChild(a);
a.click();
document.body.removeChild(a);
URL.revokeObjectURL(url);
}
async function saveProfile() {
const payload = buildProfilePayload();
const json = JSON.stringify(payload, null, 2);
const defaultPath = defaultProfileFilename();
if (isTauriRuntime()) {
try {
const { save } = await import("@tauri-apps/plugin-dialog");
const { writeTextFile } = await import("@tauri-apps/plugin-fs");
const path = await save({
defaultPath,
filters: [{ name: "JSON", extensions: ["json"] }],
});
if (path) {
await writeTextFile(path, json);
}
} catch (e: any) {
console.warn("profile save error (tauri):", e?.message ?? e);
}
} else {
downloadJsonFallback(payload, defaultPath);
}
}
function applyProfileData(data: Partial<GenerateProfileJson> & { configs?: Record<string, TableGenerateConfig> }) {
if (!data.configs || typeof data.configs !== "object") {
throw new Error("Invalid profile file: missing configs");
}
Object.keys(configs).forEach((k) => delete configs[k]);
Object.keys(checkedTables).forEach((k) => delete checkedTables[k]);
Object.keys(checkedColumns).forEach((k) => delete checkedColumns[k]);
Object.keys(expandedTables).forEach((k) => delete expandedTables[k]);
for (const [key, cfg] of Object.entries(data.configs)) {
if (cfg && cfg.tableName) configs[key] = cfg;
}
if (data.checkedTables && typeof data.checkedTables === "object") {
for (const [k, v] of Object.entries(data.checkedTables)) {
checkedTables[k] = !!v;
}
}
if (data.checkedColumns && typeof data.checkedColumns === "object") {
for (const [k, v] of Object.entries(data.checkedColumns)) {
checkedColumns[k] = !!v;
}
}
if (data.expandedTables && typeof data.expandedTables === "object") {
for (const [k, v] of Object.entries(data.expandedTables)) {
expandedTables[k] = !!v;
}
}
if (data.expandedSchemas && typeof data.expandedSchemas === "object") {
for (const [k, v] of Object.entries(data.expandedSchemas)) {
expandedSchemas[k] = !!v;
}
}
if (Array.isArray(data.tableOrder)) {
tableOrder.value = [...data.tableOrder];
}
}
async function triggerLoadProfile() {
if (isTauriRuntime()) {
try {
const { open } = await import("@tauri-apps/plugin-dialog");
const { readTextFile } = await import("@tauri-apps/plugin-fs");
const path = await open({
multiple: false,
filters: [{ name: "JSON", extensions: ["json"] }],
});
if (!path) return;
const text = await readTextFile(path as string);
const data = JSON.parse(text);
applyProfileData(data);
} catch (e: any) {
console.warn("profile load error (tauri):", e?.message ?? e);
}
} else {
fileInputRef.value?.click();
}
}
async function onFileSelected(event: Event) {
const input = event.target as HTMLInputElement;
const file = input.files?.[0];
if (!file) return;
try {
const text = await file.text();
const data = JSON.parse(text);
applyProfileData(data);
} catch (e: any) {
console.warn("profile load error:", e?.message ?? e);
} finally {
input.value = "";
}
}
</script>
<template>
@@ -653,7 +832,7 @@ watch(open, (val) => {
<div />
</div>
<div class="rounded-md border">
<div class="rounded-md border w-full overflow-hidden">
<div v-if="generatedResults.length === 0" class="flex h-[420px] items-center justify-center text-xs text-muted-foreground">{{ t("dataGenerate.noData") }}</div>
<template v-else>
<div class="flex items-center justify-between px-3 py-2 border-b bg-muted/10">
@@ -724,11 +903,11 @@ watch(open, (val) => {
<DialogFooter class="flex items-center justify-between border-t pt-3 sm:justify-between">
<div class="flex items-center gap-2">
<template v-if="currentStep === 'config'">
<Button variant="outline" size="sm" class="h-7 text-xs">
<Button variant="outline" size="sm" class="h-7 text-xs" @click="saveProfile">
<Save class="mr-1 h-3 w-3" />
{{ t("dataGenerate.saveProfile") }}
</Button>
<Button variant="outline" size="sm" class="h-7 text-xs">
<Button variant="outline" size="sm" class="h-7 text-xs" @click="triggerLoadProfile">
<Upload class="mr-1 h-3 w-3" />
{{ t("dataGenerate.loadProfile") }}
</Button>
@@ -768,6 +947,7 @@ watch(open, (val) => {
</template>
</div>
</DialogFooter>
<input ref="fileInputRef" type="file" accept="application/json,.json" class="hidden" @change="onFileSelected" />
</DialogScrollContent>
<Dialog v-model:open="optionsDialogOpen">
@@ -1,5 +1,5 @@
<script setup lang="ts">
import { computed, watch } from "vue";
import { computed } from "vue";
import { useI18n } from "vue-i18n";
import { Button } from "@/components/ui/button";
import { RotateCcw } from "@lucide/vue";
@@ -48,6 +48,7 @@ import FileNameParams from "./FileNameParams.vue";
import FileExtensionParams from "./FileExtensionParams.vue";
import UrlParams from "./UrlParams.vue";
import HostnameParams from "./HostnameParams.vue";
import IdNumberParams from "./IdNumberParams.vue";
import DefaultParams from "./DefaultParams.vue";
const { t } = useI18n();
@@ -62,12 +63,6 @@ function initParams() {
const key = computed(() => props.config.generatorKey);
watch(key, (newKey, oldKey) => {
if (newKey && newKey !== oldKey) {
resetParams();
}
});
function resetParams() {
props.config.generatorParams = {} as GeneratorParams;
}
@@ -116,6 +111,7 @@ const componentMap: Record<string, any> = {
file_extension: FileExtensionParams,
url: UrlParams,
hostname: HostnameParams,
id_number: IdNumberParams,
};
</script>
@@ -0,0 +1,250 @@
<script setup lang="ts">
import { computed, ref } from "vue";
import { useI18n } from "vue-i18n";
import { Button } from "@/components/ui/button";
import { RefreshCw } from "@lucide/vue";
import type { GeneratorParams } from "@/lib/dataGenerate";
import CommonOptions from "./CommonOptions.vue";
const { t } = useI18n();
function tr(key: string, fallback: string): string {
const v = t("dataGenerate.idTypes." + key);
return v === "dataGenerate.idTypes." + key ? fallback : v;
}
const props = defineProps<{ params: GeneratorParams }>();
const idTypeKeys = ["id_card", "passport", "hk_macau_pass", "taiwan_pass", "uscc", "bank_card", "drivers_license", "custom"];
if (!props.params.idTypes) {
props.params.idTypes = ["id_card"];
}
function pick<T>(arr: T[]): T {
return arr[Math.floor(Math.random() * arr.length)];
}
function toggleType(val: string) {
if (!props.params.idTypes) props.params.idTypes = [];
const idx = props.params.idTypes.indexOf(val);
if (idx >= 0) props.params.idTypes.splice(idx, 1);
else props.params.idTypes.push(val);
}
const hasCustom = computed(() => (props.params.idTypes ?? []).includes("custom"));
const areaCodes = [
"110101",
"110105",
"110108",
"120101",
"120103",
"130102",
"130105",
"210102",
"210105",
"210203",
"310101",
"310104",
"310115",
"320102",
"320105",
"330102",
"330106",
"340102",
"340104",
"350102",
"360102",
"370102",
"370202",
"410102",
"410105",
"420102",
"420106",
"430102",
"440103",
"440106",
"440303",
"440305",
"450102",
"460105",
"500103",
"510104",
"510107",
"520102",
"530102",
"540102",
"610103",
"620102",
"630102",
"640104",
"650102",
];
function generateIdCard(): string {
const area = pick(areaCodes);
const year = (1970 + Math.floor(Math.random() * 35)).toString();
const month = (Math.floor(Math.random() * 12) + 1).toString().padStart(2, "0");
const day = (Math.floor(Math.random() * 28) + 1).toString().padStart(2, "0");
const seq = Math.floor(Math.random() * 1000)
.toString()
.padStart(3, "0");
const first17 = `${area}${year}${month}${day}${seq}`;
const weights = [7, 9, 10, 5, 8, 4, 2, 1, 6, 3, 7, 9, 10, 5, 8, 4, 2];
const checks = ["1", "0", "X", "9", "8", "7", "6", "5", "4", "3", "2"];
let sum = 0;
for (let i = 0; i < 17; i++) sum += parseInt(first17[i]) * weights[i];
const check = checks[sum % 11];
return `${first17}${check}`;
}
function randDigits(n: number): string {
return Array.from({ length: n }, () => Math.floor(Math.random() * 10).toString()).join("");
}
function generatePassport(): string {
return `${pick(["E", "G", "E", "H", "P"])}${randDigits(7)}`;
}
function generateHKMacauPass(): string {
return `${pick(["C", "W", "H"])}${randDigits(8)}`;
}
function generateTaiwanPass(): string {
return `T${randDigits(8)}`;
}
function generateUSCC(): string {
const randChars = (n: number) => Array.from({ length: n }, () => "ABCDEFGHJKLMNPQRSTUVWXY23456789".charAt(Math.floor(Math.random() * "ABCDEFGHJKLMNPQRSTUVWXY23456789".length))).join("");
return `91310115MA${randChars(6)}${randDigits(4)}`;
}
function luhnCalculateCheckDigit(numStr: string): string {
let sum = 0;
const digits = numStr.split("").map(Number);
for (let i = 0; i < digits.length; i++) {
let d = digits[digits.length - 1 - i];
if (i % 2 === 0) {
d *= 2;
if (d > 9) d -= 9;
}
sum += d;
}
return ((10 - (sum % 10)) % 10).toString();
}
function generateBankCard(): string {
const bins = ["622202", "622700", "621700", "622848", "621559", "621798", "622208", "622280", "621661", "621785", "622588", "621286", "622696"];
const bin = pick(bins);
const middle = randDigits(10);
const first15 = `${bin}${middle}`;
return `${first15}${luhnCalculateCheckDigit(first15)}`;
}
function randFromCharSet(charSet: string, n: number): string {
return Array.from({ length: n }, () => charSet.charAt(Math.floor(Math.random() * charSet.length))).join("");
}
function generateFromPattern(pattern: string): string {
const letterCharSet = "ABCDEFGHIJKLMNOPQRSTUVWXYZ";
const digitCharSet = "0123456789";
let result = "";
const tokens = pattern.split(/([{}[\]-])/).filter(Boolean);
let i = 0;
while (i < tokens.length) {
const t = tokens[i];
if (t === "(" || t === ")") {
i++;
continue;
}
if (t === "[") {
const charSetToken = tokens[i + 1];
i += 2;
let charSet = "";
if (charSetToken?.includes("A-F") || charSetToken?.includes("A-Z")) charSet = letterCharSet;
else if (charSetToken?.includes("0-9")) charSet = digitCharSet;
else charSet = letterCharSet + digitCharSet;
i++;
let count = 1;
if (tokens[i] === "{") {
i++;
count = parseInt(tokens[i], 10) || 1;
i += 2;
}
result += randFromCharSet(charSet, count);
continue;
}
if (t === "-") {
result += "-";
i++;
continue;
}
i++;
}
if (!result) {
return randDigits(10);
}
return result;
}
function generateByType(type: string): string {
switch (type) {
case "id_card":
return generateIdCard();
case "passport":
return generatePassport();
case "hk_macau_pass":
return generateHKMacauPass();
case "taiwan_pass":
return generateTaiwanPass();
case "uscc":
return generateUSCC();
case "bank_card":
return generateBankCard();
case "drivers_license":
return generateIdCard();
case "custom":
return generateFromPattern(props.params.idCustomPattern ?? "([A-Z]{2})-([0-9]{8})");
default:
return generateIdCard();
}
}
const previewKey = ref(0);
const previewVal = computed(() => {
void previewKey.value;
const selected = props.params.idTypes?.length ? props.params.idTypes : ["id_card"];
return generateByType(pick(selected));
});
function refresh() {
previewKey.value++;
}
</script>
<template>
<div class="space-y-3">
<div class="rounded-md border bg-muted/10 p-3">
<div class="text-xs text-muted-foreground mb-2">{{ tr("title", "证件类型") }}</div>
<div class="flex flex-wrap gap-1.5">
<button v-for="k in idTypeKeys" :key="k" type="button" class="px-2 py-1 rounded border text-xs" :class="(params.idTypes ?? []).includes(k) ? 'bg-primary text-primary-foreground border-primary' : 'bg-background'" @click="toggleType(k)">{{ tr(k, k) }}</button>
</div>
</div>
<div v-if="hasCustom" class="rounded-md border bg-muted/10 p-3">
<div class="text-xs text-muted-foreground mb-1">{{ tr("customPattern", "自定义模式") }}</div>
<input v-model="params.idCustomPattern" class="w-full rounded border bg-background px-2 py-1 text-xs font-mono" :placeholder="tr('customPatternPlaceholder', '如 ([A-Z]{2})-([0-9]{8})')" />
<div class="text-[10px] text-muted-foreground mt-1">{{ tr("customPatternHint", "支持 [A-Z] 字母、[0-9] 数字、{n} 重复、连字符等简单模式") }}</div>
</div>
<div class="rounded-md border bg-muted/10 p-3">
<div class="flex items-center gap-2 text-xs">
<span class="text-muted-foreground shrink-0">{{ t("dataGenerate.preview") }}</span>
<span :key="previewKey" class="font-mono text-sm">{{ previewVal }}</span>
<Button variant="ghost" size="icon" class="h-5 w-5 ml-auto" @click="refresh">
<RefreshCw class="h-3 w-3" />
</Button>
</div>
</div>
<CommonOptions :params="params" />
</div>
</template>