refactor: 代码生成页面布局重构优化
This commit is contained in:
1 parent
8419f1ac2b
commit
e4b6166e62
12 files changed
+2483
-1337
No files matched your search
+1
-1
@@ -1,7 +1,7 @@
|
|||||||
{
|
{
|
||||||
"name": "vue3-element-admin",
|
"name": "vue3-element-admin",
|
||||||
"description": "Vue3 + Vite + TypeScript + Element-Plus 的后台管理模板,vue-element-admin 的 Vue3 版本",
|
"description": "Vue3 + Vite + TypeScript + Element-Plus 的后台管理模板,vue-element-admin 的 Vue3 版本",
|
||||||
"version": "4.4.1",
|
"version": "4.5.0",
|
||||||
"private": true,
|
"private": true,
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
|
|||||||
@@ -0,0 +1,291 @@
|
|||||||
|
<template>
|
||||||
|
<div class="basic-config-step">
|
||||||
|
<!-- 表信息卡片 -->
|
||||||
|
<div class="config-card">
|
||||||
|
<div class="card-header">
|
||||||
|
<div class="header-icon icon-table">
|
||||||
|
<el-icon><Grid /></el-icon>
|
||||||
|
</div>
|
||||||
|
<div class="header-title">
|
||||||
|
<div class="title">表信息</div>
|
||||||
|
<div class="subtitle">数据库表名与业务映射</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<el-form :model="formData" :rules="rules" :label-width="100" class="card-form">
|
||||||
|
<el-row :gutter="24">
|
||||||
|
<el-col :span="12">
|
||||||
|
<el-form-item label="表名" prop="tableName">
|
||||||
|
<el-input v-model="formData.tableName" readonly>
|
||||||
|
<template #prefix>
|
||||||
|
<el-icon><Document /></el-icon>
|
||||||
|
</template>
|
||||||
|
</el-input>
|
||||||
|
</el-form-item>
|
||||||
|
</el-col>
|
||||||
|
<el-col :span="12">
|
||||||
|
<el-form-item label="业务名" prop="businessName">
|
||||||
|
<el-input v-model="formData.businessName" placeholder="如:用户管理">
|
||||||
|
<template #prefix>
|
||||||
|
<el-icon><OfficeBuilding /></el-icon>
|
||||||
|
</template>
|
||||||
|
</el-input>
|
||||||
|
</el-form-item>
|
||||||
|
</el-col>
|
||||||
|
</el-row>
|
||||||
|
</el-form>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- 包信息卡片 -->
|
||||||
|
<div class="config-card">
|
||||||
|
<div class="card-header">
|
||||||
|
<div class="header-icon icon-package">
|
||||||
|
<el-icon><Box /></el-icon>
|
||||||
|
</div>
|
||||||
|
<div class="header-title">
|
||||||
|
<div class="title">包信息</div>
|
||||||
|
<div class="subtitle">Java 包结构与模块划分</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<el-form :model="formData" :rules="rules" :label-width="100" class="card-form">
|
||||||
|
<el-row :gutter="24">
|
||||||
|
<el-col :span="12">
|
||||||
|
<el-form-item label="主包名" prop="packageName">
|
||||||
|
<el-input v-model="formData.packageName" placeholder="com.youlai.boot">
|
||||||
|
<template #prefix>
|
||||||
|
<el-icon><Folder /></el-icon>
|
||||||
|
</template>
|
||||||
|
</el-input>
|
||||||
|
</el-form-item>
|
||||||
|
</el-col>
|
||||||
|
<el-col :span="12">
|
||||||
|
<el-form-item label="模块名" prop="moduleName">
|
||||||
|
<el-input v-model="formData.moduleName" placeholder="system">
|
||||||
|
<template #prefix>
|
||||||
|
<el-icon><Collection /></el-icon>
|
||||||
|
</template>
|
||||||
|
</el-input>
|
||||||
|
</el-form-item>
|
||||||
|
</el-col>
|
||||||
|
</el-row>
|
||||||
|
</el-form>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- 生成配置卡片 -->
|
||||||
|
<div class="config-card">
|
||||||
|
<div class="card-header">
|
||||||
|
<div class="header-icon icon-gen">
|
||||||
|
<el-icon><MagicStick /></el-icon>
|
||||||
|
</div>
|
||||||
|
<div class="header-title">
|
||||||
|
<div class="title">生成配置</div>
|
||||||
|
<div class="subtitle">代码生成规则与输出选项</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<el-form ref="formRef" :model="formData" :rules="rules" :label-width="100" class="card-form">
|
||||||
|
<el-row :gutter="24">
|
||||||
|
<el-col :span="12">
|
||||||
|
<el-form-item label="实体名" prop="entityName">
|
||||||
|
<el-input v-model="formData.entityName" placeholder="User">
|
||||||
|
<template #prefix>
|
||||||
|
<el-icon><Coin /></el-icon>
|
||||||
|
</template>
|
||||||
|
</el-input>
|
||||||
|
</el-form-item>
|
||||||
|
</el-col>
|
||||||
|
<el-col :span="12">
|
||||||
|
<el-form-item label="作者">
|
||||||
|
<el-input v-model="formData.author" placeholder="youlai">
|
||||||
|
<template #prefix>
|
||||||
|
<el-icon><User /></el-icon>
|
||||||
|
</template>
|
||||||
|
</el-input>
|
||||||
|
</el-form-item>
|
||||||
|
</el-col>
|
||||||
|
</el-row>
|
||||||
|
<el-row :gutter="24">
|
||||||
|
<el-col :span="12">
|
||||||
|
<el-form-item label="移除表前缀">
|
||||||
|
<el-input v-model="formData.removeTablePrefix" placeholder="如: sys_">
|
||||||
|
<template #prefix>
|
||||||
|
<el-icon><Delete /></el-icon>
|
||||||
|
</template>
|
||||||
|
</el-input>
|
||||||
|
</el-form-item>
|
||||||
|
</el-col>
|
||||||
|
<el-col :span="12">
|
||||||
|
<el-form-item label="页面类型">
|
||||||
|
<el-radio-group v-model="formData.pageType" size="large">
|
||||||
|
<el-radio-button value="classic">
|
||||||
|
<el-icon><DocumentChecked /></el-icon>
|
||||||
|
普通
|
||||||
|
</el-radio-button>
|
||||||
|
<el-radio-button value="curd">
|
||||||
|
<el-icon><SetUp /></el-icon>
|
||||||
|
封装(CURD)
|
||||||
|
</el-radio-button>
|
||||||
|
</el-radio-group>
|
||||||
|
</el-form-item>
|
||||||
|
</el-col>
|
||||||
|
</el-row>
|
||||||
|
<el-row :gutter="24">
|
||||||
|
<el-col :span="12">
|
||||||
|
<el-form-item>
|
||||||
|
<template #label>
|
||||||
|
<div class="flex items-center gap-2">
|
||||||
|
<span>上级菜单</span>
|
||||||
|
<el-tooltip effect="dark" placement="top">
|
||||||
|
<template #content>
|
||||||
|
<div style="max-width: 280px; line-height: 1.8">
|
||||||
|
选择上级菜单,生成代码后会自动创建对应菜单。
|
||||||
|
<br />
|
||||||
|
注意:生成菜单后需分配权限给角色,否则菜单将无法显示。
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
<el-icon class="cursor-pointer text-gray-400 hover:text-primary">
|
||||||
|
<QuestionFilled />
|
||||||
|
</el-icon>
|
||||||
|
</el-tooltip>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
<el-tree-select
|
||||||
|
v-model="formData.parentMenuId"
|
||||||
|
placeholder="选择上级菜单"
|
||||||
|
:data="menuOptions"
|
||||||
|
check-strictly
|
||||||
|
:render-after-expand="false"
|
||||||
|
filterable
|
||||||
|
clearable
|
||||||
|
/>
|
||||||
|
</el-form-item>
|
||||||
|
</el-col>
|
||||||
|
</el-row>
|
||||||
|
</el-form>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup lang="ts">
|
||||||
|
import type { GenConfigForm } from "@/api/codegen";
|
||||||
|
|
||||||
|
const formData = defineModel<GenConfigForm>({ required: true });
|
||||||
|
|
||||||
|
defineProps<{
|
||||||
|
menuOptions: OptionItem[];
|
||||||
|
}>();
|
||||||
|
|
||||||
|
const formRef = ref();
|
||||||
|
|
||||||
|
const rules = {
|
||||||
|
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" }],
|
||||||
|
};
|
||||||
|
|
||||||
|
async function validate(): Promise<boolean> {
|
||||||
|
try {
|
||||||
|
await formRef.value?.validate();
|
||||||
|
return true;
|
||||||
|
} catch {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
defineExpose({ validate });
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style scoped lang="scss">
|
||||||
|
.basic-config-step {
|
||||||
|
padding: 8px;
|
||||||
|
|
||||||
|
.config-card {
|
||||||
|
padding: 24px;
|
||||||
|
margin-bottom: 20px;
|
||||||
|
background: var(--el-bg-color);
|
||||||
|
border: 1px solid var(--el-border-color-lighter);
|
||||||
|
border-radius: 12px;
|
||||||
|
transition: all 0.3s ease;
|
||||||
|
|
||||||
|
&:hover {
|
||||||
|
border-color: var(--el-border-color);
|
||||||
|
box-shadow: 0 4px 20px rgba(0, 0, 0, 0.06);
|
||||||
|
}
|
||||||
|
|
||||||
|
.card-header {
|
||||||
|
display: flex;
|
||||||
|
gap: 14px;
|
||||||
|
align-items: center;
|
||||||
|
padding-bottom: 16px;
|
||||||
|
margin-bottom: 20px;
|
||||||
|
border-bottom: 1px solid var(--el-border-color-lighter);
|
||||||
|
|
||||||
|
.header-icon {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
width: 44px;
|
||||||
|
height: 44px;
|
||||||
|
font-size: 20px;
|
||||||
|
border-radius: 10px;
|
||||||
|
transition: transform 0.3s ease;
|
||||||
|
|
||||||
|
&.icon-table {
|
||||||
|
color: var(--el-color-primary);
|
||||||
|
background: linear-gradient(
|
||||||
|
135deg,
|
||||||
|
var(--el-color-primary-light-8),
|
||||||
|
var(--el-color-primary-light-9)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
&.icon-package {
|
||||||
|
color: var(--el-color-success);
|
||||||
|
background: linear-gradient(
|
||||||
|
135deg,
|
||||||
|
var(--el-color-success-light-8),
|
||||||
|
var(--el-color-success-light-9)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
&.icon-gen {
|
||||||
|
color: var(--el-color-warning);
|
||||||
|
background: linear-gradient(
|
||||||
|
135deg,
|
||||||
|
var(--el-color-warning-light-8),
|
||||||
|
var(--el-color-warning-light-9)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
&:hover .header-icon {
|
||||||
|
transform: scale(1.08) rotate(-3deg);
|
||||||
|
}
|
||||||
|
|
||||||
|
.header-title {
|
||||||
|
.title {
|
||||||
|
margin-bottom: 4px;
|
||||||
|
font-size: 16px;
|
||||||
|
font-weight: 600;
|
||||||
|
color: var(--el-text-color-primary);
|
||||||
|
}
|
||||||
|
.subtitle {
|
||||||
|
font-size: 13px;
|
||||||
|
color: var(--el-text-color-secondary);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.card-form {
|
||||||
|
:deep(.el-input__prefix-inner) {
|
||||||
|
color: var(--el-text-color-secondary);
|
||||||
|
}
|
||||||
|
|
||||||
|
:deep(.el-radio-button__inner) {
|
||||||
|
display: inline-flex;
|
||||||
|
gap: 4px;
|
||||||
|
align-items: center;
|
||||||
|
padding: 10px 20px;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@@ -0,0 +1,419 @@
|
|||||||
|
<template>
|
||||||
|
<div class="field-config-step">
|
||||||
|
<!-- 顶部统计栏 -->
|
||||||
|
<div class="stats-bar">
|
||||||
|
<div class="stat-item">
|
||||||
|
<div class="stat-icon bg-primary">
|
||||||
|
<el-icon><Tickets /></el-icon>
|
||||||
|
</div>
|
||||||
|
<div class="stat-info">
|
||||||
|
<div class="stat-value">{{ fieldConfigs.length }}</div>
|
||||||
|
<div class="stat-label">字段总数</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="stat-item">
|
||||||
|
<div class="stat-icon bg-success">
|
||||||
|
<el-icon><Search /></el-icon>
|
||||||
|
</div>
|
||||||
|
<div class="stat-info">
|
||||||
|
<div class="stat-value">{{ queryCount }}</div>
|
||||||
|
<div class="stat-label">查询字段</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="stat-item">
|
||||||
|
<div class="stat-icon bg-warning">
|
||||||
|
<el-icon><List /></el-icon>
|
||||||
|
</div>
|
||||||
|
<div class="stat-info">
|
||||||
|
<div class="stat-value">{{ listCount }}</div>
|
||||||
|
<div class="stat-label">列表字段</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="stat-item">
|
||||||
|
<div class="stat-icon bg-info">
|
||||||
|
<el-icon><EditPen /></el-icon>
|
||||||
|
</div>
|
||||||
|
<div class="stat-info">
|
||||||
|
<div class="stat-value">{{ formCount }}</div>
|
||||||
|
<div class="stat-label">表单字段</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- 批量操作 -->
|
||||||
|
<div class="bulk-actions">
|
||||||
|
<span class="text-sm text-gray-500">批量:</span>
|
||||||
|
<el-dropdown @command="(cmd: any) => bulkSet(cmd.key, cmd.value)">
|
||||||
|
<el-button size="small" type="primary" plain>
|
||||||
|
<el-icon><Search /></el-icon>
|
||||||
|
查询
|
||||||
|
</el-button>
|
||||||
|
<template #dropdown>
|
||||||
|
<el-dropdown-menu>
|
||||||
|
<el-dropdown-item :command="{ key: 'isShowInQuery', value: 1 }">
|
||||||
|
全选
|
||||||
|
</el-dropdown-item>
|
||||||
|
<el-dropdown-item :command="{ key: 'isShowInQuery', value: 0 }">
|
||||||
|
全不选
|
||||||
|
</el-dropdown-item>
|
||||||
|
</el-dropdown-menu>
|
||||||
|
</template>
|
||||||
|
</el-dropdown>
|
||||||
|
<el-dropdown @command="(cmd: any) => bulkSet(cmd.key, cmd.value)">
|
||||||
|
<el-button size="small" type="success" plain>
|
||||||
|
<el-icon><List /></el-icon>
|
||||||
|
列表
|
||||||
|
</el-button>
|
||||||
|
<template #dropdown>
|
||||||
|
<el-dropdown-menu>
|
||||||
|
<el-dropdown-item :command="{ key: 'isShowInList', value: 1 }">全选</el-dropdown-item>
|
||||||
|
<el-dropdown-item :command="{ key: 'isShowInList', value: 0 }">
|
||||||
|
全不选
|
||||||
|
</el-dropdown-item>
|
||||||
|
</el-dropdown-menu>
|
||||||
|
</template>
|
||||||
|
</el-dropdown>
|
||||||
|
<el-dropdown @command="(cmd: any) => bulkSet(cmd.key, cmd.value)">
|
||||||
|
<el-button size="small" type="warning" plain>
|
||||||
|
<el-icon><EditPen /></el-icon>
|
||||||
|
表单
|
||||||
|
</el-button>
|
||||||
|
<template #dropdown>
|
||||||
|
<el-dropdown-menu>
|
||||||
|
<el-dropdown-item :command="{ key: 'isShowInForm', value: 1 }">全选</el-dropdown-item>
|
||||||
|
<el-dropdown-item :command="{ key: 'isShowInForm', value: 0 }">
|
||||||
|
全不选
|
||||||
|
</el-dropdown-item>
|
||||||
|
</el-dropdown-menu>
|
||||||
|
</template>
|
||||||
|
</el-dropdown>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- 字段表格 -->
|
||||||
|
<el-table
|
||||||
|
ref="tableRef"
|
||||||
|
v-loading="loading"
|
||||||
|
:data="fieldConfigs"
|
||||||
|
:element-loading-text="loadingText"
|
||||||
|
highlight-current-row
|
||||||
|
class="field-table"
|
||||||
|
>
|
||||||
|
<!-- 拖拽手柄 -->
|
||||||
|
<el-table-column width="48" align="center">
|
||||||
|
<template #default>
|
||||||
|
<el-icon class="cursor-move sortable-handle text-gray-400 hover:text-primary">
|
||||||
|
<Rank />
|
||||||
|
</el-icon>
|
||||||
|
</template>
|
||||||
|
</el-table-column>
|
||||||
|
|
||||||
|
<!-- 字段信息 -->
|
||||||
|
<el-table-column label="字段信息" min-width="360">
|
||||||
|
<template #default="{ row }">
|
||||||
|
<div class="flex items-start gap-3">
|
||||||
|
<div class="field-info" style=" flex-shrink: 0;width: 140px">
|
||||||
|
<div class="flex items-center gap-2">
|
||||||
|
<span class="font-medium text-sm">{{ row.columnName }}</span>
|
||||||
|
<el-tag v-if="row.isPrimaryKey" size="small" type="warning" effect="dark">
|
||||||
|
主键
|
||||||
|
</el-tag>
|
||||||
|
</div>
|
||||||
|
<div class="text-xs text-gray-400 font-mono mt-1">
|
||||||
|
{{ row.columnType }} → {{ row.fieldType }}
|
||||||
|
<span v-if="row.maxLength">({{ row.maxLength }})</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="flex-1 flex flex-col gap-1.5">
|
||||||
|
<div class="flex items-center gap-2">
|
||||||
|
<span class="text-xs text-gray-500 w-10 text-right">字段名</span>
|
||||||
|
<el-input v-model="row.fieldName" size="small" style="width: 130px" />
|
||||||
|
</div>
|
||||||
|
<div class="flex items-center gap-2">
|
||||||
|
<span class="text-xs text-gray-500 w-10 text-right">注释</span>
|
||||||
|
<el-input v-model="row.fieldComment" size="small" style="width: 130px" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
</el-table-column>
|
||||||
|
|
||||||
|
<!-- 查询 -->
|
||||||
|
<el-table-column label="查询" width="60" align="center">
|
||||||
|
<template #default="{ row }">
|
||||||
|
<el-checkbox v-model="row.isShowInQuery" :true-value="1" :false-value="0" />
|
||||||
|
</template>
|
||||||
|
</el-table-column>
|
||||||
|
|
||||||
|
<!-- 查询方式 -->
|
||||||
|
<el-table-column label="查询方式" width="120">
|
||||||
|
<template #default="{ row }">
|
||||||
|
<el-select
|
||||||
|
v-model="row.queryType"
|
||||||
|
:disabled="row.isShowInQuery !== 1"
|
||||||
|
size="small"
|
||||||
|
placeholder=""
|
||||||
|
>
|
||||||
|
<el-option
|
||||||
|
v-for="(item, key) in queryTypeOptions"
|
||||||
|
:key="key"
|
||||||
|
:label="item.label"
|
||||||
|
:value="item.value"
|
||||||
|
/>
|
||||||
|
</el-select>
|
||||||
|
</template>
|
||||||
|
</el-table-column>
|
||||||
|
|
||||||
|
<!-- 列表 -->
|
||||||
|
<el-table-column label="列表" width="60" align="center">
|
||||||
|
<template #default="{ row }">
|
||||||
|
<el-checkbox v-model="row.isShowInList" :true-value="1" :false-value="0" />
|
||||||
|
</template>
|
||||||
|
</el-table-column>
|
||||||
|
|
||||||
|
<!-- 表单 -->
|
||||||
|
<el-table-column label="表单" width="60" align="center">
|
||||||
|
<template #default="{ row }">
|
||||||
|
<el-checkbox v-model="row.isShowInForm" :true-value="1" :false-value="0" />
|
||||||
|
</template>
|
||||||
|
</el-table-column>
|
||||||
|
|
||||||
|
<!-- 表单类型 -->
|
||||||
|
<el-table-column label="表单类型" width="120">
|
||||||
|
<template #default="{ row }">
|
||||||
|
<el-select
|
||||||
|
v-model="row.formType"
|
||||||
|
:disabled="row.isShowInForm !== 1 && row.isShowInQuery !== 1"
|
||||||
|
size="small"
|
||||||
|
placeholder=""
|
||||||
|
>
|
||||||
|
<el-option
|
||||||
|
v-for="(item, key) in formTypeOptions"
|
||||||
|
:key="key"
|
||||||
|
:label="item.label"
|
||||||
|
:value="item.value"
|
||||||
|
/>
|
||||||
|
</el-select>
|
||||||
|
</template>
|
||||||
|
</el-table-column>
|
||||||
|
|
||||||
|
<!-- 字典类型 -->
|
||||||
|
<el-table-column label="字典类型" width="120">
|
||||||
|
<template #default="{ row }">
|
||||||
|
<el-select
|
||||||
|
v-if="row.formType === FormTypeEnum.SELECT.value"
|
||||||
|
v-model="row.dictType"
|
||||||
|
clearable
|
||||||
|
size="small"
|
||||||
|
placeholder="请选择"
|
||||||
|
>
|
||||||
|
<el-option
|
||||||
|
v-for="item in dictOptions"
|
||||||
|
:key="item.value"
|
||||||
|
:label="item.label"
|
||||||
|
:value="item.value"
|
||||||
|
/>
|
||||||
|
</el-select>
|
||||||
|
<span v-else class="text-gray-300 text-xs">-</span>
|
||||||
|
</template>
|
||||||
|
</el-table-column>
|
||||||
|
|
||||||
|
<!-- 必填 -->
|
||||||
|
<el-table-column label="必填" width="60" align="center">
|
||||||
|
<template #default="{ row }">
|
||||||
|
<el-switch
|
||||||
|
v-model="row.isRequired"
|
||||||
|
:active-value="1"
|
||||||
|
:inactive-value="0"
|
||||||
|
:disabled="row.isShowInForm !== 1"
|
||||||
|
size="small"
|
||||||
|
/>
|
||||||
|
</template>
|
||||||
|
</el-table-column>
|
||||||
|
</el-table>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup lang="ts">
|
||||||
|
import Sortable from "sortablejs";
|
||||||
|
import { FormTypeEnum, QueryTypeEnum } from "@/enums/codegen";
|
||||||
|
import type { GenConfigForm } from "@/api/codegen";
|
||||||
|
|
||||||
|
const formData = defineModel<GenConfigForm>({ required: true });
|
||||||
|
|
||||||
|
defineProps<{
|
||||||
|
loading: boolean;
|
||||||
|
loadingText: string;
|
||||||
|
dictOptions?: OptionItem[];
|
||||||
|
}>();
|
||||||
|
|
||||||
|
const formTypeOptions: Record<string, OptionItem> = FormTypeEnum;
|
||||||
|
const queryTypeOptions: Record<string, OptionItem> = QueryTypeEnum;
|
||||||
|
|
||||||
|
const tableRef = ref();
|
||||||
|
const sortFlag = ref<Sortable | null>(null);
|
||||||
|
|
||||||
|
const fieldConfigs = computed(() => formData.value?.fieldConfigs || []);
|
||||||
|
|
||||||
|
// 统计数量
|
||||||
|
const queryCount = computed(() => fieldConfigs.value.filter((f) => f.isShowInQuery === 1).length);
|
||||||
|
const listCount = computed(() => fieldConfigs.value.filter((f) => f.isShowInList === 1).length);
|
||||||
|
const formCount = computed(() => fieldConfigs.value.filter((f) => f.isShowInForm === 1).length);
|
||||||
|
|
||||||
|
// 批量设置
|
||||||
|
function bulkSet(key: "isShowInQuery" | "isShowInList" | "isShowInForm", value: 0 | 1) {
|
||||||
|
fieldConfigs.value.forEach((row) => {
|
||||||
|
row[key] = value;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// 用 Sortable.js 实现行拖拽排序,需要在字段配置步骤显示后调用
|
||||||
|
function initSort() {
|
||||||
|
if (sortFlag.value) return;
|
||||||
|
const tbody = tableRef.value?.$el?.querySelector(".el-table__body-wrapper tbody");
|
||||||
|
if (!tbody) return;
|
||||||
|
sortFlag.value = Sortable.create(tbody, {
|
||||||
|
animation: 150,
|
||||||
|
ghostClass: "sortable-ghost",
|
||||||
|
handle: ".sortable-handle",
|
||||||
|
easing: "cubic-bezier(1, 0, 0, 1)",
|
||||||
|
onEnd: (evt: any) => {
|
||||||
|
const { oldIndex, newIndex } = evt;
|
||||||
|
if (oldIndex === undefined || newIndex === undefined || oldIndex === newIndex) return;
|
||||||
|
const list = formData.value?.fieldConfigs || [];
|
||||||
|
const [item] = list.splice(oldIndex, 1);
|
||||||
|
list.splice(newIndex, 0, item);
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function destroySort() {
|
||||||
|
sortFlag.value?.destroy();
|
||||||
|
sortFlag.value = null;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 暴露给父组件
|
||||||
|
defineExpose({ initSort, destroySort });
|
||||||
|
|
||||||
|
onBeforeUnmount(() => {
|
||||||
|
destroySort();
|
||||||
|
});
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style scoped lang="scss">
|
||||||
|
.field-config-step {
|
||||||
|
.stats-bar {
|
||||||
|
display: flex;
|
||||||
|
gap: 16px;
|
||||||
|
align-items: center;
|
||||||
|
padding: 16px 20px;
|
||||||
|
margin-bottom: 16px;
|
||||||
|
background: var(--el-bg-color);
|
||||||
|
border: 1px solid var(--el-border-color-lighter);
|
||||||
|
border-radius: 12px;
|
||||||
|
|
||||||
|
.stat-item {
|
||||||
|
display: flex;
|
||||||
|
gap: 10px;
|
||||||
|
align-items: center;
|
||||||
|
padding: 0 16px;
|
||||||
|
border-right: 1px solid var(--el-border-color-lighter);
|
||||||
|
|
||||||
|
&:last-of-type {
|
||||||
|
border-right: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.stat-icon {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
width: 38px;
|
||||||
|
height: 38px;
|
||||||
|
font-size: 18px;
|
||||||
|
color: #fff;
|
||||||
|
border-radius: 10px;
|
||||||
|
|
||||||
|
&.bg-primary {
|
||||||
|
background: linear-gradient(
|
||||||
|
135deg,
|
||||||
|
var(--el-color-primary),
|
||||||
|
var(--el-color-primary-light-3)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
&.bg-success {
|
||||||
|
background: linear-gradient(
|
||||||
|
135deg,
|
||||||
|
var(--el-color-success),
|
||||||
|
var(--el-color-success-light-3)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
&.bg-warning {
|
||||||
|
background: linear-gradient(
|
||||||
|
135deg,
|
||||||
|
var(--el-color-warning),
|
||||||
|
var(--el-color-warning-light-3)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
&.bg-info {
|
||||||
|
background: linear-gradient(135deg, var(--el-color-info), var(--el-color-info-light-3));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.stat-info {
|
||||||
|
.stat-value {
|
||||||
|
font-size: 20px;
|
||||||
|
font-weight: 700;
|
||||||
|
line-height: 1.2;
|
||||||
|
color: var(--el-text-color-primary);
|
||||||
|
}
|
||||||
|
.stat-label {
|
||||||
|
margin-top: 2px;
|
||||||
|
font-size: 12px;
|
||||||
|
color: var(--el-text-color-secondary);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.bulk-actions {
|
||||||
|
display: flex;
|
||||||
|
gap: 8px;
|
||||||
|
align-items: center;
|
||||||
|
margin-left: auto;
|
||||||
|
|
||||||
|
:deep(.el-button) {
|
||||||
|
display: inline-flex;
|
||||||
|
gap: 4px;
|
||||||
|
align-items: center;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.field-table {
|
||||||
|
overflow: hidden;
|
||||||
|
border: 1px solid var(--el-border-color-lighter);
|
||||||
|
border-radius: 12px;
|
||||||
|
|
||||||
|
:deep(.el-table__header) {
|
||||||
|
th {
|
||||||
|
font-size: 13px;
|
||||||
|
font-weight: 600;
|
||||||
|
color: var(--el-text-color-primary);
|
||||||
|
background: var(--el-fill-color-light);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
:deep(.el-table__row) {
|
||||||
|
transition: background 0.2s ease;
|
||||||
|
|
||||||
|
&:hover {
|
||||||
|
background: var(--el-fill-color-lighter) !important;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.sortable-ghost {
|
||||||
|
background: var(--el-color-primary-light-9) !important;
|
||||||
|
border: 1px dashed var(--el-color-primary);
|
||||||
|
opacity: 0.5;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@@ -0,0 +1,280 @@
|
|||||||
|
<template>
|
||||||
|
<el-drawer v-model="visible" :title="title" size="90%" destroy-on-close @close="handleClose">
|
||||||
|
<!-- 步骤导航 -->
|
||||||
|
<el-steps :active="currentStep" align-center finish-status="success">
|
||||||
|
<el-step v-for="step in STEPS" :key="step.step">
|
||||||
|
<template #icon>
|
||||||
|
<el-icon :size="20"><component :is="step.icon" /></el-icon>
|
||||||
|
</template>
|
||||||
|
<template #title>{{ step.title }}</template>
|
||||||
|
<template #description>{{ step.description }}</template>
|
||||||
|
</el-step>
|
||||||
|
</el-steps>
|
||||||
|
|
||||||
|
<!-- 步骤内容 -->
|
||||||
|
<div class="drawer-content mt-5">
|
||||||
|
<BasicConfigStep
|
||||||
|
v-show="currentStep === STEP.BASIC_CONFIG"
|
||||||
|
ref="basicConfigRef"
|
||||||
|
v-model="genConfigFormData"
|
||||||
|
:menu-options="menuOptions"
|
||||||
|
/>
|
||||||
|
|
||||||
|
<FieldConfigStep
|
||||||
|
v-show="currentStep === STEP.FIELD_CONFIG"
|
||||||
|
ref="fieldConfigRef"
|
||||||
|
v-model="genConfigFormData"
|
||||||
|
:loading="loading"
|
||||||
|
:loading-text="loadingText"
|
||||||
|
:dict-options="dictOptions"
|
||||||
|
/>
|
||||||
|
|
||||||
|
<PreviewStep
|
||||||
|
v-show="currentStep === STEP.PREVIEW"
|
||||||
|
ref="previewRef"
|
||||||
|
:gen-config-form-data="genConfigFormData"
|
||||||
|
:preview-scope="previewScope"
|
||||||
|
:preview-types="previewTypes"
|
||||||
|
:preview-type-options="previewTypeOptions"
|
||||||
|
:filtered-tree-data="filteredTreeData"
|
||||||
|
:code="code"
|
||||||
|
:current-file-key="currentFileKey"
|
||||||
|
:table-name="currentTableName"
|
||||||
|
@update:preview-scope="previewScope = $event"
|
||||||
|
@update:preview-types="previewTypes = $event"
|
||||||
|
@file-click="handleFileTreeNodeClick"
|
||||||
|
@copy="handleCopyCode"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- 底部操作栏 -->
|
||||||
|
<template #footer>
|
||||||
|
<div class="drawer-footer">
|
||||||
|
<div>
|
||||||
|
<el-button v-if="currentStep > STEP.BASIC_CONFIG" @click="handlePrev">
|
||||||
|
<el-icon><Back /></el-icon>
|
||||||
|
{{ STEPS[currentStep].prevText }}
|
||||||
|
</el-button>
|
||||||
|
</div>
|
||||||
|
<div class="flex gap-3">
|
||||||
|
<el-button @click="handleClose">取消</el-button>
|
||||||
|
<el-button type="primary" :loading="loading" @click="handleNext">
|
||||||
|
{{ STEPS[currentStep].nextText }}
|
||||||
|
<el-icon v-if="currentStep < STEP.PREVIEW"><Right /></el-icon>
|
||||||
|
<el-icon v-else><Download /></el-icon>
|
||||||
|
</el-button>
|
||||||
|
<el-button
|
||||||
|
v-if="currentStep === STEP.PREVIEW"
|
||||||
|
type="primary"
|
||||||
|
plain
|
||||||
|
:disabled="!canWriteToLocal"
|
||||||
|
@click="openWriteDialog()"
|
||||||
|
>
|
||||||
|
<template #icon><FolderOpened /></template>
|
||||||
|
写入本地
|
||||||
|
</el-button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<!-- 写入本地对话框 -->
|
||||||
|
<WriteLocalDialog
|
||||||
|
v-model="writeDialogVisible"
|
||||||
|
:can-write-to-local="canWriteToLocal"
|
||||||
|
:supports-f-s-access="supportsFSAccess"
|
||||||
|
:frontend-dir-path="frontendDirPath"
|
||||||
|
:backend-dir-path="backendDirPath"
|
||||||
|
:write-scope="writeScope"
|
||||||
|
:overwrite-mode="overwriteMode"
|
||||||
|
:write-progress="writeProgress"
|
||||||
|
:write-running="writeRunning"
|
||||||
|
@pick-frontend-dir="pickFrontendDir"
|
||||||
|
@pick-backend-dir="pickBackendDir"
|
||||||
|
@confirm-write="confirmWrite"
|
||||||
|
/>
|
||||||
|
</el-drawer>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup lang="ts">
|
||||||
|
import GeneratorAPI from "@/api/codegen";
|
||||||
|
import { useGenConfig } from "../composables/useGenConfig";
|
||||||
|
import { useCodePreview } from "../composables/useCodePreview";
|
||||||
|
import { useLocalWrite } from "../composables/useLocalWrite";
|
||||||
|
|
||||||
|
const STEP = { BASIC_CONFIG: 0, FIELD_CONFIG: 1, PREVIEW: 2 } as const;
|
||||||
|
const STEPS = [
|
||||||
|
{
|
||||||
|
step: 0,
|
||||||
|
title: "基础配置",
|
||||||
|
description: "配置表信息和生成选项",
|
||||||
|
icon: "Setting",
|
||||||
|
prevText: "",
|
||||||
|
nextText: "下一步,字段配置",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
step: 1,
|
||||||
|
title: "字段配置",
|
||||||
|
description: "配置字段显示和表单类型",
|
||||||
|
icon: "Grid",
|
||||||
|
prevText: "上一步,基础配置",
|
||||||
|
nextText: "下一步,确认生成",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
step: 2,
|
||||||
|
title: "预览生成",
|
||||||
|
description: "预览代码并下载",
|
||||||
|
icon: "View",
|
||||||
|
prevText: "上一步,字段配置",
|
||||||
|
nextText: "下载代码",
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
const visible = defineModel<boolean>("visible", { required: true });
|
||||||
|
|
||||||
|
defineProps<{ title: string }>();
|
||||||
|
defineEmits<{ success: [] }>();
|
||||||
|
|
||||||
|
const currentStep = ref(STEP.BASIC_CONFIG);
|
||||||
|
const currentTableName = ref("");
|
||||||
|
const loading = ref(false);
|
||||||
|
const loadingText = ref("loading...");
|
||||||
|
|
||||||
|
const basicConfigRef = ref();
|
||||||
|
const fieldConfigRef = ref();
|
||||||
|
const previewRef = ref();
|
||||||
|
|
||||||
|
const { genConfigFormData, menuOptions, dictOptions, loadConfig, saveConfig, validateBasic } =
|
||||||
|
useGenConfig();
|
||||||
|
|
||||||
|
const {
|
||||||
|
filteredTreeData,
|
||||||
|
previewScope,
|
||||||
|
previewTypes,
|
||||||
|
previewTypeOptions,
|
||||||
|
code,
|
||||||
|
currentFileKey,
|
||||||
|
handlePreview,
|
||||||
|
handleFileTreeNodeClick,
|
||||||
|
handleCopyCode,
|
||||||
|
} = useCodePreview(genConfigFormData);
|
||||||
|
|
||||||
|
const {
|
||||||
|
supportsFSAccess,
|
||||||
|
writeDialog,
|
||||||
|
frontendDirPath,
|
||||||
|
backendDirPath,
|
||||||
|
writeScope,
|
||||||
|
overwriteMode,
|
||||||
|
writeProgress,
|
||||||
|
writeRunning,
|
||||||
|
canWriteToLocal,
|
||||||
|
openWriteDialog,
|
||||||
|
setPreviewFiles,
|
||||||
|
pickFrontendDir,
|
||||||
|
pickBackendDir,
|
||||||
|
confirmWrite,
|
||||||
|
} = useLocalWrite(genConfigFormData);
|
||||||
|
|
||||||
|
const writeDialogVisible = computed({
|
||||||
|
get: () => writeDialog.visible,
|
||||||
|
set: (val) => {
|
||||||
|
writeDialog.visible = val;
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
watch(currentStep, (val) => {
|
||||||
|
if (val === STEP.FIELD_CONFIG) {
|
||||||
|
nextTick(() => fieldConfigRef.value?.initSort());
|
||||||
|
}
|
||||||
|
if (val === STEP.PREVIEW) {
|
||||||
|
nextTick(() => previewRef.value?.refreshEditor());
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
async function open(tableName: string) {
|
||||||
|
currentTableName.value = tableName;
|
||||||
|
currentStep.value = STEP.BASIC_CONFIG;
|
||||||
|
loading.value = true;
|
||||||
|
try {
|
||||||
|
const config = await loadConfig(tableName);
|
||||||
|
if (config.id) {
|
||||||
|
currentStep.value = STEP.PREVIEW;
|
||||||
|
await doPreview(tableName);
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
ElMessage.error("获取生成配置失败");
|
||||||
|
visible.value = false;
|
||||||
|
} finally {
|
||||||
|
loading.value = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function handlePrev() {
|
||||||
|
if (currentStep.value === STEP.PREVIEW) {
|
||||||
|
// 从预览回退要重新加载,不然下次进来数据会有问题
|
||||||
|
genConfigFormData.value = { fieldConfigs: [] };
|
||||||
|
loading.value = true;
|
||||||
|
try {
|
||||||
|
genConfigFormData.value = await GeneratorAPI.getGenConfig(currentTableName.value);
|
||||||
|
} finally {
|
||||||
|
loading.value = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (currentStep.value > STEP.BASIC_CONFIG) {
|
||||||
|
currentStep.value--;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function handleNext() {
|
||||||
|
if (currentStep.value === STEP.BASIC_CONFIG) {
|
||||||
|
if (!validateBasic()) return;
|
||||||
|
currentStep.value = STEP.FIELD_CONFIG;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (currentStep.value === STEP.FIELD_CONFIG) {
|
||||||
|
loading.value = true;
|
||||||
|
loadingText.value = "代码生成中,请稍候...";
|
||||||
|
try {
|
||||||
|
await saveConfig(currentTableName.value);
|
||||||
|
await doPreview(currentTableName.value);
|
||||||
|
currentStep.value = STEP.PREVIEW;
|
||||||
|
} catch {
|
||||||
|
ElMessage.error("代码生成失败");
|
||||||
|
} finally {
|
||||||
|
loading.value = false;
|
||||||
|
loadingText.value = "loading...";
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (currentStep.value === STEP.PREVIEW) {
|
||||||
|
const pageType = genConfigFormData.value.pageType || "classic";
|
||||||
|
GeneratorAPI.download(currentTableName.value, pageType as "classic" | "curd", "ts");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function doPreview(tableName: string) {
|
||||||
|
const files = await handlePreview(tableName);
|
||||||
|
// 把文件列表传给写入本地模块,这样点写入时能拿到数据
|
||||||
|
setPreviewFiles(files);
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleClose() {
|
||||||
|
visible.value = false;
|
||||||
|
fieldConfigRef.value?.destroySort();
|
||||||
|
}
|
||||||
|
|
||||||
|
defineExpose({ open });
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style scoped lang="scss">
|
||||||
|
.drawer-content {
|
||||||
|
min-height: 400px;
|
||||||
|
}
|
||||||
|
.drawer-footer {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@@ -0,0 +1,500 @@
|
|||||||
|
<template>
|
||||||
|
<div class="preview-step">
|
||||||
|
<!-- 工具栏 -->
|
||||||
|
<div class="preview-toolbar">
|
||||||
|
<div class="toolbar-left">
|
||||||
|
<div class="filter-group">
|
||||||
|
<span class="filter-label">
|
||||||
|
<el-icon><View /></el-icon>
|
||||||
|
预览范围
|
||||||
|
</span>
|
||||||
|
<el-radio-group
|
||||||
|
:model-value="previewScope"
|
||||||
|
size="small"
|
||||||
|
@update:model-value="onScopeChange"
|
||||||
|
>
|
||||||
|
<el-radio-button value="all">
|
||||||
|
<el-icon><Grid /></el-icon>
|
||||||
|
全部
|
||||||
|
</el-radio-button>
|
||||||
|
<el-radio-button value="frontend">
|
||||||
|
<el-icon><Monitor /></el-icon>
|
||||||
|
前端
|
||||||
|
</el-radio-button>
|
||||||
|
<el-radio-button value="backend">
|
||||||
|
<el-icon><Cpu /></el-icon>
|
||||||
|
后端
|
||||||
|
</el-radio-button>
|
||||||
|
</el-radio-group>
|
||||||
|
</div>
|
||||||
|
<el-divider direction="vertical" />
|
||||||
|
<div class="filter-group">
|
||||||
|
<span class="filter-label">
|
||||||
|
<el-icon><Files /></el-icon>
|
||||||
|
文件类型
|
||||||
|
</span>
|
||||||
|
<el-checkbox-group
|
||||||
|
:model-value="previewTypes"
|
||||||
|
size="small"
|
||||||
|
@update:model-value="onTypesChange"
|
||||||
|
>
|
||||||
|
<el-checkbox-button v-for="t in previewTypeOptions" :key="t" :value="t">
|
||||||
|
{{ t }}
|
||||||
|
</el-checkbox-button>
|
||||||
|
</el-checkbox-group>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="toolbar-right">
|
||||||
|
<el-button size="small" type="primary" plain @click="emit('copy')">
|
||||||
|
<template #icon><CopyDocument /></template>
|
||||||
|
复制代码
|
||||||
|
</el-button>
|
||||||
|
<el-button size="small" type="success" plain @click="handleDownload">
|
||||||
|
<template #icon><Download /></template>
|
||||||
|
下载 ZIP
|
||||||
|
</el-button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- 主区域 -->
|
||||||
|
<div class="preview-container">
|
||||||
|
<!-- 文件树 -->
|
||||||
|
<div class="file-tree-panel" :style="{ width: fileTreeWidth + 'px' }">
|
||||||
|
<div class="panel-header">
|
||||||
|
<div class="header-left">
|
||||||
|
<el-icon class="header-icon"><FolderOpened /></el-icon>
|
||||||
|
<span class="header-title">文件列表</span>
|
||||||
|
<el-tag size="small" type="info" effect="plain">{{ fileCount }} 个文件</el-tag>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<el-scrollbar class="panel-body">
|
||||||
|
<el-tree
|
||||||
|
ref="fileTreeRef"
|
||||||
|
:data="filteredTreeData"
|
||||||
|
node-key="key"
|
||||||
|
default-expand-all
|
||||||
|
highlight-current
|
||||||
|
:current-node-key="currentFileKey"
|
||||||
|
@node-click="(data: any) => emit('file-click', data)"
|
||||||
|
>
|
||||||
|
<template #default="{ data }">
|
||||||
|
<div class="tree-node" :class="{ 'is-active': data.key === currentFileKey }">
|
||||||
|
<el-icon class="file-icon" :class="`icon-${getFileIcon(data)}`">
|
||||||
|
<Document />
|
||||||
|
</el-icon>
|
||||||
|
<span class="node-label">{{ data.label }}</span>
|
||||||
|
<el-tag
|
||||||
|
v-if="data.scope"
|
||||||
|
size="small"
|
||||||
|
:type="data.scope === 'frontend' ? 'success' : 'warning'"
|
||||||
|
effect="plain"
|
||||||
|
class="scope-tag"
|
||||||
|
>
|
||||||
|
{{ data.scope === "frontend" ? "前端" : "后端" }}
|
||||||
|
</el-tag>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
</el-tree>
|
||||||
|
</el-scrollbar>
|
||||||
|
<div class="resize-handle" @mousedown="startResize">
|
||||||
|
<div class="resize-line" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- 代码预览 -->
|
||||||
|
<div class="code-preview-panel">
|
||||||
|
<div class="panel-header">
|
||||||
|
<div class="header-left">
|
||||||
|
<el-icon class="header-icon"><Document /></el-icon>
|
||||||
|
<span class="file-path">{{ currentFilePath || "请选择文件预览" }}</span>
|
||||||
|
</div>
|
||||||
|
<div class="header-right">
|
||||||
|
<el-tag
|
||||||
|
v-if="currentLanguage"
|
||||||
|
size="small"
|
||||||
|
type="primary"
|
||||||
|
effect="dark"
|
||||||
|
class="lang-tag"
|
||||||
|
>
|
||||||
|
{{ currentLanguage }}
|
||||||
|
</el-tag>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<el-scrollbar class="panel-body">
|
||||||
|
<div v-if="!code" class="empty-code">
|
||||||
|
<el-icon class="empty-icon"><DocumentCopy /></el-icon>
|
||||||
|
<div class="empty-text">点击左侧文件预览代码</div>
|
||||||
|
</div>
|
||||||
|
<Codemirror
|
||||||
|
v-else
|
||||||
|
ref="cmRef"
|
||||||
|
:value="code"
|
||||||
|
:options="cmOptions"
|
||||||
|
border
|
||||||
|
:readonly="true"
|
||||||
|
height="100%"
|
||||||
|
width="100%"
|
||||||
|
/>
|
||||||
|
</el-scrollbar>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup lang="ts">
|
||||||
|
import "codemirror/mode/javascript/javascript.js";
|
||||||
|
import Codemirror from "codemirror-editor-vue3";
|
||||||
|
import type { CmComponentRef } from "codemirror-editor-vue3";
|
||||||
|
import type { EditorConfiguration } from "codemirror";
|
||||||
|
import GeneratorAPI from "@/api/codegen";
|
||||||
|
import type { GenConfigForm } from "@/api/codegen";
|
||||||
|
import { getFileIcon } from "../utils/tree-builder";
|
||||||
|
|
||||||
|
const props = defineProps<{
|
||||||
|
genConfigFormData: GenConfigForm;
|
||||||
|
previewScope: "all" | "frontend" | "backend";
|
||||||
|
previewTypes: string[];
|
||||||
|
previewTypeOptions: string[];
|
||||||
|
filteredTreeData: any[];
|
||||||
|
code: string;
|
||||||
|
currentFileKey: string;
|
||||||
|
tableName: string;
|
||||||
|
}>();
|
||||||
|
|
||||||
|
const emit = defineEmits<{
|
||||||
|
(e: "update:previewScope", val: "all" | "frontend" | "backend"): void;
|
||||||
|
(e: "update:previewTypes", val: string[]): void;
|
||||||
|
(e: "file-click", data: any): void;
|
||||||
|
(e: "copy"): void;
|
||||||
|
}>();
|
||||||
|
|
||||||
|
const cmRef = ref<CmComponentRef>();
|
||||||
|
const cmOptions: EditorConfiguration = { mode: "text/javascript" };
|
||||||
|
const fileTreeRef = ref();
|
||||||
|
const fileTreeWidth = ref(280);
|
||||||
|
|
||||||
|
const currentFilePath = computed(() => {
|
||||||
|
const key = props.currentFileKey;
|
||||||
|
if (!key) return "";
|
||||||
|
const idx = key.indexOf(":");
|
||||||
|
return idx > -1 ? key.slice(idx + 1) : key;
|
||||||
|
});
|
||||||
|
|
||||||
|
function onScopeChange(val: any) {
|
||||||
|
emit("update:previewScope", val as "all" | "frontend" | "backend");
|
||||||
|
}
|
||||||
|
|
||||||
|
function onTypesChange(val: any) {
|
||||||
|
emit("update:previewTypes", val as string[]);
|
||||||
|
}
|
||||||
|
|
||||||
|
const currentLanguage = computed(() => {
|
||||||
|
if (!props.currentFileKey) return "";
|
||||||
|
const parts = currentFilePath.value.split(".");
|
||||||
|
return parts.length > 1 ? parts.pop() : "";
|
||||||
|
});
|
||||||
|
|
||||||
|
const fileCount = computed(() => {
|
||||||
|
let count = 0;
|
||||||
|
function walk(nodes: any[]) {
|
||||||
|
nodes.forEach((n) => {
|
||||||
|
if (!n.children || !n.children.length) count++;
|
||||||
|
else walk(n.children);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
walk(props.filteredTreeData);
|
||||||
|
return count;
|
||||||
|
});
|
||||||
|
|
||||||
|
function handleDownload() {
|
||||||
|
const pageType = props.genConfigFormData.pageType || "classic";
|
||||||
|
GeneratorAPI.download(props.tableName, pageType as "classic" | "curd", "ts");
|
||||||
|
}
|
||||||
|
|
||||||
|
function refreshEditor() {
|
||||||
|
const inst = cmRef.value as any;
|
||||||
|
inst?.cminstance?.refresh?.();
|
||||||
|
inst?.cm?.refresh?.();
|
||||||
|
inst?.editor?.refresh?.();
|
||||||
|
}
|
||||||
|
|
||||||
|
function startResize(e: MouseEvent) {
|
||||||
|
const startX = e.clientX;
|
||||||
|
const startWidth = fileTreeWidth.value;
|
||||||
|
const onMove = (ev: MouseEvent) => {
|
||||||
|
fileTreeWidth.value = Math.max(200, Math.min(500, startWidth + ev.clientX - startX));
|
||||||
|
};
|
||||||
|
const onUp = () => {
|
||||||
|
document.removeEventListener("mousemove", onMove);
|
||||||
|
document.removeEventListener("mouseup", onUp);
|
||||||
|
};
|
||||||
|
document.addEventListener("mousemove", onMove);
|
||||||
|
document.addEventListener("mouseup", onUp);
|
||||||
|
}
|
||||||
|
|
||||||
|
defineExpose({ refreshEditor, fileTreeRef });
|
||||||
|
|
||||||
|
onBeforeUnmount(() => {
|
||||||
|
cmRef.value?.destroy();
|
||||||
|
});
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style scoped lang="scss">
|
||||||
|
.preview-step {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
height: 100%;
|
||||||
|
|
||||||
|
.preview-toolbar {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
padding: 12px 16px;
|
||||||
|
margin-bottom: 12px;
|
||||||
|
background: var(--el-bg-color);
|
||||||
|
border: 1px solid var(--el-border-color-lighter);
|
||||||
|
border-radius: 12px;
|
||||||
|
|
||||||
|
.toolbar-left {
|
||||||
|
display: flex;
|
||||||
|
gap: 12px;
|
||||||
|
align-items: center;
|
||||||
|
|
||||||
|
.filter-group {
|
||||||
|
display: flex;
|
||||||
|
gap: 8px;
|
||||||
|
align-items: center;
|
||||||
|
|
||||||
|
.filter-label {
|
||||||
|
display: inline-flex;
|
||||||
|
gap: 4px;
|
||||||
|
align-items: center;
|
||||||
|
font-size: 13px;
|
||||||
|
font-weight: 500;
|
||||||
|
color: var(--el-text-color-secondary);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.toolbar-right {
|
||||||
|
display: flex;
|
||||||
|
gap: 8px;
|
||||||
|
align-items: center;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.preview-container {
|
||||||
|
display: flex;
|
||||||
|
flex: 1;
|
||||||
|
gap: 12px;
|
||||||
|
min-height: 0;
|
||||||
|
|
||||||
|
.file-tree-panel,
|
||||||
|
.code-preview-panel {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
overflow: hidden;
|
||||||
|
background: var(--el-bg-color);
|
||||||
|
border: 1px solid var(--el-border-color-lighter);
|
||||||
|
border-radius: 12px;
|
||||||
|
transition: box-shadow 0.3s ease;
|
||||||
|
|
||||||
|
&:hover {
|
||||||
|
box-shadow: 0 2px 12px rgba(0, 0, 0, 0.04);
|
||||||
|
}
|
||||||
|
|
||||||
|
.panel-header {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
padding: 12px 16px;
|
||||||
|
background: var(--el-fill-color-light);
|
||||||
|
border-bottom: 1px solid var(--el-border-color-lighter);
|
||||||
|
|
||||||
|
.header-left,
|
||||||
|
.header-right {
|
||||||
|
display: flex;
|
||||||
|
gap: 8px;
|
||||||
|
align-items: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.header-icon {
|
||||||
|
font-size: 16px;
|
||||||
|
color: var(--el-color-primary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.header-title {
|
||||||
|
font-size: 14px;
|
||||||
|
font-weight: 600;
|
||||||
|
color: var(--el-text-color-primary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.file-path {
|
||||||
|
font-family: "JetBrains Mono", "Fira Code", Consolas, Monaco, monospace;
|
||||||
|
font-size: 13px;
|
||||||
|
color: var(--el-text-color-regular);
|
||||||
|
}
|
||||||
|
|
||||||
|
.lang-tag {
|
||||||
|
font-family: "JetBrains Mono", monospace;
|
||||||
|
font-size: 11px;
|
||||||
|
text-transform: uppercase;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.panel-body {
|
||||||
|
flex: 1;
|
||||||
|
min-height: 0;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.file-tree-panel {
|
||||||
|
position: relative;
|
||||||
|
min-width: 200px;
|
||||||
|
max-width: 500px;
|
||||||
|
|
||||||
|
:deep(.el-tree) {
|
||||||
|
padding: 4px;
|
||||||
|
|
||||||
|
.el-tree-node__content {
|
||||||
|
height: 32px;
|
||||||
|
padding: 0 8px;
|
||||||
|
margin: 1px 0;
|
||||||
|
border-radius: 6px;
|
||||||
|
transition: all 0.2s ease;
|
||||||
|
|
||||||
|
&:hover {
|
||||||
|
background: var(--el-fill-color-light);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.el-tree-node.is-current > .el-tree-node__content {
|
||||||
|
font-weight: 500;
|
||||||
|
color: var(--el-color-primary);
|
||||||
|
background: var(--el-color-primary-light-9);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 文件夹图标颜色
|
||||||
|
.el-tree-node__expand-icon {
|
||||||
|
color: var(--el-color-warning);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.tree-node {
|
||||||
|
display: flex;
|
||||||
|
flex: 1;
|
||||||
|
gap: 6px;
|
||||||
|
align-items: center;
|
||||||
|
|
||||||
|
.file-icon {
|
||||||
|
font-size: 15px;
|
||||||
|
transition: transform 0.2s ease;
|
||||||
|
|
||||||
|
&.icon-java {
|
||||||
|
color: #b07219;
|
||||||
|
}
|
||||||
|
&.icon-vue {
|
||||||
|
color: #42b883;
|
||||||
|
}
|
||||||
|
&.icon-typescript {
|
||||||
|
color: #3178c6;
|
||||||
|
}
|
||||||
|
&.icon-xml {
|
||||||
|
color: #f60;
|
||||||
|
}
|
||||||
|
&.icon-html {
|
||||||
|
color: #e34c26;
|
||||||
|
}
|
||||||
|
&.icon-javascript {
|
||||||
|
color: #f7df1e;
|
||||||
|
}
|
||||||
|
&.icon-scss {
|
||||||
|
color: #c6538c;
|
||||||
|
}
|
||||||
|
&.icon-sql {
|
||||||
|
color: #336791;
|
||||||
|
}
|
||||||
|
&.icon-folder {
|
||||||
|
color: var(--el-color-warning);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.node-label {
|
||||||
|
flex: 1;
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
font-size: 13px;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.scope-tag {
|
||||||
|
height: 20px;
|
||||||
|
padding: 0 6px;
|
||||||
|
margin-left: auto;
|
||||||
|
font-size: 11px;
|
||||||
|
}
|
||||||
|
|
||||||
|
&:hover .file-icon {
|
||||||
|
transform: scale(1.15);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 当前选中的行标签样式调整
|
||||||
|
&.is-active .scope-tag {
|
||||||
|
color: #fff;
|
||||||
|
background: var(--el-color-primary);
|
||||||
|
border-color: var(--el-color-primary);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.resize-handle {
|
||||||
|
position: absolute;
|
||||||
|
top: 0;
|
||||||
|
right: 0;
|
||||||
|
bottom: 0;
|
||||||
|
z-index: 10;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
width: 12px;
|
||||||
|
cursor: col-resize;
|
||||||
|
|
||||||
|
.resize-line {
|
||||||
|
width: 2px;
|
||||||
|
height: 40px;
|
||||||
|
background: var(--el-border-color);
|
||||||
|
border-radius: 1px;
|
||||||
|
transition: background 0.2s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
&:hover .resize-line {
|
||||||
|
background: var(--el-color-primary);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.code-preview-panel {
|
||||||
|
flex: 1;
|
||||||
|
min-width: 0;
|
||||||
|
|
||||||
|
.empty-code {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 12px;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
height: 100%;
|
||||||
|
color: var(--el-text-color-secondary);
|
||||||
|
|
||||||
|
.empty-icon {
|
||||||
|
font-size: 48px;
|
||||||
|
opacity: 0.3;
|
||||||
|
}
|
||||||
|
|
||||||
|
.empty-text {
|
||||||
|
font-size: 14px;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@@ -0,0 +1,119 @@
|
|||||||
|
<template>
|
||||||
|
<el-card class="page-search" shadow="never">
|
||||||
|
<el-form ref="queryFormRef" :model="queryParams" :inline="true">
|
||||||
|
<el-form-item prop="keywords" label="关键字">
|
||||||
|
<el-input
|
||||||
|
v-model="queryParams.keywords"
|
||||||
|
placeholder="表名"
|
||||||
|
clearable
|
||||||
|
@keyup.enter="handleQuery"
|
||||||
|
/>
|
||||||
|
</el-form-item>
|
||||||
|
<el-form-item>
|
||||||
|
<el-button type="primary" @click="handleQuery">
|
||||||
|
<template #icon><Search /></template>
|
||||||
|
搜索
|
||||||
|
</el-button>
|
||||||
|
<el-button @click="handleResetQuery">
|
||||||
|
<template #icon><Refresh /></template>
|
||||||
|
重置
|
||||||
|
</el-button>
|
||||||
|
</el-form-item>
|
||||||
|
</el-form>
|
||||||
|
</el-card>
|
||||||
|
|
||||||
|
<el-card class="page-content" shadow="never">
|
||||||
|
<el-table v-loading="loading" :data="pageData" highlight-current-row border>
|
||||||
|
<el-table-column type="selection" width="55" align="center" />
|
||||||
|
<el-table-column label="表名" prop="tableName" min-width="100" />
|
||||||
|
<el-table-column label="描述" prop="tableComment" width="150" />
|
||||||
|
<el-table-column label="存储引擎" align="center" prop="engine" />
|
||||||
|
<el-table-column label="排序规则" align="center" prop="tableCollation" />
|
||||||
|
<el-table-column label="创建时间" align="center" prop="createTime" />
|
||||||
|
<el-table-column fixed="right" label="操作" width="200">
|
||||||
|
<template #default="scope">
|
||||||
|
<el-button
|
||||||
|
type="primary"
|
||||||
|
size="small"
|
||||||
|
link
|
||||||
|
@click="emit('generate', scope.row.tableName)"
|
||||||
|
>
|
||||||
|
<template #icon><MagicStick /></template>
|
||||||
|
生成代码
|
||||||
|
</el-button>
|
||||||
|
<el-button
|
||||||
|
v-if="scope.row.isConfigured === 1"
|
||||||
|
type="danger"
|
||||||
|
size="small"
|
||||||
|
link
|
||||||
|
@click="emit('reset-config', scope.row.tableName)"
|
||||||
|
>
|
||||||
|
<template #icon><RefreshLeft /></template>
|
||||||
|
重置配置
|
||||||
|
</el-button>
|
||||||
|
</template>
|
||||||
|
</el-table-column>
|
||||||
|
</el-table>
|
||||||
|
|
||||||
|
<pagination
|
||||||
|
v-if="total > 0"
|
||||||
|
v-model:page="queryParams.pageNum"
|
||||||
|
v-model:limit="queryParams.pageSize"
|
||||||
|
:total="total"
|
||||||
|
@pagination="handleQuery"
|
||||||
|
/>
|
||||||
|
</el-card>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup lang="ts">
|
||||||
|
import GeneratorAPI from "@/api/codegen";
|
||||||
|
import type { TableQueryParams, TableItem } from "@/api/codegen";
|
||||||
|
|
||||||
|
const emit = defineEmits<{
|
||||||
|
generate: [tableName: string];
|
||||||
|
"reset-config": [tableName: string];
|
||||||
|
}>();
|
||||||
|
|
||||||
|
const queryFormRef = ref();
|
||||||
|
const queryParams = reactive<TableQueryParams>({
|
||||||
|
pageNum: 1,
|
||||||
|
pageSize: 10,
|
||||||
|
});
|
||||||
|
|
||||||
|
const loading = ref(false);
|
||||||
|
const pageData = ref<TableItem[]>([]);
|
||||||
|
const total = ref(0);
|
||||||
|
|
||||||
|
function handleQuery() {
|
||||||
|
loading.value = true;
|
||||||
|
GeneratorAPI.getTablePage(queryParams)
|
||||||
|
.then((data) => {
|
||||||
|
pageData.value = data.list;
|
||||||
|
total.value = data.total ?? 0;
|
||||||
|
})
|
||||||
|
.finally(() => {
|
||||||
|
loading.value = false;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleResetQuery() {
|
||||||
|
queryFormRef.value?.resetFields();
|
||||||
|
queryParams.pageNum = 1;
|
||||||
|
handleQuery();
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleResetConfig(tableName: string) {
|
||||||
|
ElMessageBox.confirm("确定要重置配置吗?", "提示", { type: "warning" }).then(() => {
|
||||||
|
GeneratorAPI.resetGenConfig(tableName).then(() => {
|
||||||
|
ElMessage.success("重置成功");
|
||||||
|
handleQuery();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
onMounted(() => {
|
||||||
|
handleQuery();
|
||||||
|
});
|
||||||
|
|
||||||
|
defineExpose({ handleQuery, handleResetConfig });
|
||||||
|
</script>
|
||||||
@@ -0,0 +1,276 @@
|
|||||||
|
<template>
|
||||||
|
<el-dialog v-model="modelValue" title="写入本地项目" width="640px" class="write-local-dialog">
|
||||||
|
<div class="dialog-body">
|
||||||
|
<!-- 浏览器不支持提示 -->
|
||||||
|
<el-alert
|
||||||
|
v-if="!supportsFSAccess"
|
||||||
|
title="当前浏览器不支持本地文件写入"
|
||||||
|
description="请使用 Chrome 或 Edge 最新版本,或点击「下载 ZIP」替代"
|
||||||
|
type="warning"
|
||||||
|
show-icon
|
||||||
|
:closable="false"
|
||||||
|
class="mb-4"
|
||||||
|
/>
|
||||||
|
|
||||||
|
<!-- 目录选择 -->
|
||||||
|
<div class="dir-section">
|
||||||
|
<div class="section-title">
|
||||||
|
<el-icon><Folder /></el-icon>
|
||||||
|
<span>选择项目目录</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="dir-item">
|
||||||
|
<div class="dir-label">
|
||||||
|
<el-tag size="small" type="success" effect="light">前端</el-tag>
|
||||||
|
</div>
|
||||||
|
<el-input
|
||||||
|
:model-value="frontendDirPath"
|
||||||
|
placeholder="点击右侧按钮选择前端项目根目录"
|
||||||
|
readonly
|
||||||
|
class="dir-input"
|
||||||
|
>
|
||||||
|
<template #prefix>
|
||||||
|
<el-icon><FolderOpened /></el-icon>
|
||||||
|
</template>
|
||||||
|
</el-input>
|
||||||
|
<el-button :disabled="!supportsFSAccess" @click="emit('pickFrontendDir')">
|
||||||
|
<el-icon><FolderAdd /></el-icon>
|
||||||
|
选择
|
||||||
|
</el-button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="dir-item">
|
||||||
|
<div class="dir-label">
|
||||||
|
<el-tag size="small" type="warning" effect="light">后端</el-tag>
|
||||||
|
</div>
|
||||||
|
<el-input
|
||||||
|
:model-value="backendDirPath"
|
||||||
|
placeholder="点击右侧按钮选择后端项目根目录"
|
||||||
|
readonly
|
||||||
|
class="dir-input"
|
||||||
|
>
|
||||||
|
<template #prefix>
|
||||||
|
<el-icon><FolderOpened /></el-icon>
|
||||||
|
</template>
|
||||||
|
</el-input>
|
||||||
|
<el-button :disabled="!supportsFSAccess" @click="emit('pickBackendDir')">
|
||||||
|
<el-icon><FolderAdd /></el-icon>
|
||||||
|
选择
|
||||||
|
</el-button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- 写入选项 -->
|
||||||
|
<div class="option-section">
|
||||||
|
<div class="section-title">
|
||||||
|
<el-icon><SetUp /></el-icon>
|
||||||
|
<span>写入选项</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="option-row">
|
||||||
|
<span class="option-label">写入范围</span>
|
||||||
|
<el-radio-group
|
||||||
|
:model-value="writeScope"
|
||||||
|
size="small"
|
||||||
|
@update:model-value="emit('update:writeScope', $event as any)"
|
||||||
|
>
|
||||||
|
<el-radio-button value="all">
|
||||||
|
<el-icon><Grid /></el-icon>
|
||||||
|
全部
|
||||||
|
</el-radio-button>
|
||||||
|
<el-radio-button value="frontend">
|
||||||
|
<el-icon><Monitor /></el-icon>
|
||||||
|
仅前端
|
||||||
|
</el-radio-button>
|
||||||
|
<el-radio-button value="backend">
|
||||||
|
<el-icon><Cpu /></el-icon>
|
||||||
|
仅后端
|
||||||
|
</el-radio-button>
|
||||||
|
</el-radio-group>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="option-row">
|
||||||
|
<span class="option-label">覆盖策略</span>
|
||||||
|
<el-radio-group
|
||||||
|
:model-value="overwriteMode"
|
||||||
|
size="small"
|
||||||
|
@update:model-value="emit('update:overwriteMode', $event as any)"
|
||||||
|
>
|
||||||
|
<el-radio-button value="overwrite">直接覆盖</el-radio-button>
|
||||||
|
<el-radio-button value="skip">跳过已存在</el-radio-button>
|
||||||
|
<el-radio-button value="ifChanged">有变更才覆盖</el-radio-button>
|
||||||
|
</el-radio-group>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- 进度条 -->
|
||||||
|
<div v-if="writeProgress.total > 0" class="progress-section">
|
||||||
|
<div class="progress-header">
|
||||||
|
<span class="progress-title">写入进度</span>
|
||||||
|
<span class="progress-count">{{ writeProgress.done }} / {{ writeProgress.total }}</span>
|
||||||
|
</div>
|
||||||
|
<el-progress
|
||||||
|
:percentage="writeProgress.percent"
|
||||||
|
:stroke-width="8"
|
||||||
|
:status="writeProgress.percent === 100 ? 'success' : ''"
|
||||||
|
striped
|
||||||
|
striped-flow
|
||||||
|
/>
|
||||||
|
<div class="progress-file">{{ writeProgress.current }}</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<template #footer>
|
||||||
|
<el-button @click="modelValue = false">取消</el-button>
|
||||||
|
<el-button type="primary" :disabled="writeRunning || !dirReady" @click="emit('confirmWrite')">
|
||||||
|
<el-icon><Download /></el-icon>
|
||||||
|
开始写入
|
||||||
|
</el-button>
|
||||||
|
</template>
|
||||||
|
</el-dialog>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup lang="ts">
|
||||||
|
const modelValue = defineModel<boolean>({ required: true });
|
||||||
|
|
||||||
|
const props = defineProps<{
|
||||||
|
supportsFSAccess: boolean;
|
||||||
|
frontendDirPath: string;
|
||||||
|
backendDirPath: string;
|
||||||
|
writeScope: "all" | "frontend" | "backend";
|
||||||
|
overwriteMode: "overwrite" | "skip" | "ifChanged";
|
||||||
|
writeProgress: { total: number; done: number; percent: number; current: string };
|
||||||
|
writeRunning: boolean;
|
||||||
|
canWriteToLocal: boolean;
|
||||||
|
}>();
|
||||||
|
|
||||||
|
const emit = defineEmits<{
|
||||||
|
"update:writeScope": [val: "all" | "frontend" | "backend"];
|
||||||
|
"update:overwriteMode": [val: "overwrite" | "skip" | "ifChanged"];
|
||||||
|
pickFrontendDir: [];
|
||||||
|
pickBackendDir: [];
|
||||||
|
confirmWrite: [];
|
||||||
|
}>();
|
||||||
|
|
||||||
|
// 根据写入范围检查目录是否都选好了
|
||||||
|
const dirReady = computed(() => {
|
||||||
|
if (props.writeScope === "all") {
|
||||||
|
return !!props.frontendDirPath && !!props.backendDirPath;
|
||||||
|
}
|
||||||
|
if (props.writeScope === "frontend") {
|
||||||
|
return !!props.frontendDirPath;
|
||||||
|
}
|
||||||
|
return !!props.backendDirPath;
|
||||||
|
});
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style scoped lang="scss">
|
||||||
|
.write-local-dialog {
|
||||||
|
:deep(.el-dialog__body) {
|
||||||
|
padding: 8px 24px 16px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.dialog-body {
|
||||||
|
.section-title {
|
||||||
|
display: flex;
|
||||||
|
gap: 8px;
|
||||||
|
align-items: center;
|
||||||
|
padding-bottom: 8px;
|
||||||
|
margin-bottom: 12px;
|
||||||
|
font-size: 14px;
|
||||||
|
font-weight: 600;
|
||||||
|
color: var(--el-text-color-primary);
|
||||||
|
border-bottom: 1px solid var(--el-border-color-lighter);
|
||||||
|
|
||||||
|
.el-icon {
|
||||||
|
font-size: 16px;
|
||||||
|
color: var(--el-color-primary);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.dir-section {
|
||||||
|
margin-bottom: 20px;
|
||||||
|
|
||||||
|
.dir-item {
|
||||||
|
display: flex;
|
||||||
|
gap: 10px;
|
||||||
|
align-items: center;
|
||||||
|
margin-bottom: 10px;
|
||||||
|
|
||||||
|
.dir-label {
|
||||||
|
flex-shrink: 0;
|
||||||
|
width: 50px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.dir-input {
|
||||||
|
flex: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
:deep(.el-button) {
|
||||||
|
display: inline-flex;
|
||||||
|
gap: 4px;
|
||||||
|
align-items: center;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.option-section {
|
||||||
|
margin-bottom: 16px;
|
||||||
|
|
||||||
|
.option-row {
|
||||||
|
display: flex;
|
||||||
|
gap: 16px;
|
||||||
|
align-items: center;
|
||||||
|
margin-bottom: 12px;
|
||||||
|
|
||||||
|
.option-label {
|
||||||
|
flex-shrink: 0;
|
||||||
|
width: 60px;
|
||||||
|
font-size: 13px;
|
||||||
|
color: var(--el-text-color-regular);
|
||||||
|
}
|
||||||
|
|
||||||
|
:deep(.el-radio-button__inner) {
|
||||||
|
display: inline-flex;
|
||||||
|
gap: 4px;
|
||||||
|
align-items: center;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.progress-section {
|
||||||
|
padding: 12px 16px;
|
||||||
|
background: var(--el-fill-color-light);
|
||||||
|
border-radius: 8px;
|
||||||
|
|
||||||
|
.progress-header {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
margin-bottom: 8px;
|
||||||
|
|
||||||
|
.progress-title {
|
||||||
|
font-size: 13px;
|
||||||
|
font-weight: 500;
|
||||||
|
color: var(--el-text-color-primary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.progress-count {
|
||||||
|
font-size: 12px;
|
||||||
|
color: var(--el-text-color-secondary);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.progress-file {
|
||||||
|
margin-top: 6px;
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
font-family: "JetBrains Mono", monospace;
|
||||||
|
font-size: 12px;
|
||||||
|
color: var(--el-text-color-secondary);
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@@ -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<GenConfigForm>) {
|
||||||
|
const treeData = ref<TreeNode[]>([]);
|
||||||
|
const previewScope = ref<"all" | "frontend" | "backend">("all");
|
||||||
|
const previewTypeOptions = ref<string[]>([]);
|
||||||
|
const previewTypes = ref<string[]>([]);
|
||||||
|
|
||||||
|
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,
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -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<GenConfigForm>({
|
||||||
|
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<OptionItem[]>([]);
|
||||||
|
const dictOptions = ref<OptionItem[]>([]);
|
||||||
|
|
||||||
|
/** 自动根据表前缀推导实体名 */
|
||||||
|
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,
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -0,0 +1,237 @@
|
|||||||
|
import type { GeneratorPreviewItem, GenConfigForm } from "@/api/codegen";
|
||||||
|
import { ElLoading } from "element-plus";
|
||||||
|
|
||||||
|
export function useLocalWrite(genConfigFormData: Ref<GenConfigForm>) {
|
||||||
|
const supportsFSAccess = typeof (window as any).showDirectoryPicker === "function";
|
||||||
|
|
||||||
|
const writeDialog = reactive({ visible: false });
|
||||||
|
const frontendDirHandle = ref<FileSystemDirectoryHandle | null>(null);
|
||||||
|
const backendDirHandle = ref<FileSystemDirectoryHandle | null>(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<GeneratorPreviewItem[]>([]);
|
||||||
|
|
||||||
|
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<boolean> {
|
||||||
|
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<boolean> {
|
||||||
|
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,
|
||||||
|
};
|
||||||
|
}
|
||||||
+15
-1336
File diff suppressed because it is too large.
Load diff
@@ -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<string, string> = {
|
||||||
|
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[];
|
||||||
|
}
|
||||||
Reference in new issue
Block a user