diff --git a/package.json b/package.json index d72a5aa1..bd9d7361 100644 --- a/package.json +++ b/package.json @@ -1,7 +1,7 @@ { "name": "vue3-element-admin", "description": "Vue3 + Vite + TypeScript + Element-Plus 的后台管理模板,vue-element-admin 的 Vue3 版本", - "version": "4.4.1", + "version": "4.5.0", "private": true, "type": "module", "scripts": { diff --git a/src/views/codegen/components/BasicConfigStep.vue b/src/views/codegen/components/BasicConfigStep.vue new file mode 100644 index 00000000..66b5e1e1 --- /dev/null +++ b/src/views/codegen/components/BasicConfigStep.vue @@ -0,0 +1,291 @@ + + + + + diff --git a/src/views/codegen/components/FieldConfigStep.vue b/src/views/codegen/components/FieldConfigStep.vue new file mode 100644 index 00000000..14efe035 --- /dev/null +++ b/src/views/codegen/components/FieldConfigStep.vue @@ -0,0 +1,419 @@ + + + + + diff --git a/src/views/codegen/components/GeneratorDrawer.vue b/src/views/codegen/components/GeneratorDrawer.vue new file mode 100644 index 00000000..372b491c --- /dev/null +++ b/src/views/codegen/components/GeneratorDrawer.vue @@ -0,0 +1,280 @@ + + + + + diff --git a/src/views/codegen/components/PreviewStep.vue b/src/views/codegen/components/PreviewStep.vue new file mode 100644 index 00000000..f459c9c7 --- /dev/null +++ b/src/views/codegen/components/PreviewStep.vue @@ -0,0 +1,500 @@ + + + + + diff --git a/src/views/codegen/components/TableList.vue b/src/views/codegen/components/TableList.vue new file mode 100644 index 00000000..b3dd620e --- /dev/null +++ b/src/views/codegen/components/TableList.vue @@ -0,0 +1,119 @@ + + + diff --git a/src/views/codegen/components/WriteLocalDialog.vue b/src/views/codegen/components/WriteLocalDialog.vue new file mode 100644 index 00000000..8129584f --- /dev/null +++ b/src/views/codegen/components/WriteLocalDialog.vue @@ -0,0 +1,276 @@ + + + + + diff --git a/src/views/codegen/composables/useCodePreview.ts b/src/views/codegen/composables/useCodePreview.ts new file mode 100644 index 00000000..3cfa3dbc --- /dev/null +++ b/src/views/codegen/composables/useCodePreview.ts @@ -0,0 +1,122 @@ +import GeneratorAPI from "@/api/codegen"; +import type { GenConfigForm } from "@/api/codegen"; +import { + buildFileTree, + filterTree, + findFirstLeaf, + findLeafByKey, + getFileIcon, +} from "../utils/tree-builder"; +import type { TreeNode } from "../utils/tree-builder"; + +export function useCodePreview(genConfigFormData: Ref) { + const treeData = ref([]); + const previewScope = ref<"all" | "frontend" | "backend">("all"); + const previewTypeOptions = ref([]); + const previewTypes = ref([]); + + const filteredTreeData = computed(() => { + if (!treeData.value.length) return []; + return filterTree(treeData.value, previewScope.value, previewTypes.value); + }); + + const code = ref(""); + const currentFileKey = ref(""); + const fileTreeRef = ref(); + + const { copy, copied } = useClipboard(); + + watch(copied, () => { + if (copied.value) ElMessage.success("复制成功"); + }); + + /** 获取预览数据并构建文件树 */ + async function handlePreview(tableName: string) { + treeData.value = []; + const pageType = genConfigFormData.value.pageType || "classic"; + const data = await GeneratorAPI.getPreviewData(tableName, pageType as "classic" | "curd", "ts"); + const previewList = data || []; + + // 提取语言类型选项 + const typeOptions = Array.from( + new Set( + previewList + .map((item) => item.language || item.fileName.split(".").pop() || "") + .filter(Boolean) + ) + ); + previewTypeOptions.value = typeOptions; + previewTypes.value = [...typeOptions]; + + // 构建树 + const tree = buildFileTree(previewList); + treeData.value = tree?.children ? [...tree.children] : []; + + // 选中第一个叶子节点 + const firstLeaf = findFirstLeaf(tree); + if (firstLeaf) { + code.value = firstLeaf.content || ""; + currentFileKey.value = firstLeaf.key || ""; + await nextTick(); + fileTreeRef.value?.setCurrentKey?.(currentFileKey.value); + } + + return previewList; + } + + /** 点击文件树节点 */ + function handleFileTreeNodeClick(data: TreeNode) { + if (!data.children || data.children.length === 0) { + code.value = data.content || ""; + currentFileKey.value = data.key || ""; + } + } + + /** 复制代码 */ + function handleCopyCode() { + if (code.value) copy(code.value); + } + + // 过滤条件变了之后,如果当前选中的文件还在就继续高亮,否则选第一个 + watch( + () => filteredTreeData.value, + async (nodes) => { + if (!nodes.length) { + currentFileKey.value = ""; + code.value = ""; + return; + } + if (currentFileKey.value) { + const leaf = findLeafByKey(nodes, currentFileKey.value); + if (leaf) { + await nextTick(); + fileTreeRef.value?.setCurrentKey?.(currentFileKey.value); + return; + } + } + const first = findFirstLeaf({ label: "root", key: "root", children: nodes }); + if (first) { + code.value = first.content || ""; + currentFileKey.value = first.key || ""; + await nextTick(); + fileTreeRef.value?.setCurrentKey?.(currentFileKey.value); + } + }, + { immediate: true } + ); + + return { + treeData, + filteredTreeData, + previewScope, + previewTypes, + previewTypeOptions, + code, + currentFileKey, + fileTreeRef, + handlePreview, + handleFileTreeNodeClick, + handleCopyCode, + getFileIcon, + }; +} diff --git a/src/views/codegen/composables/useGenConfig.ts b/src/views/codegen/composables/useGenConfig.ts new file mode 100644 index 00000000..cbd30fad --- /dev/null +++ b/src/views/codegen/composables/useGenConfig.ts @@ -0,0 +1,106 @@ +import GeneratorAPI from "@/api/codegen"; +import type { GenConfigForm } from "@/api/codegen"; +import DictAPI from "@/api/system/dict"; +import MenuAPI from "@/api/system/menu"; +import { QueryTypeEnum } from "@/enums/codegen"; + +export function useGenConfig() { + const genConfigFormData = ref({ + fieldConfigs: [], + pageType: "classic", + }); + + const genConfigFormRules = { + tableName: [{ required: true, message: "请输入表名", trigger: "blur" }], + businessName: [{ required: true, message: "请输入业务名", trigger: "blur" }], + packageName: [{ required: true, message: "请输入主包名", trigger: "blur" }], + moduleName: [{ required: true, message: "请输入模块名", trigger: "blur" }], + entityName: [{ required: true, message: "请输入实体名", trigger: "blur" }], + }; + + const menuOptions = ref([]); + const dictOptions = ref([]); + + /** 自动根据表前缀推导实体名 */ + 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; + } + ); + + // Date 类型字段默认用范围查询,比较符合直觉 + watch( + () => genConfigFormData.value.fieldConfigs, + (newVal) => { + if (!newVal) return; + newVal.forEach((fieldConfig) => { + if ( + fieldConfig.fieldType?.includes("Date") && + fieldConfig.isShowInQuery === 1 && + fieldConfig.queryType == null + ) { + fieldConfig.queryType = QueryTypeEnum.BETWEEN.value as number; + } + }); + }, + { deep: true, immediate: true } + ); + + /** 加载配置:并行获取菜单、字典、生成配置 */ + async function loadConfig(tableName: string) { + const [menuList, dictList, config] = await Promise.all([ + MenuAPI.getOptions(true), + DictAPI.getList(), + GeneratorAPI.getGenConfig(tableName), + ]); + menuOptions.value = menuList; + dictOptions.value = dictList; + genConfigFormData.value = config; + return config; + } + + /** 保存配置 */ + async function saveConfig(tableName: string) { + await GeneratorAPI.saveGenConfig(tableName, genConfigFormData.value); + } + + /** 校验基础配置必填项 */ + function validateBasic(): boolean { + const { tableName, packageName, businessName, moduleName, entityName } = + genConfigFormData.value; + if (!tableName || !packageName || !businessName || !moduleName || !entityName) { + ElMessage.error("表名、业务名、包名、模块名、实体名不能为空"); + return false; + } + return true; + } + + /** 批量设置字段属性 */ + function bulkSet(key: "isShowInQuery" | "isShowInList" | "isShowInForm", value: 0 | 1) { + const list = genConfigFormData.value?.fieldConfigs || []; + list.forEach((row) => { + row[key] = value; + }); + } + + return { + genConfigFormData, + genConfigFormRules, + menuOptions, + dictOptions, + loadConfig, + saveConfig, + validateBasic, + bulkSet, + }; +} diff --git a/src/views/codegen/composables/useLocalWrite.ts b/src/views/codegen/composables/useLocalWrite.ts new file mode 100644 index 00000000..c2e1d07a --- /dev/null +++ b/src/views/codegen/composables/useLocalWrite.ts @@ -0,0 +1,237 @@ +import type { GeneratorPreviewItem, GenConfigForm } from "@/api/codegen"; +import { ElLoading } from "element-plus"; + +export function useLocalWrite(genConfigFormData: Ref) { + const supportsFSAccess = typeof (window as any).showDirectoryPicker === "function"; + + const writeDialog = reactive({ visible: false }); + const frontendDirHandle = ref(null); + const backendDirHandle = ref(null); + 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); + const lastPreviewFiles = ref([]); + + const needFrontend = computed(() => + lastPreviewFiles.value.some((f) => resolveRootForItem(f) === "frontend") + ); + const needBackend = computed(() => + lastPreviewFiles.value.some((f) => resolveRootForItem(f) === "backend") + ); + // 只要有预览文件就可以点写入按钮,目录在弹窗里选 + const canWriteToLocal = computed(() => lastPreviewFiles.value.length > 0); + + function openWriteDialog() { + writeDialog.visible = true; + } + + function setPreviewFiles(files: GeneratorPreviewItem[]) { + lastPreviewFiles.value = files; + } + + async function pickFrontendDir() { + try { + frontendDirHandle.value = await (window as any).showDirectoryPicker(); + frontendDirPath.value = frontendDirHandle.value?.name || ""; + ElMessage.success("前端目录选择成功"); + } catch { + // 用户取消 + } + } + + async function pickBackendDir() { + try { + backendDirHandle.value = await (window as any).showDirectoryPicker(); + backendDirPath.value = backendDirHandle.value?.name || ""; + ElMessage.success("后端目录选择成功"); + } catch { + // 用户取消 + } + } + + async function confirmWrite() { + await writeGeneratedCode(); + writeDialog.visible = false; + } + + // ---- 内部工具函数 ---- + + function resolveRootForItem(item: GeneratorPreviewItem): "frontend" | "backend" { + return item.scope === "backend" ? "backend" : "frontend"; + } + + function stripProjectRoot(p: string): string { + const normalized = p.replace(/\\/g, "/"); + const frontApp = genConfigFormData.value.frontendAppName; + const backApp = genConfigFormData.value.backendAppName; + if (frontApp && normalized.startsWith(`${frontApp}/`)) + return normalized.slice(frontApp.length + 1); + if (backApp && normalized.startsWith(`${backApp}/`)) + return normalized.slice(backApp.length + 1); + const idx = normalized.indexOf("/src/"); + if (idx > -1) return normalized.slice(idx + 1); + if (normalized.startsWith("src/")) return normalized; + return normalized; + } + + // 逐级创建/获取目录句柄 + async function ensureDir(root: FileSystemDirectoryHandle, path: string[], create = true) { + let current = root; + for (const segment of path) { + current = await current.getDirectoryHandle(segment, { create }); + } + return current; + } + + async function writeFileToDir( + dirHandle: FileSystemDirectoryHandle, + filePath: string, + content: string + ) { + const normalized = filePath.replace(/\\/g, "/"); + const parts = normalized.split("/").filter(Boolean); + const fileName = parts.pop()!; + const targetDir = await ensureDir(dirHandle, parts, true); + const fileHandle = await targetDir.getFileHandle(fileName, { create: true }); + const writable = await fileHandle.createWritable(); + await writable.write(content ?? ""); + await writable.close(); + } + + async function pathExists( + dirHandle: FileSystemDirectoryHandle, + filePath: string + ): Promise { + try { + const normalized = filePath.replace(/\\/g, "/"); + const parts = normalized.split("/").filter(Boolean); + const fileName = parts.pop()!; + const targetDir = await ensureDir(dirHandle, parts, false); + await targetDir.getFileHandle(fileName, { create: false }); + return true; + } catch { + return false; + } + } + + async function isSameFile( + dirHandle: FileSystemDirectoryHandle, + filePath: string, + content: string + ): Promise { + try { + const normalized = filePath.replace(/\\/g, "/"); + const parts = normalized.split("/").filter(Boolean); + const fileName = parts.pop()!; + const targetDir = await ensureDir(dirHandle, parts, false); + const fileHandle = await targetDir.getFileHandle(fileName, { create: false }); + const file = await fileHandle.getFile(); + const text = await file.text(); + return text === (content ?? ""); + } catch { + return false; + } + } + + async function writeGeneratedCode() { + 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; + } + + 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) => writeScope.value === "all" || resolveRootForItem(f) === writeScope.value + ); + writeProgress.total = files.length; + writeProgress.done = 0; + writeProgress.percent = 0; + writeProgress.current = ""; + + const concurrency = 4; + const queue = files.slice(); + + async function worker() { + while (queue.length) { + const item = queue.shift()!; + try { + await (async () => { + const root = resolveRootForItem(item); + const relativePath = stripProjectRoot(`${item.path}/${item.fileName}`); + writeProgress.current = relativePath; + + const targetRoot = + root === "frontend" ? frontendDirHandle.value! : backendDirHandle.value!; + + if (overwriteMode.value === "ifChanged") { + const same = await isSameFile(targetRoot, relativePath, item.content || ""); + if (same) return; + } + if (overwriteMode.value === "skip") { + const exists = await pathExists(targetRoot, relativePath); + if (exists) return; + } + + await writeFileToDir(targetRoot, relativePath, item.content || ""); + if (root === "frontend") frontCount++; + else backCount++; + })().catch((err) => { + console.error("写入失败:", item.path, err); + failed.push(item.path); + }); + } finally { + writeProgress.done++; + writeProgress.percent = Math.round((writeProgress.done / writeProgress.total) * 100); + } + } + } + + await Promise.all(Array.from({ length: concurrency }, () => worker())); + loadingSvc.close(); + writeRunning.value = false; + + if (failed.length) { + ElMessage.warning( + `部分文件写入失败 ${failed.length} 个,成功 前端 ${frontCount} 个,后端 ${backCount} 个` + ); + } else { + ElMessage.success(`写入完成:前端 ${frontCount} 个文件,后端 ${backCount} 个文件`); + } + } + + return { + supportsFSAccess, + writeDialog, + frontendDirPath, + backendDirPath, + writeScope, + overwriteMode, + writeProgress, + writeRunning, + canWriteToLocal, + openWriteDialog, + setPreviewFiles, + pickFrontendDir, + pickBackendDir, + confirmWrite, + }; +} diff --git a/src/views/codegen/index.vue b/src/views/codegen/index.vue index 0ca4c807..710c3099 100644 --- a/src/views/codegen/index.vue +++ b/src/views/codegen/index.vue @@ -1,1352 +1,31 @@  diff --git a/src/views/codegen/utils/tree-builder.ts b/src/views/codegen/utils/tree-builder.ts new file mode 100644 index 00000000..5539a4a7 --- /dev/null +++ b/src/views/codegen/utils/tree-builder.ts @@ -0,0 +1,117 @@ +import type { GeneratorPreviewItem } from "@/api/codegen"; + +/** 文件树节点 */ +export interface TreeNode { + label: string; + key?: string; + content?: string; + children?: TreeNode[]; + scope?: "frontend" | "backend"; + language?: string; +} + +/** + * 递归构建文件树 + * 将扁平的预览文件列表转为树形结构 + */ +export function buildFileTree(data: GeneratorPreviewItem[]): TreeNode { + const root: TreeNode = { label: "前后端代码", key: "root", children: [] }; + + data.forEach((item) => { + const normalizedPath = item.path.replace(/\\/g, "/"); + const parts = normalizedPath.split("/").filter(Boolean); + + let currentNode = root; + let currentKey = root.key || "root"; + + parts.forEach((part) => { + let node = currentNode.children?.find((child) => child.label === part); + if (!node) { + currentKey = `${currentKey}/${part}`; + node = { label: part, key: currentKey, children: [] }; + currentNode.children?.push(node); + } else { + currentKey = node.key || `${currentKey}/${part}`; + } + currentNode = node; + }); + + currentNode.children?.push({ + label: item.fileName, + key: `${item.scope || ""}:${normalizedPath}/${item.fileName}`, + content: item?.content, + scope: item.scope, + language: item.language, + }); + }); + + return root; +} + +/** 递归查找第一个叶子节点 */ +export function findFirstLeaf(node: TreeNode): TreeNode | null { + if (!node.children || node.children.length === 0) { + return node; + } + for (const child of node.children) { + const leaf = findFirstLeaf(child); + if (leaf) return leaf; + } + return null; +} + +/** 根据 key 查找叶子节点 */ +export function findLeafByKey(nodes: TreeNode[], key: string): TreeNode | null { + for (const node of nodes) { + if (!node.children || node.children.length === 0) { + if (node.key === key) return node; + continue; + } + const found = findLeafByKey(node.children, key); + if (found) return found; + } + return null; +} + +/** 根据文件扩展名获取图标名 */ +export function getFileIcon(node: TreeNode): string { + const ext = (node.language || node.label.split(".").pop() || "").toLowerCase(); + const iconMap: Record = { + java: "java", + html: "html", + vue: "vue", + ts: "typescript", + xml: "xml", + }; + if (iconMap[ext]) return iconMap[ext]; + if (["cs", "go", "py", "php", "js"].includes(ext)) return "code"; + return "file"; +} + +/** + * 过滤树节点(基于 scope 和 language) + * 返回过滤后的新树 + */ +export function filterTree( + nodes: TreeNode[], + scope: "all" | "frontend" | "backend", + types: string[] +): TreeNode[] { + const match = (node: TreeNode): boolean => { + if (scope !== "all" && node.scope !== scope) return false; + if (!types.length) return true; + const language = node.language || node.label.split(".").pop() || ""; + return types.includes(language); + }; + + const cloneFilter = (node: TreeNode): TreeNode | null => { + if (!node.children || node.children.length === 0) { + return match(node) ? { ...node } : null; + } + const children = node.children.map((c) => cloneFilter(c)).filter(Boolean) as TreeNode[]; + if (!children.length) return null; + return { label: node.label, key: node.key, children }; + }; + + return nodes.map((n) => cloneFilter(n)).filter(Boolean) as TreeNode[]; +}