feat(代码生成器): 重构代码生成模板并增强安全性

重构代码生成模板结构,将模板文件移动到标准目录
添加文件覆盖控制和安全路径检查,防止生成到项目外
优化生成逻辑,支持跳过已存在文件并返回统计信息
完善模板内容,增加分页查询和更多功能实现细节
This commit is contained in:
zhangtao
2025-10-29 01:41:26 +08:00
parent a1f9475f3a
commit 0f1916e544
13 changed files with 419 additions and 157 deletions
@@ -222,10 +222,6 @@ async def gen_code_local_controller(
返回:
- JSONResponse: 包含生成结果的JSON响应
"""
from app.config.setting import settings
if not settings.allow_overwrite:
logger.error('【系统预设】不允许生成文件覆盖到本地')
return ErrorResponse(msg='【系统预设】不允许生成文件覆盖到本地')
result = await GenTableService.generate_code_service(auth, table_name)
logger.info('生成代码到指定路径成功')
return SuccessResponse(msg="生成代码到指定路径成功", data=result)
@@ -408,18 +408,35 @@ class GenTableService:
"""
env = Jinja2TemplateInitializerUtil.init_jinja2()
render_info = await cls.__get_gen_render_info(auth, table_name)
gen_table_schema = render_info[3]
skipped = 0
for template in render_info[0]:
try:
render_content = await env.get_template(template).render_async(**render_info[2])
gen_path = cls.__get_gen_path(render_info[3], template)
gen_path = cls.__get_gen_path(gen_table_schema, template)
if gen_path:
# 只允许写入到项目根目录及其子目录
project_root = os.path.realpath(str(settings.BASE_DIR.parent))
target_path = os.path.realpath(gen_path)
if not target_path.startswith(project_root):
raise CustomException(msg='生成路径不允许,请选择项目目录内路径')
os.makedirs(os.path.dirname(gen_path), exist_ok=True)
# 覆盖控制:存在且不允许覆盖则跳过
if os.path.exists(gen_path) and not settings.allow_overwrite:
skipped += 1
continue
with open(gen_path, 'w', encoding='utf-8') as f:
f.write(render_content)
except Exception as e:
raise CustomException(msg=f'渲染模板失败,表名:{render_info[3].table_name},详细错误信息:{str(e)}')
raise CustomException(msg=f'渲染模板失败,表名:{gen_table_schema.table_name},详细错误信息:{str(e)}')
return SuccessResponse(msg='生成代码成功')
msg = '生成代码成功'
if skipped:
msg += f'(已跳过 {skipped} 个已存在文件)'
return SuccessResponse(msg=msg)
@classmethod
async def batch_gen_code_service(cls, auth: AuthSchema, table_names: List[str]) -> bytes:
@@ -682,13 +699,14 @@ class GenTableService:
- Optional[str]: 生成的文件路径,若失败则返回None。
"""
try:
gen_path = gen_table.gen_path or ""
gen_path = (gen_table.gen_path or '').strip()
file_name = Jinja2TemplateUtil.get_file_name(template, gen_table)
# 修复:检查文件名是否为空
if not file_name:
return None
if gen_path == '/':
return os.path.join(os.getcwd(), GEN_PATH, file_name)
# 默认写入到项目根目录(backend的上一级)
project_root = str(settings.BASE_DIR.parent)
if gen_path in ['', '/']:
return os.path.join(project_root, file_name)
else:
return os.path.join(gen_path, file_name)
except Exception:
+21 -17
View File
@@ -248,33 +248,35 @@ class Jinja2TemplateUtil:
# 处理空值情况
category = tpl_category or GenConstant.TPL_CRUD
templates = [
# Python相关模板
'python/controller.py.j2',
'python/service.py.j2',
'python/crud.py.j2',
'python/schema.py.j2',
'python/param.py.j2',
'python/model.py.j2',
# Vue相关模板
f'{use_web_type}/api.ts.j2',
# SQL脚本模板
'sql/sql.sql.j2',
# Python相关模板(调整为实际目录)
'backend/app/v1/module_demo/python/controller.py.j2',
'backend/app/v1/module_demo/python/service.py.j2',
'backend/app/v1/module_demo/python/crud.py.j2',
'backend/app/v1/module_demo/python/schema.py.j2',
'backend/app/v1/module_demo/python/param.py.j2',
'backend/app/v1/module_demo/python/model.py.j2',
'backend/app/v1/module_demo/python/__init__.py.j2',
# Vue相关模板(API
'frontend/src/api/api.ts.j2',
# SQL脚本模板(调整为实际目录)
'backend/sql/sql.sql.j2',
]
if category == GenConstant.TPL_CRUD:
templates.append(f'{use_web_type}/index.vue.j2')
templates.append(f'frontend/src/views/module_demo/{use_web_type}/index.vue.j2')
elif category == GenConstant.TPL_TREE:
templates.append(f'{use_web_type}/index-tree.vue.j2')
templates.append(f'frontend/src/views/module_demo/{use_web_type}/index-tree.vue.j2')
elif category == GenConstant.TPL_SUB:
templates.append(f'{use_web_type}/index.vue.j2')
templates.append(f'frontend/src/views/module_demo/{use_web_type}/index.vue.j2')
return templates
@classmethod
def get_file_name(cls, template: List[str], gen_table: GenTableOutSchema):
def get_file_name(cls, template: str, gen_table: GenTableOutSchema):
"""
根据模板生成文件名。
参数:
- template (List[str]): 模板列表
- template (str): 模板路径字符串
- gen_table (GenTableOutSchema): 生成表的配置信息。
返回:
@@ -299,7 +301,9 @@ class Jinja2TemplateUtil:
return f'{python_path}/app/api/v1/{module_name}/{business_name}/param.py'
elif 'schema.py.j2' in template:
return f'{python_path}/app/api/v1/{module_name}/{business_name}/schema.py'
elif 'sql.j2' in template:
elif '__init__.py.j2' in template:
return f'{vue_path}/src/views/{module_name}/{business_name}/__init__.py'
elif 'sql.sql.j2' in template:
return f'{cls.BACKEND_PROJECT_PATH}/sql/{module_name}/{business_name}_menu.sql'
elif 'api.ts.j2' in template:
return f'{vue_path}/src/api/{module_name}/{business_name}.ts'
@@ -0,0 +1 @@
# -*- coding:utf-8 -*-
@@ -6,7 +6,6 @@ from app.common.response import SuccessResponse, StreamResponse
from app.core.dependencies import AuthPermission
from app.core.router_class import OperationLogRoute
from app.api.v1.module_system.auth.schema import AuthSchema
from app.common.request import PaginationService
from app.core.base_params import PaginationQueryParam
from app.utils.common_util import bytes2file_response
from app.core.logger import logger
@@ -34,9 +33,14 @@ async def get_obj_list_controller(
search: {{ table_name|snake_to_pascal_case }}QueryParam = Depends(),
auth: AuthSchema = Depends(AuthPermission(["{{ permission_prefix }}:query"]))
) -> JSONResponse:
"""查询{{ function_name }}列表接口"""
result_dict_list = await {{ table_name|snake_to_pascal_case }}Service.list_service(auth=auth, search=search, order_by=page.order_by)
result_dict = await PaginationService.paginate(data_list=result_dict_list, page_no=page.page_no, page_size=page.page_size)
"""查询{{ function_name }}列表接口(数据库分页)"""
result_dict = await {{ table_name|snake_to_pascal_case }}Service.page_service(
auth=auth,
page_no=page.page_no if page.page_no is not None else 1,
page_size=page.page_size if page.page_size is not None else 10,
search=search,
order_by=page.order_by
)
logger.info("查询{{ function_name }}列表成功")
return SuccessResponse(data=result_dict, msg="查询{{ function_name }}列表成功")
@@ -1,11 +1,11 @@
# -*- coding:utf-8 -*-
from typing import Dict, List, Optional, Sequence
from typing import Dict, List, Optional, Sequence, Union, Any
from app.core.base_crud import CRUDBase
from app.api.v1.module_system.auth.schema import AuthSchema
from .model import {{ table_name|snake_to_pascal_case }}Model
from .schema import {{ table_name|snake_to_pascal_case }}CreateSchema, {{ table_name|snake_to_pascal_case }}UpdateSchema
from .schema import {{ table_name|snake_to_pascal_case }}CreateSchema, {{ table_name|snake_to_pascal_case }}UpdateSchema, {{ table_name|snake_to_pascal_case }}OutSchema
class {{ table_name|snake_to_pascal_case }}CRUD(CRUDBase[{{ table_name|snake_to_pascal_case }}Model, {{ table_name|snake_to_pascal_case }}CreateSchema, {{ table_name|snake_to_pascal_case }}UpdateSchema]):
@@ -15,13 +15,13 @@ class {{ table_name|snake_to_pascal_case }}CRUD(CRUDBase[{{ table_name|snake_to_
"""初始化CRUD"""
super().__init__(model={{ table_name|snake_to_pascal_case }}Model, auth=auth)
async def get_by_id_crud(self, id: int) -> Optional[{{ table_name|snake_to_pascal_case }}Model]:
async def get_by_id_crud(self, id: int, preload: Optional[List[Union[str, Any]]] = None) -> Optional[{{ table_name|snake_to_pascal_case }}Model]:
"""详情"""
return await self.get(id=id)
return await self.get(id=id, preload=preload)
async def list_crud(self, search: Optional[Dict] = None, order_by: Optional[List[Dict[str, str]]] = None) -> Sequence[{{ table_name|snake_to_pascal_case }}Model]:
async def list_crud(self, search: Optional[Dict] = None, order_by: Optional[List[Dict[str, str]]] = None, preload: Optional[List[Union[str, Any]]] = None) -> Sequence[{{ table_name|snake_to_pascal_case }}Model]:
"""列表查询"""
return await self.list(search=search, order_by=order_by)
return await self.list(search=search, order_by=order_by, preload=preload)
async def create_crud(self, data: {{ table_name|snake_to_pascal_case }}CreateSchema) -> Optional[{{ table_name|snake_to_pascal_case }}Model]:
"""创建"""
@@ -38,3 +38,16 @@ class {{ table_name|snake_to_pascal_case }}CRUD(CRUDBase[{{ table_name|snake_to_
async def set_available_crud(self, ids: List[int], status: bool) -> None:
"""批量设置可用状态"""
return await self.set(ids=ids, status=status)
async def page_crud(self, offset: int, limit: int, order_by: Optional[List[Dict[str, str]]] = None, search: Optional[Dict] = None, preload: Optional[List[Union[str, Any]]] = None) -> Dict:
"""分页查询"""
order_by_list = order_by or [{'id': 'asc'}]
search_dict = search or {}
return await self.page(
offset=offset,
limit=limit,
order_by=order_by_list,
search=search_dict,
out_schema={{ table_name|snake_to_pascal_case }}OutSchema,
preload=preload
)
@@ -2,8 +2,8 @@
from datetime import datetime
from typing import Optional
from sqlalchemy import String, Integer, Text, DateTime
from sqlalchemy.orm import Mapped, mapped_column
from sqlalchemy import String, Integer, Text, DateTime, Boolean, ForeignKey
from sqlalchemy.orm import Mapped, mapped_column, relationship
from app.core.base_model import CreatorMixin
@@ -15,6 +15,7 @@ class {{ table_name|snake_to_pascal_case }}Model(CreatorMixin):
__tablename__ = '{{ table_name }}'
__table_args__ = {'comment': '{{ function_name }}'}
__loader_options__ = ["creator"]
{% for column in columns %}
{{ column.column_name }}: Mapped[Optional[{{ column.python_type }}]] = mapped_column({{ column.column_type|get_sqlalchemy_type }}, nullable=True, comment='{{ column.column_comment }}')
@@ -35,6 +35,20 @@ class {{ table_name|snake_to_pascal_case }}Service:
obj_list = await {{ table_name|snake_to_pascal_case }}CRUD(auth).list_crud(search=search_dict, order_by=order_by)
return [{{ table_name|snake_to_pascal_case }}OutSchema.model_validate(obj).model_dump() for obj in obj_list]
@classmethod
async def page_service(cls, auth: AuthSchema, page_no: int, page_size: int, search: Optional[{{ table_name|snake_to_pascal_case }}QueryParam] = None, order_by: Optional[List[Dict[str, str]]] = None) -> Dict:
"""分页查询(数据库分页)"""
search_dict = search.__dict__ if search else {}
order_by_list = order_by or [{'id': 'asc'}]
offset = (page_no - 1) * page_size
result = await {{ table_name|snake_to_pascal_case }}CRUD(auth).page_crud(
offset=offset,
limit=page_size,
order_by=order_by_list,
search=search_dict
)
return result
@classmethod
async def create_service(cls, auth: AuthSchema, data: {{ table_name|snake_to_pascal_case }}CreateSchema) -> Dict:
"""创建"""
@@ -80,6 +94,17 @@ class {{ table_name|snake_to_pascal_case }}Service:
}
data = obj_list.copy()
for item in data:
# 状态转换
if 'status' in item:
item['status'] = '正常' if item.get('status') else '停用'
# 创建者转换
creator_info = item.get('creator')
if isinstance(creator_info, dict):
item['creator'] = creator_info.get('name', '未知')
elif creator_info is None:
item['creator'] = '未知'
return ExcelUtil.export_list2excel(list_data=data, mapping_dict=mapping_dict)
@classmethod
@@ -117,7 +142,9 @@ class {{ table_name|snake_to_pascal_case }}Service:
"{{ column.column_name }}": row['{{ column.column_name }}'],
{% endfor %}
}
await {{ table_name|snake_to_pascal_case }}CRUD(auth).create(data=data)
# 使用CreateSchema做校验后入库
create_schema = {{ table_name|snake_to_pascal_case }}CreateSchema.model_validate(data)
await {{ table_name|snake_to_pascal_case }}CRUD(auth).create_crud(data=create_schema)
success_count += 1
except Exception as e:
error_msgs.append(f"第{count}行: {str(e)}")
@@ -1,6 +1,6 @@
import request from "@/utils/request";
const API_PATH = "/{{ module_name }}/{{ business_name }}";
const API_PATH = "/{{ module_name }}/{{ business_name|lower }}";
// 参考 demo.ts 的风格,提供标准的 CRUD 与导入/导出 APITypeScript
const {{ business_name|replace('_', ' ')|title|replace(' ', '') }}API = {
@@ -60,7 +60,7 @@ const {{ business_name|replace('_', ' ')|title|replace(' ', '') }}API = {
// 下载导入模板
downloadTemplate() {
return request<ApiResponse>({
return request<Blob>({
url: `${API_PATH}/download/template`,
method: "post",
responseType: "blob",
@@ -68,7 +68,7 @@ const {{ business_name|replace('_', ' ')|title|replace(' ', '') }}API = {
},
// 导入
import(data: any) {
import(data: FormData) {
return request<ApiResponse>({
url: `${API_PATH}/import`,
method: "post",
@@ -76,6 +76,14 @@ const {{ business_name|replace('_', ' ')|title|replace(' ', '') }}API = {
headers: { "Content-Type": "multipart/form-data" },
});
},
// 批量启用/停用
batchAvailable(body: { ids: number[]; status: boolean }) {
return request<ApiResponse>({
url: `${API_PATH}/available/setting`,
method: "patch",
data: body,
});
},
};
export default {{ business_name|replace('_', ' ')|title|replace(' ', '') }}API;
@@ -1,8 +1,8 @@
<template>
<div class="app-container">
<!-- 搜索区域 -->
<div class="search-container">
<el-form :model="queryFormData" ref="queryFormRef" :inline="true" label-suffix=":">
<div v-show="visible" class="search-container">
<el-form ref="queryFormRef" :model="queryFormData" label-suffix=":" :inline="true" @submit.prevent="handleQuery">
{% for column in columns %}
{% if column.is_query == "1" %}
{% set dict_type = column.dict_type %}
@@ -12,7 +12,7 @@
{% if column.html_type == "input" %}
<el-form-item label="{{ comment }}" prop="{{ column.python_field }}">
<el-input v-model="queryFormData.{{ column.python_field }}" placeholder="请输入{{ comment }}" clearable @keyup.enter="handleQuery" />
<el-input v-model="queryFormData.{{ column.python_field }}" placeholder="请输入{{ comment }}" clearable />
</el-form-item>
{% elif (column.html_type == "select" or column.html_type == "radio") and dict_type != "" %}
<el-form-item label="{{ comment }}" prop="{{ column.python_field }}">
@@ -30,16 +30,32 @@
<el-form-item label="{{ comment }}" prop="{{ column.python_field }}">
<el-date-picker v-model="queryFormData.{{ column.python_field }}" type="date" value-format="YYYY-MM-DD" clearable placeholder="请选择{{ comment }}" />
</el-form-item>
{% elif column.html_type == "datetime" and column.query_type == "BETWEEN" %}
<el-form-item label="{{ comment }}" style="width: 308px">
<el-date-picker v-model="dateRangeMap.{{ column.python_field }}" value-format="YYYY-MM-DD" type="daterange" range-separator="-" start-placeholder="开始日期" end-placeholder="结束日期" />
</el-form-item>
{% endif %}
{% endif %}
{% endfor %}
<el-form-item class="search-buttons">
<el-button type="primary" icon="search" @click="handleQuery">查询</el-button>
<el-button icon="refresh" @click="handleResetQuery">重置</el-button>
<!-- 可选:创建人选择与统一日期范围(展开后显示) -->
<el-form-item v-if="isExpand" prop="creator" label="创建人">
<UserTableSelect v-model="queryFormData.creator" @confirm-click="handleConfirm" @clear-click="handleQuery" />
</el-form-item>
<el-form-item v-if="isExpand" prop="start_time" label="创建时间">
<DatePicker v-model="dateRange" @update:model-value="handleDateRangeChange" />
</el-form-item>
<el-form-item>
<el-button v-hasPerm="['{{ module_name }}:{{ business_name }}:query']" type="primary" icon="search" @click="handleQuery">查询</el-button>
<el-button v-hasPerm="['{{ module_name }}:{{ business_name|lower }}:query']" icon="refresh" @click="handleResetQuery">重置</el-button>
<template v-if="isExpandable">
<el-link class="ml-3" type="primary" underline="never" @click="isExpand = !isExpand">
{{ '{{' }} isExpand ? "收起" : "展开" {{ '}}' }}
<el-icon>
<template v-if="isExpand">
<ArrowUp />
</template>
<template v-else>
<ArrowDown />
</template>
</el-icon>
</el-link>
</template>
</el-form-item>
</el-form>
</div>
@@ -59,23 +75,61 @@
<!-- 功能区域 -->
<div class="data-table__toolbar">
<div class="data-table__toolbar--actions">
<div class="data-table__toolbar--left">
<el-row :gutter="10">
<el-col :span="1.5">
<el-button type="success" icon="plus" @click="handleOpenDialog('create')">新增</el-button>
<el-button v-hasPerm="['{{ module_name }}:{{ business_name }}:create']" type="success" icon="plus" @click="handleOpenDialog('create')">新增</el-button>
</el-col>
<el-col :span="1.5">
<el-button type="danger" icon="delete" :disabled="selectIds.length === 0" @click="handleDelete(selectIds)">批量删除</el-button>
<el-button v-hasPerm="['{{ module_name }}:{{ business_name }}:delete']" type="danger" icon="delete" :disabled="selectIds.length === 0" @click="handleDelete(selectIds)">批量删除</el-button>
</el-col>
<el-col :span="1.5">
<el-dropdown v-hasPerm="['{{ module_name }}:{{ business_name }}:batch']" trigger="click">
<el-button type="default" :disabled="selectIds.length === 0" icon="ArrowDown">更多</el按钮>
<template #dropdown>
<el-dropdown-menu>
<el-dropdown-item icon="Check" @click="handleMoreClick(true)">批量启用</el-dropdown-item>
<el-dropdown-item icon="CircleClose" @click="handleMoreClick(false)">批量停用</el-dropdown-item>
</el-dropdown-menu>
</template>
</el-dropdown>
</el-col>
</el-row>
</div>
<div class="data-table__toolbar--tools">
<div class="data-table__toolbar--right">
<el-row :gutter="10">
<el-col :span="1.5">
<el-tooltip content="刷新">
<el-button type="primary" icon="refresh" circle @click="handleRefresh" />
<el-tooltip content="导入">
<el-button v-hasPerm="['{{ module_name }}:{{ business_name }}:import']" type="success" icon="upload" circle @click="handleOpenImportDialog" />
</el-tooltip>
</el-col>
<el-col :span="1.5">
<el-tooltip content="导出">
<el-button v-hasPerm="['{{ module_name }}:{{ business_name }}:export']" type="warning" icon="download" circle @click="handleOpenExportsModal" />
</el-tooltip>
</el-col>
<el-col :span="1.5">
<el-tooltip content="搜索显示/隐藏">
<el-button v-hasPerm="['*:*:*']" type="info" icon="search" circle @click="visible = !visible" />
</el-tooltip>
</el-col>
<el-col :span="1.5">
<el-tooltip content="刷新">
<el-button v-hasPerm="['{{ module_name }}:{{ business_name }}:refresh']" type="primary" icon="refresh" circle @click="handleRefresh" />
</el-tooltip>
</el-col>
<el-col :span="1.5">
<el-popover placement="bottom" trigger="click">
<template #reference>
<el-button type="danger" icon="operation" circle></el-button>
</template>
<el-scrollbar max-height="350px">
<template v-for="column in tableColumns" :key="column.prop">
<el-checkbox v-if="column.prop" v-model="column.show" :label="column.label" />
</template>
</el-scrollbar>
</el-popover>
</el-col>
</el-row>
</div>
</div>
@@ -95,10 +149,10 @@
<template #empty>
<el-empty :image-size="80" description="暂无数据" />
</template>
<el-table-column type="selection" min-width="55" align="center" />
<el-table-column fixed label="序号" min-width="60">
<el-table-column v-if="tableColumns.find((col) => col.prop === 'selection')?.show" type="selection" min-width="55" align="center" />
<el-table-column v-if="tableColumns.find((col) => col.prop === 'index')?.show" fixed label="序号" min-width="60">
<template #default="scope">
{{ '{' }}{{ '{' }} (queryFormData.page_no - 1) * queryFormData.page_size + scope.$index + 1 {{ '}' }}{{ '}' }}
{{ '{{' }} (queryFormData.page_no - 1) * queryFormData.page_size + scope.$index + 1 {{ '}}' }}
</template>
</el-table-column>
{% for column in columns %}
@@ -107,20 +161,24 @@
{% set parentheseIndex = column_comment.find("") %}
{% set comment = column_comment[:parentheseIndex] if parentheseIndex != -1 else column_comment %}
{% if column.is_list == "1" %}
<el-table-column label="{{ comment }}" prop="{{ python_field }}" min-width="140">
{% if column.html_type == "datetime" %}
<el-table-column v-if="tableColumns.find((col) => col.prop === '{{ python_field }}')?.show" label="{{ comment }}" prop="{{ python_field }}" min-width="140">
{% if python_field == "status" %}
<template #default="scope">
<span>{{ '{' }}{{ '{' }} scope.row.{{ python_field }} {{ '}' }}{{ '}' }}</span>
<el-tag :type="scope.row.status ? 'success' : 'info'">{{ '{{' }} scope.row.status ? '启用' : '停用' {{ '}}' }}</el-tag>
</template>
{% elif python_field == "creator" %}
<template #default="scope">
<el-tag>{{ '{{' }} scope.row.creator?.name {{ '}}' }}</el-tag>
</template>
{% endif %}
</el-table-column>
{% endif %}
{% endfor %}
<el-table-column fixed="right" label="操作" align="center" min-width="200">
<el-table-column v-if="tableColumns.find(col => col.prop === 'operation')?.show" fixed="right" label="操作" align="center" min-width="180">
<template #default="scope">
<el-button type="info" size="small" link icon="document" @click="handleOpenDialog('detail', scope.row.id)">详情</el-button>
<el-button type="primary" size="small" link icon="edit" @click="handleOpenDialog('update', scope.row.id)">编辑</el-button>
<el-button type="danger" size="small" link icon="delete" @click="handleDelete([scope.row.id])">删除</el-button>
<el-button v-hasPerm="['{{ module_name }}:{{ business_name }}:detail']" type="info" size="small" link icon="document" @click="handleOpenDialog('detail', scope.row.id)">详情</el-button>
<el-button v-hasPerm="['{{ module_name }}:{{ business_name }}:update']" type="primary" size="small" link icon="edit" @click="handleOpenDialog('update', scope.row.id)">编辑</el-button>
<el-button v-hasPerm="['{{ module_name }}:{{ business_name }}:delete']" type="danger" size="small" link icon="delete" @click="handleDelete([scope.row.id])">删除</el-button>
</template>
</el-table-column>
</el-table>
@@ -158,7 +216,14 @@
{% set comment = column_comment[:parentheseIndex] if parentheseIndex != -1 else column_comment %}
{% set required = 'true' if column.is_required == '1' else 'false' %}
{% if column.html_type == "input" %}
{% if column.python_field == "status" %}
<el-form-item label="状态" prop="status" :required="true">
<el-radio-group v-model="formData.status">
<el-radio :value="true">启用</el-radio>
<el-radio :value="false">停用</el-radio>
</el-radio-group>
</el-form-item>
{% elif column.html_type == "input" %}
<el-form-item label="{{ comment }}" prop="{{ column.python_field }}" :required="{{ required }}">
<el-input v-model="formData.{{ column.python_field }}" placeholder="请输入{{ comment }}" />
</el-form-item>
@@ -203,25 +268,52 @@
<template #footer>
<div class="dialog-footer">
<el-button @click="handleCloseDialog">取消</el-button>
<el-button v-if="dialogVisible.type !== 'detail'" type="primary" @click="handleSubmit">确定</el-button>
<el-button v-else type="primary" @click="handleCloseDialog">确定</el-button>
<el-button v-if="dialogVisible.type !== 'detail'" v-hasPerm="['{{ module_name }}:{{ business_name|lower }}:submit']" type="primary" @click="handleSubmit">确定</el-button>
<el-button v-else v-hasPerm="['{{ module_name }}:{{ business_name|lower }}:detail']" type="primary" @click="handleCloseDialog">确定</el-button>
</div>
</template>
</el-dialog>
<!-- 导入弹窗 -->
<ImportModal
v-model="importDialogVisible"
:content-config="curdContentConfig"
@upload="handleUpload"
/>
<!-- 导出弹窗 -->
<ExportModal
v-model="exportsDialogVisible"
:content-config="curdContentConfig"
:query-params="queryFormData"
:page-data="pageTableData"
:selection-data="selectionRows"
/>
</div>
</template>
<script setup lang="ts">
import { ref, reactive, onMounted } from 'vue'
import { ElMessage, ElMessageBox } from 'element-plus'
import { ResultEnum } from '@/enums/api/result.enum'
import { QuestionFilled, ArrowUp, ArrowDown, Check, CircleClose } from '@element-plus/icons-vue'
import { formatToDateTime } from '@/utils/dateUtil'
import {{ business_name|replace('_', ' ')|title|replace(' ', '') }}API from '@/api/{{ module_name }}/{{ business_name }}'
import { useDictStore } from '@/store/index'
import SingleImageUpload from '@/components/Upload/SingleImageUpload.vue'
import ImportModal from '@/components/CURD/ImportModal.vue'
import ExportModal from '@/components/CURD/ExportModal.vue'
import DatePicker from '@/components/DatePicker/index.vue'
import type { IContentConfig } from '@/components/CURD/types'
const visible = ref(true)
const isExpand = ref(false)
const isExpandable = ref(true)
const queryFormRef = ref()
const dataFormRef = ref()
const total = ref(0)
const selectIds = ref([])
const selectIds = ref<number[]>([])
const selectionRows = ref<any[]>([])
const loading = ref(false)
// 字典仓库与需要加载的字典类型
@@ -235,147 +327,53 @@ const dictTypes = [
]
// 表格数据
const pageTableData = ref([])
const pageTableData = ref<any[]>([])
// 详情表单
const detailFormData = ref({})
// 日期范围映射(支持多个 BETWEEN 字段)
const dateRangeMap = reactive({})
// 查询参数
const queryFormData = reactive({
page_no: 1,
page_size: 10,
// 表格列配置(根据列生成,可显隐)
const tableColumns = ref([
{ prop: 'selection', label: '选择框', show: true },
{ prop: 'index', label: '序号', show: true },
{% for column in columns %}
{% if column.is_query == "1" and column.query_type != "BETWEEN" %}
{{ column.python_field }}: undefined,
{% if column.is_list == "1" %}
{ prop: '{{ column.python_field }}', label: '{{ column.column_comment or column.python_field }}', show: true },
{% endif %}
{% endfor %}
})
{ prop: 'operation', label: '操作', show: true }
])
// 加载表格数据
async function loadingData() {
loading.value = true
try {
const params = {
...queryFormData,
// 导出列(不含选择/序号/操作)
const exportColumns = [
{% for column in columns %}
{% if column.html_type == "datetime" and column.query_type == "BETWEEN" %}
{{ column.python_field }}_start: dateRangeMap.{{ column.python_field }} && dateRangeMap.{{ column.python_field }}.length > 0 ? dateRangeMap.{{ column.python_field }}[0] : undefined,
{{ column.python_field }}_end: dateRangeMap.{{ column.python_field }} && dateRangeMap.{{ column.python_field }}.length > 0 ? dateRangeMap.{{ column.python_field }}[1] : undefined,
{% if column.is_list == "1" %}
{ prop: '{{ column.python_field }}', label: '{{ column.column_comment or column.python_field }}' },
{% endif %}
{% endfor %}
}
const response = await {{ business_name|replace('_', ' ')|title|replace(' ', '') }}API.list(params)
// 参考 demo.ts 返回结构
pageTableData.value = response.data.data.items
total.value = response.data.data.total
} catch (error) {
console.error(error)
} finally {
loading.value = false
}
}
]
// 查询(重置页码后获取数据)
async function handleQuery() {
queryFormData.page_no = 1
loadingData()
}
// 重置查询
async function handleResetQuery() {
queryFormRef.value.resetFields()
queryFormData.page_no = 1
// 重置所有日期范围选择器
{% for column in columns %}
{% if column.html_type == "datetime" and column.query_type == "BETWEEN" %}
dateRangeMap.{{ column.python_field }} = []
{% endif %}
{% endfor %}
loadingData()
}
// 行复选框选中项变化
function handleSelectionChange(selection) {
selectIds.value = selection.map((item) => item.id)
}
// 关闭弹窗
function handleCloseDialog() {
dialogVisible.visible = false
}
// 打开弹窗
async function handleOpenDialog(type, id) {
dialogVisible.type = type
if (id) {
const response = await {{ business_name|replace('_', ' ')|title|replace(' ', '') }}API.detail(id)
if (type === 'detail') {
dialogVisible.title = '详情'
Object.assign(detailFormData.value, response.data.data)
} else if (type === 'update') {
dialogVisible.title = '修改'
Object.assign(formData, response.data.data)
// 导入/导出配置
const curdContentConfig = {
permPrefix: '{{ module_name }}:{{ business_name }}',
cols: exportColumns as any,
importTemplate: () => {{ business_name|replace('_', ' ')|title|replace(' ', '') }}API.downloadTemplate(),
exportsAction: async (params: any) => {
const query: any = { ...params };
if (typeof query.status === 'string') {
query.status = query.status === 'true'
}
} else {
dialogVisible.title = '新增{{ function_name }}'
formData.id = undefined
query.page_no = 1
query.page_size = 9999
const all: any[] = []
while (true) {
const res = await {{ business_name|replace('_', ' ')|title|replace(' ', '') }}API.list(query)
const items = res.data?.data?.items || []
const total = res.data?.data?.total || 0
all.push(...items)
if (all.length >= total || items.length === 0) break
query.page_no += 1
}
dialogVisible.visible = true
}
// 提交表单
async function handleSubmit() {
dataFormRef.value.validate(async (valid) => {
if (valid) {
loading.value = true
try {
const id = formData.id
if (id) {
await {{ business_name|replace('_', ' ')|title|replace(' ', '') }}API.update(id, { id, ...formData })
ElMessage.success('修改成功')
} else {
await {{ business_name|replace('_', ' ')|title|replace(' ', '') }}API.create(formData)
ElMessage.success('新增成功')
}
dialogVisible.visible = false
handleResetQuery()
} catch (error) {
console.error(error)
ElMessage.error('操作失败')
} finally {
loading.value = false
}
}
})
}
// 删除、批量删除
async function handleDelete(ids) {
ElMessageBox.confirm('确认删除该项数据?', '警告', {
confirmButtonText: '确定',
cancelButtonText: '取消',
type: 'warning',
})
.then(async () => {
try {
loading.value = true
await {{ business_name|replace('_', ' ')|title|replace(' ', '') }}API.delete(ids)
ElMessage.success('删除成功')
handleResetQuery()
} catch (error) {
console.error(error)
ElMessage.error('删除失败')
} finally {
loading.value = false
}
})
.catch(() => {
ElMessageBox.close()
})
}
return all
},
} as unknown as IContentConfig
// 弹窗状态
const dialogVisible = reactive({
@@ -406,6 +404,198 @@ const rules = reactive({
{% endfor %}
})
// 详情表单
const detailFormData = ref<any>({})
// 统一日期范围
const dateRange = ref<[Date, Date] | []>([])
function handleDateRangeChange(range: [Date, Date]) {
dateRange.value = range
if (range && range.length === 2) {
queryFormData.start_time = formatToDateTime(range[0])
queryFormData.end_time = formatToDateTime(range[1])
} else {
queryFormData.start_time = undefined
queryFormData.end_time = undefined
}
}
// 查询参数
const queryFormData = reactive({
page_no: 1,
page_size: 10,
{% for column in columns %}
{% if column.is_query == "1" and column.query_type != "BETWEEN" %}
{{ column.python_field }}: undefined,
{% endif %}
{% endfor %}
start_time: undefined,
end_time: undefined,
creator: undefined,
})
// 加载表格数据
async function loadingData() {
loading.value = true
try {
const response = await {{ business_name|replace('_', ' ')|title|replace(' ', '') }}API.list(queryFormData)
pageTableData.value = response.data.data.items
total.value = response.data.data.total
} catch (error) {
console.error(error)
} finally {
loading.value = false
}
}
// 查询(重置页码后获取数据)
async function handleQuery() {
queryFormData.page_no = 1
loadingData()
}
// 选择创建人后触发查询
function handleConfirm() {
handleQuery()
}
// 重置查询
async function handleResetQuery() {
queryFormRef.value.resetFields()
queryFormData.page_no = 1
dateRange.value = []
queryFormData.start_time = undefined
queryFormData.end_time = undefined
loadingData()
}
// 行复选框选中项变化
function handleSelectionChange(selection: any[]) {
selectIds.value = selection.map((item: any) => item.id)
selectionRows.value = selection
}
// 关闭弹窗
function handleCloseDialog() {
dialogVisible.visible = false
}
// 打开弹窗
async function handleOpenDialog(type: 'create' | 'update' | 'detail', id?: number) {
dialogVisible.type = type
if (id) {
const response = await {{ business_name|replace('_', ' ')|title|replace(' ', '') }}API.detail(id)
if (type === 'detail') {
dialogVisible.title = '详情'
Object.assign(detailFormData.value, response.data.data)
} else if (type === 'update') {
dialogVisible.title = '修改'
Object.assign(formData, response.data.data)
}
} else {
dialogVisible.title = '新增{{ function_name }}'
formData.id = undefined
}
dialogVisible.visible = true
}
// 提交表单
async function handleSubmit() {
dataFormRef.value.validate(async (valid: any) => {
if (valid) {
loading.value = true
try {
const id = formData.id
if (id) {
await {{ business_name|replace('_', ' ')|title|replace(' ', '') }}API.update(id, { id, ...formData })
} else {
await {{ business_name|replace('_', ' ')|title|replace(' ', '') }}API.create(formData)
}
dialogVisible.visible = false
handleResetQuery()
} catch (error) {
console.error(error)
} finally {
loading.value = false
}
}
})
}
// 删除、批量删除
async function handleDelete(ids: number[]) {
ElMessageBox.confirm('确认删除该项数据?', '警告', {
confirmButtonText: '确定',
cancelButtonText: '取消',
type: 'warning',
})
.then(async () => {
try {
loading.value = true
await {{ business_name|replace('_', ' ')|title|replace(' ', '') }}API.delete(ids)
handleResetQuery()
} catch (error) {
console.error(error)
} finally {
loading.value = false
}
})
.catch(() => {
ElMessageBox.close()
})
}
// 批量启用/停用
async function handleMoreClick(status: boolean) {
if (selectIds.value.length) {
ElMessageBox.confirm(`确认${status ? '启用' : '停用'}该项数据?`, '警告', {
confirmButtonText: '确定',
cancelButtonText: '取消',
type: 'warning',
}).then(async () => {
try {
loading.value = true
await {{ business_name|replace('_', ' ')|title|replace(' ', '') }}API.batchAvailable({ ids: selectIds.value, status })
handleResetQuery()
} catch (error) {
console.error(error)
} finally {
loading.value = false
}
}).catch(() => {
ElMessageBox.close()
})
}
}
// 导入弹窗显示状态
const importDialogVisible = ref(false)
// 导出弹窗显示状态
const exportsDialogVisible = ref(false)
// 打开导入弹窗
function handleOpenImportDialog() {
importDialogVisible.value = true
}
// 打开导出弹窗
function handleOpenExportsModal() {
exportsDialogVisible.value = true
}
// 处理上传
const handleUpload = async (formData: FormData) => {
try {
const response = await {{ business_name|replace('_', ' ')|title|replace(' ', '') }}API.import(formData)
if (response.data.code === ResultEnum.SUCCESS) {
ElMessage.success(`${response.data.msg}${response.data.data}`)
importDialogVisible.value = false
await handleQuery()
}
} catch (error) {
console.error(error)
}
}
// 列表刷新
async function handleRefresh() {
await loadingData()