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响应 - 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) result = await GenTableService.generate_code_service(auth, table_name)
logger.info('生成代码到指定路径成功') logger.info('生成代码到指定路径成功')
return SuccessResponse(msg="生成代码到指定路径成功", data=result) return SuccessResponse(msg="生成代码到指定路径成功", data=result)
@@ -408,18 +408,35 @@ class GenTableService:
""" """
env = Jinja2TemplateInitializerUtil.init_jinja2() env = Jinja2TemplateInitializerUtil.init_jinja2()
render_info = await cls.__get_gen_render_info(auth, table_name) 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]: for template in render_info[0]:
try: try:
render_content = await env.get_template(template).render_async(**render_info[2]) 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: 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) 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: with open(gen_path, 'w', encoding='utf-8') as f:
f.write(render_content) f.write(render_content)
except Exception as e: 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 @classmethod
async def batch_gen_code_service(cls, auth: AuthSchema, table_names: List[str]) -> bytes: async def batch_gen_code_service(cls, auth: AuthSchema, table_names: List[str]) -> bytes:
@@ -682,13 +699,14 @@ class GenTableService:
- Optional[str]: 生成的文件路径,若失败则返回None。 - Optional[str]: 生成的文件路径,若失败则返回None。
""" """
try: 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) file_name = Jinja2TemplateUtil.get_file_name(template, gen_table)
# 修复:检查文件名是否为空
if not file_name: if not file_name:
return None return None
if gen_path == '/': # 默认写入到项目根目录(backend的上一级)
return os.path.join(os.getcwd(), GEN_PATH, file_name) project_root = str(settings.BASE_DIR.parent)
if gen_path in ['', '/']:
return os.path.join(project_root, file_name)
else: else:
return os.path.join(gen_path, file_name) return os.path.join(gen_path, file_name)
except Exception: except Exception:
+21 -17
View File
@@ -248,33 +248,35 @@ class Jinja2TemplateUtil:
# 处理空值情况 # 处理空值情况
category = tpl_category or GenConstant.TPL_CRUD category = tpl_category or GenConstant.TPL_CRUD
templates = [ templates = [
# Python相关模板 # Python相关模板(调整为实际目录)
'python/controller.py.j2', 'backend/app/v1/module_demo/python/controller.py.j2',
'python/service.py.j2', 'backend/app/v1/module_demo/python/service.py.j2',
'python/crud.py.j2', 'backend/app/v1/module_demo/python/crud.py.j2',
'python/schema.py.j2', 'backend/app/v1/module_demo/python/schema.py.j2',
'python/param.py.j2', 'backend/app/v1/module_demo/python/param.py.j2',
'python/model.py.j2', 'backend/app/v1/module_demo/python/model.py.j2',
# Vue相关模板 'backend/app/v1/module_demo/python/__init__.py.j2',
f'{use_web_type}/api.ts.j2',
# SQL脚本模板 # Vue相关模板(API
'sql/sql.sql.j2', 'frontend/src/api/api.ts.j2',
# SQL脚本模板(调整为实际目录)
'backend/sql/sql.sql.j2',
] ]
if category == GenConstant.TPL_CRUD: 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: 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: 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 return templates
@classmethod @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): 生成表的配置信息。 - gen_table (GenTableOutSchema): 生成表的配置信息。
返回: 返回:
@@ -299,7 +301,9 @@ class Jinja2TemplateUtil:
return f'{python_path}/app/api/v1/{module_name}/{business_name}/param.py' return f'{python_path}/app/api/v1/{module_name}/{business_name}/param.py'
elif 'schema.py.j2' in template: elif 'schema.py.j2' in template:
return f'{python_path}/app/api/v1/{module_name}/{business_name}/schema.py' 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' return f'{cls.BACKEND_PROJECT_PATH}/sql/{module_name}/{business_name}_menu.sql'
elif 'api.ts.j2' in template: elif 'api.ts.j2' in template:
return f'{vue_path}/src/api/{module_name}/{business_name}.ts' 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.dependencies import AuthPermission
from app.core.router_class import OperationLogRoute from app.core.router_class import OperationLogRoute
from app.api.v1.module_system.auth.schema import AuthSchema 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.core.base_params import PaginationQueryParam
from app.utils.common_util import bytes2file_response from app.utils.common_util import bytes2file_response
from app.core.logger import logger 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(), search: {{ table_name|snake_to_pascal_case }}QueryParam = Depends(),
auth: AuthSchema = Depends(AuthPermission(["{{ permission_prefix }}:query"])) auth: AuthSchema = Depends(AuthPermission(["{{ permission_prefix }}:query"]))
) -> JSONResponse: ) -> JSONResponse:
"""查询{{ function_name }}列表接口""" """查询{{ 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 {{ table_name|snake_to_pascal_case }}Service.page_service(
result_dict = await PaginationService.paginate(data_list=result_dict_list, page_no=page.page_no, page_size=page.page_size) 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 }}列表成功") logger.info("查询{{ function_name }}列表成功")
return SuccessResponse(data=result_dict, msg="查询{{ function_name }}列表成功") return SuccessResponse(data=result_dict, msg="查询{{ function_name }}列表成功")
@@ -1,11 +1,11 @@
# -*- coding:utf-8 -*- # -*- 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.core.base_crud import CRUDBase
from app.api.v1.module_system.auth.schema import AuthSchema from app.api.v1.module_system.auth.schema import AuthSchema
from .model import {{ table_name|snake_to_pascal_case }}Model 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]): 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""" """初始化CRUD"""
super().__init__(model={{ table_name|snake_to_pascal_case }}Model, auth=auth) 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]: async def create_crud(self, data: {{ table_name|snake_to_pascal_case }}CreateSchema) -> Optional[{{ table_name|snake_to_pascal_case }}Model]:
"""创建""" """创建"""
@@ -37,4 +37,17 @@ 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: async def set_available_crud(self, ids: List[int], status: bool) -> None:
"""批量设置可用状态""" """批量设置可用状态"""
return await self.set(ids=ids, status=status) 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 datetime import datetime
from typing import Optional from typing import Optional
from sqlalchemy import String, Integer, Text, DateTime from sqlalchemy import String, Integer, Text, DateTime, Boolean, ForeignKey
from sqlalchemy.orm import Mapped, mapped_column from sqlalchemy.orm import Mapped, mapped_column, relationship
from app.core.base_model import CreatorMixin from app.core.base_model import CreatorMixin
@@ -15,6 +15,7 @@ class {{ table_name|snake_to_pascal_case }}Model(CreatorMixin):
__tablename__ = '{{ table_name }}' __tablename__ = '{{ table_name }}'
__table_args__ = {'comment': '{{ function_name }}'} __table_args__ = {'comment': '{{ function_name }}'}
__loader_options__ = ["creator"]
{% for column in columns %} {% 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 }}') {{ column.column_name }}: Mapped[Optional[{{ column.python_type }}]] = mapped_column({{ column.column_type|get_sqlalchemy_type }}, nullable=True, comment='{{ column.column_comment }}')
@@ -34,6 +34,20 @@ class {{ table_name|snake_to_pascal_case }}Service:
search_dict = search.__dict__ if search else None search_dict = search.__dict__ if search else None
obj_list = await {{ table_name|snake_to_pascal_case }}CRUD(auth).list_crud(search=search_dict, order_by=order_by) 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] 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 @classmethod
async def create_service(cls, auth: AuthSchema, data: {{ table_name|snake_to_pascal_case }}CreateSchema) -> Dict: 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() 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) return ExcelUtil.export_list2excel(list_data=data, mapping_dict=mapping_dict)
@classmethod @classmethod
@@ -117,7 +142,9 @@ class {{ table_name|snake_to_pascal_case }}Service:
"{{ column.column_name }}": row['{{ column.column_name }}'], "{{ column.column_name }}": row['{{ column.column_name }}'],
{% endfor %} {% 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 success_count += 1
except Exception as e: except Exception as e:
error_msgs.append(f"第{count}行: {str(e)}") error_msgs.append(f"第{count}行: {str(e)}")
@@ -1,6 +1,6 @@
import request from "@/utils/request"; import request from "@/utils/request";
const API_PATH = "/{{ module_name }}/{{ business_name }}"; const API_PATH = "/{{ module_name }}/{{ business_name|lower }}";
// 参考 demo.ts 的风格,提供标准的 CRUD 与导入/导出 APITypeScript // 参考 demo.ts 的风格,提供标准的 CRUD 与导入/导出 APITypeScript
const {{ business_name|replace('_', ' ')|title|replace(' ', '') }}API = { const {{ business_name|replace('_', ' ')|title|replace(' ', '') }}API = {
@@ -60,7 +60,7 @@ const {{ business_name|replace('_', ' ')|title|replace(' ', '') }}API = {
// 下载导入模板 // 下载导入模板
downloadTemplate() { downloadTemplate() {
return request<ApiResponse>({ return request<Blob>({
url: `${API_PATH}/download/template`, url: `${API_PATH}/download/template`,
method: "post", method: "post",
responseType: "blob", responseType: "blob",
@@ -68,7 +68,7 @@ const {{ business_name|replace('_', ' ')|title|replace(' ', '') }}API = {
}, },
// 导入 // 导入
import(data: any) { import(data: FormData) {
return request<ApiResponse>({ return request<ApiResponse>({
url: `${API_PATH}/import`, url: `${API_PATH}/import`,
method: "post", method: "post",
@@ -76,6 +76,14 @@ const {{ business_name|replace('_', ' ')|title|replace(' ', '') }}API = {
headers: { "Content-Type": "multipart/form-data" }, 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; export default {{ business_name|replace('_', ' ')|title|replace(' ', '') }}API;
@@ -86,13 +94,13 @@ export default {{ business_name|replace('_', ' ')|title|replace(' ', '') }}API;
export interface {{ business_name|replace('_', ' ')|title|replace(' ', '') }}PageQuery extends PageQuery { export interface {{ business_name|replace('_', ' ')|title|replace(' ', '') }}PageQuery extends PageQuery {
{% for column in columns %} {% for column in columns %}
{% if column.is_query == "1" and column.query_type != "BETWEEN" %} {% if column.is_query == "1" and column.query_type != "BETWEEN" %}
{{ column.python_field }}?: {{ {{ column.python_field }}?: {{
'boolean' if ('status' in (column.python_field|lower)) or (column.html_type == 'radio') 'boolean' if ('status' in (column.python_field|lower)) or (column.html_type == 'radio')
else 'number' if column.is_pk == '1' else 'number' if column.is_pk == '1'
else 'string' else 'string'
}}; }};
{% endif %} {% endif %}
{% endfor %} {% endfor %}
// 时间范围查询(按示例统一字段名,如需按字段拆分可在页面层处理) // 时间范围查询(按示例统一字段名,如需按字段拆分可在页面层处理)
start_time?: string; start_time?: string;
@@ -101,11 +109,11 @@ export interface {{ business_name|replace('_', ' ')|title|replace(' ', '') }}Pag
export interface {{ business_name|replace('_', ' ')|title|replace(' ', '') }}Table { export interface {{ business_name|replace('_', ' ')|title|replace(' ', '') }}Table {
{% for column in columns %} {% for column in columns %}
{{ column.python_field }}?: {{ {{ column.python_field }}?: {{
'boolean' if ('status' in (column.python_field|lower)) or (column.html_type == 'radio') 'boolean' if ('status' in (column.python_field|lower)) or (column.html_type == 'radio')
else 'number' if column.is_pk == '1' else 'number' if column.is_pk == '1'
else 'string' else 'string'
}}; }};
{% endfor %} {% endfor %}
creator?: creatorType; creator?: creatorType;
} }
@@ -113,12 +121,12 @@ export interface {{ business_name|replace('_', ' ')|title|replace(' ', '') }}Tab
export interface {{ business_name|replace('_', ' ')|title|replace(' ', '') }}Form { export interface {{ business_name|replace('_', ' ')|title|replace(' ', '') }}Form {
id?: number; id?: number;
{% for column in columns %} {% for column in columns %}
{% if column.is_insert == "1" or column.is_edit == "1" %} {% if column.is_insert == "1" or column.is_edit == "1" %}
{{ column.python_field }}?: {{ {{ column.python_field }}?: {{
'boolean' if ('status' in (column.python_field|lower)) or (column.html_type == 'radio') 'boolean' if ('status' in (column.python_field|lower)) or (column.html_type == 'radio')
else 'number' if column.is_pk == '1' else 'number' if column.is_pk == '1'
else 'string' else 'string'
}}; }};
{% endif %} {% endif %}
{% endfor %} {% endfor %}
} }
@@ -1,8 +1,8 @@
<template> <template>
<div class="app-container"> <div class="app-container">
<!-- 搜索区域 --> <!-- 搜索区域 -->
<div class="search-container"> <div v-show="visible" class="search-container">
<el-form :model="queryFormData" ref="queryFormRef" :inline="true" label-suffix=":"> <el-form ref="queryFormRef" :model="queryFormData" label-suffix=":" :inline="true" @submit.prevent="handleQuery">
{% for column in columns %} {% for column in columns %}
{% if column.is_query == "1" %} {% if column.is_query == "1" %}
{% set dict_type = column.dict_type %} {% set dict_type = column.dict_type %}
@@ -12,7 +12,7 @@
{% if column.html_type == "input" %} {% if column.html_type == "input" %}
<el-form-item label="{{ comment }}" prop="{{ column.python_field }}"> <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> </el-form-item>
{% elif (column.html_type == "select" or column.html_type == "radio") and dict_type != "" %} {% elif (column.html_type == "select" or column.html_type == "radio") and dict_type != "" %}
<el-form-item label="{{ comment }}" prop="{{ column.python_field }}"> <el-form-item label="{{ comment }}" prop="{{ column.python_field }}">
@@ -30,16 +30,32 @@
<el-form-item label="{{ comment }}" prop="{{ column.python_field }}"> <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-date-picker v-model="queryFormData.{{ column.python_field }}" type="date" value-format="YYYY-MM-DD" clearable placeholder="请选择{{ comment }}" />
</el-form-item> </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 %}
{% endif %} {% endif %}
{% endfor %} {% endfor %}
<el-form-item class="search-buttons"> <!-- 可选:创建人选择与统一日期范围(展开后显示) -->
<el-button type="primary" icon="search" @click="handleQuery">查询</el-button> <el-form-item v-if="isExpand" prop="creator" label="创建人">
<el-button icon="refresh" @click="handleResetQuery">重置</el-button> <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-item>
</el-form> </el-form>
</div> </div>
@@ -59,23 +75,61 @@
<!-- 功能区域 --> <!-- 功能区域 -->
<div class="data-table__toolbar"> <div class="data-table__toolbar">
<div class="data-table__toolbar--actions"> <div class="data-table__toolbar--left">
<el-row :gutter="10"> <el-row :gutter="10">
<el-col :span="1.5"> <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>
<el-col :span="1.5"> <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-col>
</el-row> </el-row>
</div> </div>
<div class="data-table__toolbar--tools"> <div class="data-table__toolbar--right">
<el-row :gutter="10"> <el-row :gutter="10">
<el-col :span="1.5"> <el-col :span="1.5">
<el-tooltip content="刷新"> <el-tooltip content="导入">
<el-button type="primary" icon="refresh" circle @click="handleRefresh" /> <el-button v-hasPerm="['{{ module_name }}:{{ business_name }}:import']" type="success" icon="upload" circle @click="handleOpenImportDialog" />
</el-tooltip> </el-tooltip>
</el-col> </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> </el-row>
</div> </div>
</div> </div>
@@ -95,10 +149,10 @@
<template #empty> <template #empty>
<el-empty :image-size="80" description="暂无数据" /> <el-empty :image-size="80" description="暂无数据" />
</template> </template>
<el-table-column type="selection" min-width="55" align="center" /> <el-table-column v-if="tableColumns.find((col) => col.prop === 'selection')?.show" 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 === 'index')?.show" fixed label="序号" min-width="60">
<template #default="scope"> <template #default="scope">
{{ '{' }}{{ '{' }} (queryFormData.page_no - 1) * queryFormData.page_size + scope.$index + 1 {{ '}' }}{{ '}' }} {{ '{{' }} (queryFormData.page_no - 1) * queryFormData.page_size + scope.$index + 1 {{ '}}' }}
</template> </template>
</el-table-column> </el-table-column>
{% for column in columns %} {% for column in columns %}
@@ -107,20 +161,24 @@
{% set parentheseIndex = column_comment.find("") %} {% set parentheseIndex = column_comment.find("") %}
{% set comment = column_comment[:parentheseIndex] if parentheseIndex != -1 else column_comment %} {% set comment = column_comment[:parentheseIndex] if parentheseIndex != -1 else column_comment %}
{% if column.is_list == "1" %} {% if column.is_list == "1" %}
<el-table-column label="{{ comment }}" prop="{{ python_field }}" min-width="140"> <el-table-column v-if="tableColumns.find((col) => col.prop === '{{ python_field }}')?.show" label="{{ comment }}" prop="{{ python_field }}" min-width="140">
{% if column.html_type == "datetime" %} {% if python_field == "status" %}
<template #default="scope"> <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> </template>
{% endif %} {% endif %}
</el-table-column> </el-table-column>
{% endif %} {% endif %}
{% endfor %} {% 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"> <template #default="scope">
<el-button type="info" size="small" link icon="document" @click="handleOpenDialog('detail', 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 type="primary" size="small" link icon="edit" @click="handleOpenDialog('update', 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 type="danger" size="small" link icon="delete" @click="handleDelete([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> </template>
</el-table-column> </el-table-column>
</el-table> </el-table>
@@ -158,7 +216,14 @@
{% set comment = column_comment[:parentheseIndex] if parentheseIndex != -1 else column_comment %} {% set comment = column_comment[:parentheseIndex] if parentheseIndex != -1 else column_comment %}
{% set required = 'true' if column.is_required == '1' else 'false' %} {% 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-form-item label="{{ comment }}" prop="{{ column.python_field }}" :required="{{ required }}">
<el-input v-model="formData.{{ column.python_field }}" placeholder="请输入{{ comment }}" /> <el-input v-model="formData.{{ column.python_field }}" placeholder="请输入{{ comment }}" />
</el-form-item> </el-form-item>
@@ -203,72 +268,177 @@
<template #footer> <template #footer>
<div class="dialog-footer"> <div class="dialog-footer">
<el-button @click="handleCloseDialog">取消</el-button> <el-button @click="handleCloseDialog">取消</el-button>
<el-button v-if="dialogVisible.type !== 'detail'" type="primary" @click="handleSubmit">确定</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 type="primary" @click="handleCloseDialog">确定</el-button> <el-button v-else v-hasPerm="['{{ module_name }}:{{ business_name|lower }}:detail']" type="primary" @click="handleCloseDialog">确定</el-button>
</div> </div>
</template> </template>
</el-dialog> </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> </div>
</template> </template>
<script setup lang="ts"> <script setup lang="ts">
import { ref, reactive, onMounted } from 'vue' import { ref, reactive, onMounted } from 'vue'
import { ElMessage, ElMessageBox } from 'element-plus' 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 {{ business_name|replace('_', ' ')|title|replace(' ', '') }}API from '@/api/{{ module_name }}/{{ business_name }}'
import { useDictStore } from '@/store/index' import { useDictStore } from '@/store/index'
import SingleImageUpload from '@/components/Upload/SingleImageUpload.vue' 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 queryFormRef = ref()
const dataFormRef = ref() const dataFormRef = ref()
const total = ref(0) const total = ref(0)
const selectIds = ref([]) const selectIds = ref<number[]>([])
const selectionRows = ref<any[]>([])
const loading = ref(false) const loading = ref(false)
// 字典仓库与需要加载的字典类型 // 字典仓库与需要加载的字典类型
const dictStore = useDictStore() const dictStore = useDictStore()
const dictTypes = [ const dictTypes = [
{% for column in columns %} {% for column in columns %}
{% if column.dict_type %} {% if column.dict_type %}
'{{ column.dict_type }}', '{{ column.dict_type }}',
{% endif %} {% endif %}
{% endfor %} {% endfor %}
] ]
// 表格数据 // 表格数据
const pageTableData = ref([]) const pageTableData = ref<any[]>([])
// 表格列配置(根据列生成,可显隐)
const tableColumns = ref([
{ prop: 'selection', label: '选择框', show: true },
{ prop: 'index', label: '序号', show: true },
{% for column in columns %}
{% 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 }
])
// 导出列(不含选择/序号/操作)
const exportColumns = [
{% for column in columns %}
{% if column.is_list == "1" %}
{ prop: '{{ column.python_field }}', label: '{{ column.column_comment or column.python_field }}' },
{% endif %}
{% endfor %}
]
// 导入/导出配置
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'
}
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
}
return all
},
} as unknown as IContentConfig
// 弹窗状态
const dialogVisible = reactive({
title: '',
visible: false,
type: 'create', // 'create' | 'update' | 'detail'
})
// 编辑表单
const formData = reactive({
id: undefined,
{% for column in columns %}
{% if column.is_insert == "1" or column.is_edit == "1" %}
{{ column.python_field }}: undefined,
{% endif %}
{% endfor %}
})
// 表单验证规则(必填项按 is_required 生成)
const rules = reactive({
{% for column in columns %}
{% if column.is_insert == "1" or column.is_edit == "1" %}
{% set required = 'true' if column.is_required == '1' else 'false' %}
{{ column.python_field }}: [
{ required: {{ required }}, message: '请输入{{ column.column_comment or column.python_field }}', trigger: 'blur' },
],
{% endif %}
{% endfor %}
})
// 详情表单 // 详情表单
const detailFormData = ref({}) const detailFormData = ref<any>({})
// 日期范围映射(支持多个 BETWEEN 字段) // 统一日期范围
const dateRangeMap = reactive({}) 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({ const queryFormData = reactive({
page_no: 1, page_no: 1,
page_size: 10, page_size: 10,
{% for column in columns %} {% for column in columns %}
{% if column.is_query == "1" and column.query_type != "BETWEEN" %} {% if column.is_query == "1" and column.query_type != "BETWEEN" %}
{{ column.python_field }}: undefined, {{ column.python_field }}: undefined,
{% endif %} {% endif %}
{% endfor %} {% endfor %}
start_time: undefined,
end_time: undefined,
creator: undefined,
}) })
// 加载表格数据 // 加载表格数据
async function loadingData() { async function loadingData() {
loading.value = true loading.value = true
try { try {
const params = { const response = await {{ business_name|replace('_', ' ')|title|replace(' ', '') }}API.list(queryFormData)
...queryFormData,
{% 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,
{% endif %}
{% endfor %}
}
const response = await {{ business_name|replace('_', ' ')|title|replace(' ', '') }}API.list(params)
// 参考 demo.ts 返回结构
pageTableData.value = response.data.data.items pageTableData.value = response.data.data.items
total.value = response.data.data.total total.value = response.data.data.total
} catch (error) { } catch (error) {
@@ -284,22 +454,25 @@ async function handleQuery() {
loadingData() loadingData()
} }
// 选择创建人后触发查询
function handleConfirm() {
handleQuery()
}
// 重置查询 // 重置查询
async function handleResetQuery() { async function handleResetQuery() {
queryFormRef.value.resetFields() queryFormRef.value.resetFields()
queryFormData.page_no = 1 queryFormData.page_no = 1
// 重置所有日期范围选择器 dateRange.value = []
{% for column in columns %} queryFormData.start_time = undefined
{% if column.html_type == "datetime" and column.query_type == "BETWEEN" %} queryFormData.end_time = undefined
dateRangeMap.{{ column.python_field }} = []
{% endif %}
{% endfor %}
loadingData() loadingData()
} }
// 行复选框选中项变化 // 行复选框选中项变化
function handleSelectionChange(selection) { function handleSelectionChange(selection: any[]) {
selectIds.value = selection.map((item) => item.id) selectIds.value = selection.map((item: any) => item.id)
selectionRows.value = selection
} }
// 关闭弹窗 // 关闭弹窗
@@ -308,7 +481,7 @@ function handleCloseDialog() {
} }
// 打开弹窗 // 打开弹窗
async function handleOpenDialog(type, id) { async function handleOpenDialog(type: 'create' | 'update' | 'detail', id?: number) {
dialogVisible.type = type dialogVisible.type = type
if (id) { if (id) {
const response = await {{ business_name|replace('_', ' ')|title|replace(' ', '') }}API.detail(id) const response = await {{ business_name|replace('_', ' ')|title|replace(' ', '') }}API.detail(id)
@@ -328,23 +501,20 @@ async function handleOpenDialog(type, id) {
// 提交表单 // 提交表单
async function handleSubmit() { async function handleSubmit() {
dataFormRef.value.validate(async (valid) => { dataFormRef.value.validate(async (valid: any) => {
if (valid) { if (valid) {
loading.value = true loading.value = true
try { try {
const id = formData.id const id = formData.id
if (id) { if (id) {
await {{ business_name|replace('_', ' ')|title|replace(' ', '') }}API.update(id, { id, ...formData }) await {{ business_name|replace('_', ' ')|title|replace(' ', '') }}API.update(id, { id, ...formData })
ElMessage.success('修改成功')
} else { } else {
await {{ business_name|replace('_', ' ')|title|replace(' ', '') }}API.create(formData) await {{ business_name|replace('_', ' ')|title|replace(' ', '') }}API.create(formData)
ElMessage.success('新增成功')
} }
dialogVisible.visible = false dialogVisible.visible = false
handleResetQuery() handleResetQuery()
} catch (error) { } catch (error) {
console.error(error) console.error(error)
ElMessage.error('操作失败')
} finally { } finally {
loading.value = false loading.value = false
} }
@@ -353,7 +523,7 @@ async function handleSubmit() {
} }
// 删除、批量删除 // 删除、批量删除
async function handleDelete(ids) { async function handleDelete(ids: number[]) {
ElMessageBox.confirm('确认删除该项数据?', '警告', { ElMessageBox.confirm('确认删除该项数据?', '警告', {
confirmButtonText: '确定', confirmButtonText: '确定',
cancelButtonText: '取消', cancelButtonText: '取消',
@@ -363,11 +533,9 @@ async function handleDelete(ids) {
try { try {
loading.value = true loading.value = true
await {{ business_name|replace('_', ' ')|title|replace(' ', '') }}API.delete(ids) await {{ business_name|replace('_', ' ')|title|replace(' ', '') }}API.delete(ids)
ElMessage.success('删除成功')
handleResetQuery() handleResetQuery()
} catch (error) { } catch (error) {
console.error(error) console.error(error)
ElMessage.error('删除失败')
} finally { } finally {
loading.value = false loading.value = false
} }
@@ -377,34 +545,56 @@ async function handleDelete(ids) {
}) })
} }
// 弹窗状态 // 批量启用/停用
const dialogVisible = reactive({ async function handleMoreClick(status: boolean) {
title: '', if (selectIds.value.length) {
visible: false, ElMessageBox.confirm(`确认${status ? '启用' : '停用'}该项数据?`, '警告', {
type: 'create', // 'create' | 'update' | 'detail' 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 formData = reactive({ const importDialogVisible = ref(false)
id: undefined, // 导出弹窗显示状态
{% for column in columns %} const exportsDialogVisible = ref(false)
{% if column.is_insert == "1" or column.is_edit == "1" %}
{{ column.python_field }}: undefined,
{% endif %}
{% endfor %}
})
// 表单验证规则(必填项按 is_required 生成) // 打开导入弹窗
const rules = reactive({ function handleOpenImportDialog() {
{% for column in columns %} importDialogVisible.value = true
{% if column.is_insert == "1" or column.is_edit == "1" %} }
{% set required = 'true' if column.is_required == '1' else 'false' %} // 打开导出弹窗
{{ column.python_field }}: [ function handleOpenExportsModal() {
{ required: {{ required }}, message: '请输入{{ column.column_comment or column.python_field }}', trigger: 'blur' }, exportsDialogVisible.value = true
], }
{% endif %}
{% endfor %} // 处理上传
}) 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() { async function handleRefresh() {