feat(codegen): 代码生成支持本地写入和代码优化

This commit is contained in:
Ray.Hao
2025-08-21 18:28:38 +08:00
parent 72b89e2894
commit 07206576a8
61 changed files with 1045 additions and 1087 deletions

View File

@@ -144,6 +144,22 @@
</el-col>
</el-row>
<el-row>
<el-col :span="12">
<el-form-item label="移除表前缀">
<el-input v-model="genConfigFormData.removeTablePrefix" placeholder="如: sys_" />
</el-form-item>
</el-col>
<el-col :span="12">
<el-form-item label="页面类型">
<el-radio-group v-model="genConfigFormData.pageType">
<el-radio-button label="classic">普通</el-radio-button>
<el-radio-button label="curd">封装(CURD)</el-radio-button>
</el-radio-group>
</el-form-item>
</el-col>
</el-row>
<el-row>
<el-col :span="12">
<el-form-item>
@@ -180,6 +196,38 @@
</el-form>
<div v-show="active == 1" class="elTableCustom">
<div class="mb-2 flex-y-center gap-2">
<el-tag size="small" type="info">批量设置</el-tag>
<el-space size="small">
<el-dropdown>
<el-button size="small" type="primary" plain>查询</el-button>
<template #dropdown>
<el-dropdown-menu>
<el-dropdown-item @click="bulkSet('isShowInQuery', 1)">全选</el-dropdown-item>
<el-dropdown-item @click="bulkSet('isShowInQuery', 0)">全不选</el-dropdown-item>
</el-dropdown-menu>
</template>
</el-dropdown>
<el-dropdown>
<el-button size="small" type="success" plain>列表</el-button>
<template #dropdown>
<el-dropdown-menu>
<el-dropdown-item @click="bulkSet('isShowInList', 1)">全选</el-dropdown-item>
<el-dropdown-item @click="bulkSet('isShowInList', 0)">全不选</el-dropdown-item>
</el-dropdown-menu>
</template>
</el-dropdown>
<el-dropdown>
<el-button size="small" type="warning" plain>表单</el-button>
<template #dropdown>
<el-dropdown-menu>
<el-dropdown-item @click="bulkSet('isShowInForm', 1)">全选</el-dropdown-item>
<el-dropdown-item @click="bulkSet('isShowInForm', 0)">全不选</el-dropdown-item>
</el-dropdown-menu>
</template>
</el-dropdown>
</el-space>
</div>
<el-table
v-loading="loading"
row-key="id"
@@ -227,51 +275,19 @@
</template>
</el-table-column>
<el-table-column width="70">
<template #header>
<div class="flex-y-center">
<span>查询</span>
<el-checkbox
v-model="isCheckAllQuery"
class="ml-1"
@change="toggleCheckAll('isShowInQuery', isCheckAllQuery)"
/>
</div>
</template>
<el-table-column width="70" label="查询">
<template #default="scope">
<el-checkbox v-model="scope.row.isShowInQuery" :true-value="1" :false-value="0" />
</template>
</el-table-column>
<el-table-column width="70">
<template #header>
<div class="flex-y-center">
<span>列表</span>
<el-checkbox
v-model="isCheckAllList"
class="ml-1"
@change="toggleCheckAll('isShowInList', isCheckAllList)"
/>
</div>
</template>
<el-table-column width="70" label="列表">
<template #default="scope">
<el-checkbox v-model="scope.row.isShowInList" :true-value="1" :false-value="0" />
</template>
</el-table-column>
<el-table-column width="70">
<template #header>
<div class="flex-y-center">
<span>表单</span>
<el-checkbox
v-model="isCheckAllForm"
class="ml-1"
@change="toggleCheckAll('isShowInForm', isCheckAllForm)"
/>
</div>
</template>
<el-table-column width="70" label="表单">
<template #default="scope">
<el-checkbox v-model="scope.row.isShowInForm" :true-value="1" :false-value="0" />
</template>
@@ -347,10 +363,26 @@
</div>
<el-row v-show="active == 2">
<el-col :span="24" class="mb-2">
<div class="flex-y-center gap-3">
<span class="text-sm color-#909399">预览范围</span>
<el-radio-group v-model="previewScope" size="small">
<el-radio-button label="all">全部</el-radio-button>
<el-radio-button label="frontend">前端</el-radio-button>
<el-radio-button label="backend">后端</el-radio-button>
</el-radio-group>
<span class="ml-3 text-sm color-#909399">类型</span>
<el-checkbox-group v-model="previewTypes" size="small">
<el-checkbox-button v-for="t in previewTypeOptions" :key="t" :label="t">
{{ t }}
</el-checkbox-button>
</el-checkbox-group>
</div>
</el-col>
<el-col :span="6">
<el-scrollbar max-height="72vh">
<el-tree
:data="treeData"
:data="filteredTreeData"
default-expand-all
highlight-current
@node-click="handleFileTreeNodeClick"
@@ -403,8 +435,79 @@
<Download />
</el-icon>
</el-button>
<el-button
v-if="active === 2"
:disabled="!supportsFSAccess"
type="primary"
plain
@click="openWriteDialog"
>
<template #icon>
<el-icon><FolderOpened /></el-icon>
</template>
写入本地
</el-button>
</template>
</el-drawer>
<!-- 写入本地对话框 -->
<el-dialog v-model="writeDialog.visible" title="写入本地" width="820px">
<div class="space-y-3">
<el-alert
v-if="!supportsFSAccess"
title="当前浏览器不支持 File System Access API建议使用 Chrome/Edge 最新版"
type="warning"
show-icon
:closable="false"
/>
<el-form :label-width="110">
<el-form-item label="前端根目录">
<div class="flex-y-center gap-2">
<el-input v-model="frontendDirPath" placeholder="请选择前端根目录" readonly />
<el-button :disabled="!supportsFSAccess" @click="pickFrontendDir">选择</el-button>
</div>
</el-form-item>
<el-form-item label="后端根目录">
<div class="flex-y-center gap-2">
<el-input v-model="backendDirPath" placeholder="请选择后端根目录" readonly />
<el-button :disabled="!supportsFSAccess" @click="pickBackendDir">选择</el-button>
</div>
</el-form-item>
<el-form-item label="写入范围">
<el-radio-group v-model="writeScope">
<el-radio-button label="all">全部</el-radio-button>
<el-radio-button label="frontend">仅前端</el-radio-button>
<el-radio-button label="backend">仅后端</el-radio-button>
</el-radio-group>
</el-form-item>
<el-form-item label="覆盖策略">
<el-radio-group v-model="overwriteMode">
<el-radio-button label="overwrite">覆盖</el-radio-button>
<el-radio-button label="skip">跳过已存在</el-radio-button>
<el-radio-button label="ifChanged">仅变更覆盖</el-radio-button>
</el-radio-group>
</el-form-item>
</el-form>
<div v-if="writeProgress.total > 0" class="mt-2">
<el-progress :text-inside="true" :stroke-width="16" :percentage="writeProgress.percent" />
<div class="mt-1 text-sm color-#909399">
{{ writeProgress.done }}/{{ writeProgress.total }} {{ writeProgress.current }}
</div>
</div>
</div>
<template #footer>
<el-button @click="writeDialog.visible = false"> </el-button>
<el-button
type="primary"
:disabled="!canWriteToLocal || writeRunning"
@click="confirmWrite"
>
</el-button>
</template>
</el-dialog>
</div>
</template>
@@ -427,10 +530,11 @@ import GeneratorAPI, {
GenConfigForm,
TablePageQuery,
FieldConfig,
} from "@/api/codegen.api";
} from "@/api/codegen-api";
import { ElLoading } from "element-plus";
import DictAPI from "@/api/system/dict.api";
import MenuAPI from "@/api/system/menu.api";
import DictAPI from "@/api/system/dict-api";
import MenuAPI from "@/api/system/menu-api";
interface TreeNode {
label: string;
@@ -438,6 +542,41 @@ interface TreeNode {
children?: TreeNode[];
}
const treeData = ref<TreeNode[]>([]);
const previewScope = ref<"all" | "frontend" | "backend">("all");
const previewTypeOptions = ["ts", "vue", "java", "xml"];
const previewTypes = ref<string[]>([...previewTypeOptions]);
const filteredTreeData = computed<TreeNode[]>(() => {
if (!treeData.value.length) return [];
// 基于原树按 scope/types 过滤叶子节点
const match = (label: string, parentPath: string[]): boolean => {
// scope 过滤:根据路径初步判断
const pathStr = parentPath.join("/");
if (previewScope.value !== "all") {
const isBackend = /(^|\/)src\/main\//.test(pathStr) || /(^|\/)java\//.test(pathStr);
const scopeOfNode = isBackend ? "backend" : "frontend";
if (scopeOfNode !== previewScope.value) return false;
}
// 类型过滤:根据后缀
const ext = label.split(".").pop() || "";
return previewTypes.value.includes(ext);
};
const cloneFilter = (node: TreeNode, parents: string[] = []): TreeNode | null => {
if (!node.children || node.children.length === 0) {
return match(node.label, parents) ? { ...node } : null;
}
const nextParents = [...parents, node.label];
const children = (node.children || [])
.map((c) => cloneFilter(c, nextParents))
.filter(Boolean) as TreeNode[];
if (!children.length) return null;
return { label: node.label, children };
};
const filtered = treeData.value.map((n) => cloneFilter(n)).filter(Boolean) as TreeNode[];
return filtered;
});
const queryFormRef = ref();
const queryParams = reactive<TablePageQuery>({
@@ -457,6 +596,7 @@ const dictOptions = ref<OptionType[]>();
const menuOptions = ref<OptionType[]>([]);
const genConfigFormData = ref<GenConfigForm>({
fieldConfigs: [],
pageType: "classic",
});
const genConfigFormRules = {
@@ -472,6 +612,24 @@ const dialog = reactive({
title: "",
});
// 页面风格使用后端持久化字段 genConfigFormData.ui
watch(
() => genConfigFormData.value.removeTablePrefix,
(prefix) => {
const table = genConfigFormData.value.tableName;
if (!table) return;
const p = prefix || "";
const base = table.startsWith(p) ? table.slice(p.length) : table;
// 将下划线分隔的表名转为帕斯卡命名
const camel = base
.split("_")
.filter(Boolean)
.map((s) => s.charAt(0).toUpperCase() + s.slice(1))
.join("");
genConfigFormData.value.entityName = camel;
}
);
const { copy, copied } = useClipboard();
const code = ref();
const cmRef = ref<CmComponentRef>();
@@ -485,6 +643,28 @@ const active = ref(0);
const currentTableName = ref("");
const sortFlag = ref<object>();
// ================= 本地写盘(可选) =================
const supportsFSAccess = typeof (window as any).showDirectoryPicker === "function";
const outputMode = ref<"zip" | "local">("zip");
const frontendDirHandle = ref<any>(null);
const backendDirHandle = ref<any>(null);
const frontendDirName = ref("");
const backendDirName = ref("");
// 预览的原始文件列表(用于写盘)
const lastPreviewFiles = ref<{ path: string; fileName: string; content: string }[]>([]);
const needFrontend = computed(() =>
lastPreviewFiles.value.some((f) => resolveRootForPath(f.path) === "frontend")
);
const needBackend = computed(() =>
lastPreviewFiles.value.some((f) => resolveRootForPath(f.path) === "backend")
);
const canWriteToLocal = computed(() => {
if (!lastPreviewFiles.value.length) return false;
const frontOk = needFrontend.value ? !!frontendDirHandle.value : true;
const backOk = needBackend.value ? !!backendDirHandle.value : true;
return frontOk && backOk;
});
// 查询是否全选
const isCheckAllQuery = ref(false);
// 列表是否全选
@@ -623,7 +803,9 @@ function handleNextClick() {
ElMessage.error("表名不能为空");
return;
}
GeneratorAPI.download(tableName);
if (outputMode.value === "zip" || !supportsFSAccess) {
GeneratorAPI.download(tableName, (genConfigFormData.value.pageType as any) || "classic");
}
}
}
}
@@ -699,15 +881,15 @@ function handleResetConfig(tableName: string) {
type FieldConfigKey = "isShowInQuery" | "isShowInList" | "isShowInForm";
/** 全选 */
const toggleCheckAll = (key: FieldConfigKey, value: boolean) => {
const fieldConfigs = genConfigFormData.value?.fieldConfigs;
// 单列全选开关已移除,改为顶部“批量设置”入口;保留方法时会触发未使用告警,故删除。
if (fieldConfigs) {
fieldConfigs.forEach((row: FieldConfig) => {
row[key] = value ? 1 : 0;
});
}
};
function bulkSet(key: FieldConfigKey, value: 0 | 1) {
const list = genConfigFormData.value?.fieldConfigs || [];
list.forEach((row: any) => {
// 只改已有字段,保持响应式
row[key] = value;
});
}
const checkAllSelected = (key: keyof FieldConfig, isCheckAllRef: any) => {
const fieldConfigs = genConfigFormData.value?.fieldConfigs || [];
@@ -717,12 +899,15 @@ const checkAllSelected = (key: keyof FieldConfig, isCheckAllRef: any) => {
/** 获取生成预览 */
function handlePreview(tableName: string) {
treeData.value = [];
GeneratorAPI.getPreviewData(tableName)
GeneratorAPI.getPreviewData(tableName, (genConfigFormData.value.pageType as any) || "classic")
.then((data) => {
dialog.title = `代码生成 ${tableName}`;
// 组装树形结构完善代码
const tree = buildTree(data);
treeData.value = [tree];
// 缓存原始数据用于写盘
lastPreviewFiles.value = data || [];
// 去掉根节点“前后端代码”,直接展示其 children 作为一级目录
treeData.value = tree?.children ? [...tree.children] : [];
// 默认选中第一个叶子节点并设置 code 值
const firstLeafNode = findFirstLeafNode(tree);
@@ -858,6 +1043,280 @@ const handleCopyCode = () => {
}
};
// =============== 目录选择与写入 ===============
const pickFrontendDir = async () => {
try {
// @ts-ignore
frontendDirHandle.value = await (window as any).showDirectoryPicker();
frontendDirName.value = frontendDirHandle.value?.name || "";
ElMessage.success("前端目录选择成功");
} catch {
// 用户取消或浏览器不支持
}
};
const pickBackendDir = async () => {
try {
// @ts-ignore
backendDirHandle.value = await (window as any).showDirectoryPicker();
backendDirName.value = backendDirHandle.value?.name || "";
ElMessage.success("后端目录选择成功");
} catch {
// 用户取消或浏览器不支持
}
};
async function ensureDir(root: any, path: string[], force = true) {
let current = root;
for (const segment of path) {
try {
// @ts-ignore
current = await current.getDirectoryHandle(segment, { create: true });
} catch (err: any) {
// 若同名文件阻塞目录创建,尝试强制删除后重建
if (force && err?.name === "TypeMismatchError") {
try {
// @ts-ignore
await current.removeEntry(segment, { recursive: true });
// @ts-ignore
current = await current.getDirectoryHandle(segment, { create: true });
} catch {
throw err;
}
} else {
throw err;
}
}
}
return current;
}
async function writeFile(dirHandle: any, filePath: string, content: string) {
const normalized = filePath.replace(/\\/g, "/");
const parts = normalized.split("/").filter(Boolean);
const fileName = parts.pop()!;
const folderSegments = parts;
const targetDir = await ensureDir(dirHandle, folderSegments, true);
// @ts-ignore
let fileHandle;
try {
// @ts-ignore
fileHandle = await targetDir.getFileHandle(fileName, { create: true });
} catch (err: any) {
if (err?.name === "TypeMismatchError") {
// 存在同名目录,尝试删除后重建文件
try {
// @ts-ignore
await targetDir.removeEntry(fileName, { recursive: true });
// @ts-ignore
fileHandle = await targetDir.getFileHandle(fileName, { create: true });
} catch {
throw err;
}
} else {
throw err;
}
}
// @ts-ignore
const writable = await fileHandle.createWritable();
await writable.write(content ?? "");
await writable.close();
}
async function pathExists(dirHandle: any, filePath: string): Promise<boolean> {
try {
const normalized = filePath.replace(/\\/g, "/");
const parts = normalized.split("/").filter(Boolean);
const fileName = parts.pop()!;
const targetDir = await ensureDir(dirHandle, parts, false);
// @ts-ignore
await targetDir.getFileHandle(fileName, { create: false });
return true;
} catch {
return false;
}
}
async function isSameFile(dirHandle: any, filePath: string, content: string): Promise<boolean> {
try {
const normalized = filePath.replace(/\\/g, "/");
const parts = normalized.split("/").filter(Boolean);
const fileName = parts.pop()!;
const targetDir = await ensureDir(dirHandle, parts, false);
// @ts-ignore
const fileHandle = await targetDir.getFileHandle(fileName, { create: false });
// @ts-ignore
const file = await fileHandle.getFile();
const text = await file.text();
return text === (content ?? "");
} catch {
return false;
}
}
// 将模板中的 path 映射到前端/后端根目录
function resolveRootForPath(p: string) {
const normalized = p.replace(/\\/g, "/");
const frontApp = genConfigFormData.value.frontendAppName;
const backApp = genConfigFormData.value.backendAppName;
if (
(backApp && normalized.startsWith(`${backApp}/`)) ||
normalized.includes("/src/main/") ||
normalized.startsWith("src/main/") ||
normalized.startsWith("java/")
) {
return "backend" as const;
}
if ((frontApp && normalized.startsWith(`${frontApp}/`)) || normalized.startsWith("src/")) {
return "frontend" as const;
}
// 默认前端
return "frontend" as const;
}
function stripProjectRoot(p: string) {
const normalized = p.replace(/\\/g, "/");
const frontApp = genConfigFormData.value.frontendAppName;
const backApp = genConfigFormData.value.backendAppName;
let rel = normalized;
if (frontApp && normalized.startsWith(`${frontApp}/`)) {
rel = normalized.slice(frontApp.length + 1);
} else if (backApp && normalized.startsWith(`${backApp}/`)) {
rel = normalized.slice(backApp.length + 1);
} else {
const idx = normalized.indexOf("/src/");
if (idx > -1) {
rel = normalized.slice(idx + 1); // 保留 'src/...'
} else if (normalized.startsWith("src/")) {
rel = normalized;
}
}
return rel;
}
const writeGeneratedCode = async () => {
if (!supportsFSAccess) {
ElMessage.warning("当前浏览器不支持本地写入请选择下载ZIP");
return;
}
if (
(needFrontend.value && !frontendDirHandle.value) ||
(needBackend.value && !backendDirHandle.value)
) {
ElMessage.warning("请先选择所需的前端/后端目录");
return;
}
if (!lastPreviewFiles.value.length) {
ElMessage.warning("请先生成预览");
return;
}
loading.value = true;
const loadingSvc = ElLoading.service({
lock: true,
text: "正在写入代码...",
});
writeRunning.value = true;
let frontCount = 0;
let backCount = 0;
const failed: string[] = [];
const files = lastPreviewFiles.value.filter((f) => {
const root = resolveRootForPath(f.path);
return writeScope.value === "all" || root === writeScope.value;
});
writeProgress.total = files.length;
writeProgress.done = 0;
writeProgress.percent = 0;
writeProgress.current = "";
const concurrency = 4;
const queue = files.slice();
const workers: Promise<void>[] = [];
async function worker() {
while (queue.length) {
const item = queue.shift()!;
try {
const root = resolveRootForPath(item.path);
const relativePath = stripProjectRoot(`${item.path}/${item.fileName}`);
writeProgress.current = relativePath;
if (overwriteMode.value === "ifChanged") {
// 简单差异:已有文件内容与待写内容相同则跳过
// @ts-ignore
const targetRoot = root === "frontend" ? frontendDirHandle.value : backendDirHandle.value;
const existsSame = await isSameFile(targetRoot, relativePath, item.content || "");
if (existsSame) {
// 视作成功但不写
writeProgress.done++;
writeProgress.percent = Math.round((writeProgress.done / writeProgress.total) * 100);
continue;
}
}
if (overwriteMode.value === "skip") {
// @ts-ignore
const targetRoot = root === "frontend" ? frontendDirHandle.value : backendDirHandle.value;
const exists = await pathExists(targetRoot, relativePath);
if (exists) {
writeProgress.done++;
writeProgress.percent = Math.round((writeProgress.done / writeProgress.total) * 100);
continue;
}
}
if (root === "frontend") {
await writeFile(frontendDirHandle.value, relativePath, item.content || "");
frontCount++;
} else {
await writeFile(backendDirHandle.value, relativePath, item.content || "");
backCount++;
}
} catch (err) {
console.error("写入失败:", item.path, err);
failed.push(item.path);
} finally {
writeProgress.done++;
writeProgress.percent = Math.round((writeProgress.done / writeProgress.total) * 100);
}
}
}
for (let i = 0; i < concurrency; i++) {
workers.push(worker());
}
await Promise.all(workers);
loading.value = false;
loadingSvc.close();
writeRunning.value = false;
if (failed.length) {
ElMessage.warning(
`部分文件写入失败:${failed.length} 个,成功 前端 ${frontCount} 个/后端 ${backCount} 个。打开控制台查看详情`
);
} else {
ElMessage.success(`写入完成:前端 ${frontCount} 个文件,后端 ${backCount} 个文件`);
}
};
const writeDialog = reactive({ visible: false });
const frontendDirPath = ref("");
const backendDirPath = ref("");
const writeScope = ref<"all" | "frontend" | "backend">("all");
const overwriteMode = ref<"overwrite" | "skip" | "ifChanged">("overwrite");
const writeProgress = reactive({ total: 0, done: 0, percent: 0, current: "" });
const writeRunning = ref(false);
// 提示文本已取消展示,保留逻辑意义不大,移除。
function openWriteDialog() {
writeDialog.visible = true;
}
// 同步展示路径
watch(frontendDirName, (v) => (frontendDirPath.value = v));
watch(backendDirName, (v) => (backendDirPath.value = v));
async function confirmWrite() {
await writeGeneratedCode();
writeDialog.visible = false;
}
/** 组件挂载后执行 */
onMounted(() => {
handleQuery();