feat(codegen): ✨ 代码生成支持本地写入和代码优化
This commit is contained in:
@@ -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();
|
||||
|
||||
Reference in New Issue
Block a user