refactor: 重构代码生成模板与项目结构,新增发票PDF生成功能

1.  统一移动代码生成模板到根目录templates目录,删除旧模板文件
2.  调整SQLAlchemy导入顺序,优化代码格式
3.  重构查询参数类,复用BaseQueryParam基础能力
4.  新增发票管理模块:添加weasyprint依赖,实现本地PDF发票渲染
5.  完善邮件、菜单等模块的代码实现与注释
6.  修复SQL查询条件写法,使用is_(True)替代==True
7.  补全模型类的UserMixin、TenantMixin继承与配置
This commit is contained in:
zhangtao
2026-06-20 15:21:37 +08:00
parent d34d4a4c50
commit bbe77930a8
37 changed files with 1034 additions and 253 deletions
@@ -1 +0,0 @@
# -*- coding: utf-8 -*-
@@ -1,243 +0,0 @@
# -*- coding: utf-8 -*-
import urllib.parse
from typing import Annotated
from fastapi import APIRouter, Body, Depends, Path, UploadFile
from fastapi.responses import JSONResponse, StreamingResponse
from app.common.response import ResponseSchema, StreamResponse, SuccessResponse
from app.core.base_params import PaginationQueryParam
from app.core.base_schema import AuthSchema, BatchSetAvailable, PageResultSchema
from app.core.dependencies import AuthPermission
from app.core.router_class import OperationLogRoute
from app.utils.common_util import bytes2file_response
from .schema import {{ class_name }}CreateSchema, {{ class_name }}OutSchema, {{ class_name }}QueryParam, {{ class_name }}UpdateSchema
from .service import {{ class_name }}Service
{{ class_name }}Router = APIRouter(route_class=OperationLogRoute, prefix="/{{ module_name }}", tags=["{{ function_name }}模块"])
@{{ class_name }}Router.get(
"/detail/{id}",
summary="获取{{ function_name }}详情",
response_model=ResponseSchema[{{ class_name }}OutSchema],
)
async def get_obj_detail_controller(
id: Annotated[int, Path(description="{{ function_name }}ID")],
auth: Annotated[AuthSchema, Depends(AuthPermission(["{{ permission_prefix }}:detail"]))],
) -> JSONResponse:
"""
获取{{ function_name }}详情
参数:
- id (int): {{ function_name }}ID
- auth (AuthSchema): 认证信息模型
返回:
- JSONResponse: 包含{{ function_name }}详情的JSON响应
"""
result_dict = await {{ class_name }}Service.detail_service(id=id, auth=auth)
return SuccessResponse(data=result_dict, msg="获取{{ function_name }}详情成功")
@{{ class_name }}Router.get(
"/list",
summary="分页查询{{ function_name }}",
response_model=ResponseSchema[PageResultSchema[{{ class_name }}OutSchema]],
)
async def get_obj_list_controller(
page: Annotated[PaginationQueryParam, Depends()],
search: Annotated[{{ class_name }}QueryParam, Depends()],
auth: Annotated[AuthSchema, Depends(AuthPermission(["{{ permission_prefix }}:query"]))],
) -> JSONResponse:
"""
查询{{ function_name }}列表
参数:
- page (PaginationQueryParam): 分页查询参数
- search ({{ class_name }}QueryParam): 查询参数
- auth (AuthSchema): 认证信息模型
返回:
- JSONResponse: 包含{{ function_name }}列表分页信息的JSON响应
"""
result_dict = await {{ class_name }}Service.page_service(
auth=auth,
page_no=page.page_no,
page_size=page.page_size,
search=search,
order_by=page.order_by,
)
return SuccessResponse(data=result_dict, msg="查询{{ function_name }}列表成功")
@{{ class_name }}Router.post(
"/create",
summary="创建{{ function_name }}",
response_model=ResponseSchema[{{ class_name }}OutSchema],
)
async def create_obj_controller(
data: {{ class_name }}CreateSchema,
auth: Annotated[AuthSchema, Depends(AuthPermission(["{{ permission_prefix }}:create"]))],
) -> JSONResponse:
"""
创建{{ function_name }}
参数:
- data ({{ class_name }}CreateSchema): {{ function_name }}创建模型
- auth (AuthSchema): 认证信息模型
返回:
- JSONResponse: 包含创建{{ function_name }}详情的JSON响应
"""
result_dict = await {{ class_name }}Service.create_service(auth=auth, data=data)
return SuccessResponse(data=result_dict, msg="创建{{ function_name }}成功")
@{{ class_name }}Router.put(
"/update/{id}",
summary="修改{{ function_name }}",
response_model=ResponseSchema[{{ class_name }}OutSchema],
)
async def update_obj_controller(
data: {{ class_name }}UpdateSchema,
id: Annotated[int, Path(description="{{ function_name }}ID")],
auth: Annotated[AuthSchema, Depends(AuthPermission(["{{ permission_prefix }}:update"]))],
) -> JSONResponse:
"""
修改{{ function_name }}
参数:
- data ({{ class_name }}UpdateSchema): {{ function_name }}更新模型
- id (int): {{ function_name }}ID
- auth (AuthSchema): 认证信息模型
返回:
- JSONResponse: 包含修改{{ function_name }}详情的JSON响应
"""
result_dict = await {{ class_name }}Service.update_service(auth=auth, id=id, data=data)
return SuccessResponse(data=result_dict, msg="修改{{ function_name }}成功")
@{{ class_name }}Router.delete(
"/delete",
summary="删除{{ function_name }}",
response_model=ResponseSchema[None],
)
async def delete_obj_controller(
ids: Annotated[list[int], Body(description="ID列表")],
auth: Annotated[AuthSchema, Depends(AuthPermission(["{{ permission_prefix }}:delete"]))],
) -> JSONResponse:
"""
删除{{ function_name }}
参数:
- ids (list[int]): {{ function_name }}ID列表
- auth (AuthSchema): 认证信息模型
返回:
- JSONResponse: 包含删除{{ function_name }}详情的JSON响应
"""
await {{ class_name }}Service.delete_service(auth=auth, ids=ids)
return SuccessResponse(msg="删除{{ function_name }}成功")
@{{ class_name }}Router.patch(
"/available/setting",
summary="批量修改{{ function_name }}状态",
response_model=ResponseSchema[None],
)
async def batch_set_available_obj_controller(
data: BatchSetAvailable,
auth: Annotated[AuthSchema, Depends(AuthPermission(["{{ permission_prefix }}:patch"]))],
) -> JSONResponse:
"""
批量修改{{ function_name }}状态
参数:
- data (BatchSetAvailable): 批量修改{{ function_name }}状态模型
- auth (AuthSchema): 认证信息模型
返回:
- JSONResponse: 包含批量修改{{ function_name }}状态详情的JSON响应
"""
await {{ class_name }}Service.set_available_service(auth=auth, data=data)
return SuccessResponse(msg="批量修改{{ function_name }}状态成功")
@{{ class_name }}Router.post(
"/export",
summary="导出{{ function_name }}",
)
async def export_obj_list_controller(
search: Annotated[{{ class_name }}QueryParam, Depends()],
auth: Annotated[AuthSchema, Depends(AuthPermission(["{{ permission_prefix }}:export"]))],
) -> StreamingResponse:
"""
导出{{ function_name }}
参数:
- search ({{ class_name }}QueryParam): 查询参数
- auth (AuthSchema): 认证信息模型
返回:
- StreamingResponse: 包含{{ function_name }}列表的Excel文件流响应
"""
result_dict_list = await {{ class_name }}Service.list_service(search=search, auth=auth)
export_result = await {{ class_name }}Service.batch_export_service(obj_list=result_dict_list)
return StreamResponse(
data=bytes2file_response(export_result),
media_type="application/vnd.openxmlformats-officedocument.spreadsheetml.sheet",
headers={"Content-Disposition": "attachment; filename={{ table_name }}.xlsx"},
)
@{{ class_name }}Router.post(
"/import",
summary="导入{{ function_name }}",
response_model=ResponseSchema[str],
)
async def import_obj_list_controller(
file: UploadFile,
auth: Annotated[AuthSchema, Depends(AuthPermission(["{{ permission_prefix }}:import"]))],
) -> JSONResponse:
"""
导入{{ function_name }}
参数:
- file (UploadFile): 导入的Excel文件
- auth (AuthSchema): 认证信息模型
返回:
- JSONResponse: 包含导入{{ function_name }}详情的JSON响应
"""
batch_import_result = await {{ class_name }}Service.batch_import_service(
file=file, auth=auth, update_support=True
)
return SuccessResponse(data=batch_import_result, msg="导入{{ function_name }}成功")
@{{ class_name }}Router.post(
"/download/template",
summary="获取{{ function_name }}导入模板",
dependencies=[Depends(AuthPermission(["{{ permission_prefix }}:download"]))],
)
async def export_obj_template_controller() -> StreamingResponse:
"""
获取{{ function_name }}导入模板
返回:
- StreamingResponse: 包含{{ function_name }}导入模板的Excel文件流响应
"""
import_template_result = await {{ class_name }}Service.import_template_download_service()
return StreamResponse(
data=bytes2file_response(import_template_result),
media_type="application/vnd.openxmlformats-officedocument.spreadsheetml.sheet",
headers={
"Content-Disposition": f"attachment; filename={urllib.parse.quote('{{ function_name }}导入模板.xlsx')}",
"Access-Control-Expose-Headers": "Content-Disposition",
},
)
@@ -1,19 +0,0 @@
# -*- coding: utf-8 -*-
from app.core.base_crud import CRUDBase
from app.api.v1.module_system.auth.schema import AuthSchema
from .model import {{ class_name }}Model
from .schema import {{ class_name }}CreateSchema, {{ class_name }}UpdateSchema
class {{ class_name }}CRUD(CRUDBase[{{ class_name }}Model, {{ class_name }}CreateSchema, {{ class_name }}UpdateSchema]):
"""{{ function_name }}数据层"""
def __init__(self, auth: AuthSchema) -> None:
"""
初始化CRUD数据层
参数:
- auth (AuthSchema): 认证信息模型
"""
super().__init__(model={{ class_name }}Model, auth=auth)
@@ -1,46 +0,0 @@
# -*- coding: utf-8 -*-
{% for model_import in model_import_list %}
{{ model_import }}
{% endfor %}
from sqlalchemy.orm import Mapped, mapped_column
from app.core.base_model import ModelMixin, TenantMixin, UserMixin
{% if table.sub and not is_sub_entity %}
from ..{{ sub_module_name }}.model import {{ sub_model_class_name }}
{% endif %}
class {{ class_name }}Model(ModelMixin, TenantMixin, UserMixin):
"""
{{ function_name }}表
"""
__tablename__: str = '{{ table_name }}'
__table_args__: dict[str, str] = {'comment': '{{ function_name }}'}
__loader_options__: list[str] = ["created_by", "updated_by", "deleted_by"{% if table.sub and not is_sub_entity %}, "{{ sub_rel_list_name }}"{% endif %}]
{% if not is_sub_entity %}
{% for column in columns %}
{% if column.column_name not in ['id', 'uuid', 'tenant_id', 'status', 'description', 'created_time', 'updated_time', 'created_id', 'updated_id', 'is_deleted', 'deleted_time', 'deleted_id'] %}
{% set sqlalchemy_type = column|get_sqlalchemy_type %}
{{ column.column_name }}: Mapped[{{ column.python_type }} | None] = mapped_column({{ sqlalchemy_type }}, {% if column.is_pk %}primary_key=True, {% endif %}{% if column.is_increment %}autoincrement=True, {% endif %}{% if (not column.is_nullable) or column.is_pk %}nullable=False{% else %}nullable=True{% endif %}, comment='{{ column.column_comment }}')
{% endif %}
{% endfor %}
{% if table.sub %}
{{ sub_rel_list_name }} = relationship('{{ sub_model_class_name }}', back_populates='{{ parent_rel_name }}')
{% endif %}
{% else %}
{% for column in columns %}
{% if column.column_name not in ['id', 'uuid', 'tenant_id', 'status', 'description', 'created_time', 'updated_time', 'created_id', 'updated_id', 'is_deleted', 'deleted_time', 'deleted_id'] %}
{% set sqlalchemy_type = column|get_sqlalchemy_type %}
{% if column.column_name == sub_table_fk_name %}
{{ column.column_name }}: Mapped[{{ column.python_type }} | None] = mapped_column({{ sqlalchemy_type }}, ForeignKey('{{ parent_table_name }}.{{ parent_pk_column_name }}', ondelete='CASCADE'), {% if column.is_pk %}primary_key=True, {% endif %}{% if column.is_increment %}autoincrement=True, {% endif %}{% if (not column.is_nullable) or column.is_pk %}nullable=False{% else %}nullable=True{% endif %}, comment='{{ column.column_comment }}')
{% else %}
{{ column.column_name }}: Mapped[{{ column.python_type }} | None] = mapped_column({{ sqlalchemy_type }}, {% if column.is_pk %}primary_key=True, {% endif %}{% if column.is_increment %}autoincrement=True, {% endif %}{% if (not column.is_nullable) or column.is_pk %}nullable=False{% else %}nullable=True{% endif %}, comment='{{ column.column_comment }}')
{% endif %}
{% endif %}
{% endfor %}
{{ parent_rel_name }} = relationship('{{ parent_model_class_name }}', back_populates='{{ parent_list_rel_name }}')
{% endif %}
@@ -1,87 +0,0 @@
# -*- coding: utf-8 -*-
{% if table.sub %}
from typing import List
{% endif %}
from pydantic import BaseModel, ConfigDict, Field
from fastapi import Query
{% for import_stmt in schema_import_list %}
{{ import_stmt }}
{% endfor %}
{# DateTimeStr 由 schema_import_list 在存在 created_time/updated_time 列时注入 #}
from app.common.enums import QueueEnum
from app.core.base_schema import BaseSchema, UserBySchema
class {{ class_name }}CreateSchema(BaseModel):
"""
{{ function_name }}新增模型
"""
{% for column in columns %}
{% if column.column_name not in ['uuid', 'created_time', 'updated_time', 'created_id', 'updated_id', 'is_deleted', 'deleted_time', 'deleted_id'] and column.column_name != pk_column_name %}
{% if column.column_name == 'status' %}
{{ column.column_name }}: {{ column.python_type }} = Field(default="0", description='{{ column.column_comment }}')
{% elif column.column_name == 'description' %}
{{ column.column_name }}: str | None = Field(default=None, max_length=255, description='{{ column.column_comment }}')
{% else %}
{{ column.column_name }}: {{ column.python_type }} = Field(default=..., description='{{ column.column_comment }}')
{% endif %}
{% endif %}
{% endfor %}
class {{ class_name }}UpdateSchema({{ class_name }}CreateSchema):
"""
{{ function_name }}更新模型
"""
...
class {{ class_name }}OutSchema({{ class_name }}CreateSchema, BaseSchema, UserBySchema):
"""
{{ function_name }}响应模型
"""
model_config = ConfigDict(from_attributes=True)
class {{ class_name }}QueryParam:
"""{{ function_name }}查询参数"""
def __init__(
self,
{% for column in columns %}
{% if column.is_query and column.query_type == 'LIKE' %}
{{ column.column_name }}: str | None = Query(None, description="{{ column.column_comment }}"),
{% endif %}
{% endfor %}
{% for column in columns %}
{% if column.is_query and column.query_type == 'EQ' and column.column_name not in ['created_time', 'updated_time'] %}
{{ column.column_name }}: {{ column.python_type }} | None = Query(None, description="{{ column.column_comment }}"),
{% endif %}
{% endfor %}
{% if 'created_time' in table_column_names %}
created_time: list[DateTimeStr] | None = Query(None, description="创建时间范围", examples=["2025-01-01 00:00:00", "2025-12-31 23:59:59"]),
{% endif %}
{% if 'updated_time' in table_column_names %}
updated_time: list[DateTimeStr] | None = Query(None, description="更新时间范围", examples=["2025-01-01 00:00:00", "2025-12-31 23:59:59"]),
{% endif %}
) -> None:
{% for column in columns %}
{% if column.is_query and column.query_type == 'LIKE' %}
# 模糊查询字段
self.{{ column.column_name }} = (QueueEnum.like.value, {{ column.column_name }})
{% elif column.is_query and column.query_type == 'EQ' and column.column_name not in ['created_time', 'updated_time'] %}
# 精确查询字段
if {{ column.column_name }} is not None:
self.{{ column.column_name }} = (QueueEnum.eq.value, {{ column.column_name }})
{% endif %}
{% endfor %}
{% if 'created_time' in table_column_names %}
# 时间范围查询
if created_time and len(created_time) == 2:
self.created_time = (QueueEnum.between.value, (created_time[0], created_time[1]))
{% endif %}
{% if 'updated_time' in table_column_names %}
if updated_time and len(updated_time) == 2:
self.updated_time = (QueueEnum.between.value, (updated_time[0], updated_time[1]))
{% endif %}
{# created_id / updated_id 若为 EQ 查询列,已在上方 query_type 循环中处理 #}
@@ -1,323 +0,0 @@
# -*- coding: utf-8 -*-
import io
import pandas as pd
from fastapi import UploadFile
from app.api.v1.module_system.auth.schema import AuthSchema
from app.core.base_schema import BatchSetAvailable
from app.core.exceptions import CustomException
from app.utils.excel_util import ExcelUtil
from .crud import {{ class_name }}CRUD
from .schema import (
{{ class_name }}CreateSchema,
{{ class_name }}UpdateSchema,
{{ class_name }}OutSchema,
{{ class_name }}QueryParam
)
class {{ class_name }}Service:
"""
{{ function_name }}服务层
"""
@classmethod
async def detail_{{ business_name_slug }}_service(cls, auth: AuthSchema, id: int) -> dict:
"""
详情
参数:
- auth: AuthSchema - 认证信息
- id: int - 数据ID
返回:
- dict - 数据详情
"""
obj = await {{ class_name }}CRUD(auth).get(id=id)
if not obj:
raise CustomException(msg="该数据不存在")
return {{ class_name }}OutSchema.model_validate(obj).model_dump()
@classmethod
async def list_{{ business_name_slug }}_service(cls, auth: AuthSchema, search: {{ class_name }}QueryParam | None = None, order_by: list[dict] | None = None) -> list[dict]:
"""
列表查询
参数:
- auth: AuthSchema - 认证信息
- search: {{ class_name }}QueryParam | None - 查询参数
- order_by: list[dict] | None - 排序参数
返回:
- list[dict] - 数据列表
"""
search_dict = search.__dict__ if search else None
obj_list = await {{ class_name }}CRUD(auth).list(search=search_dict, order_by=order_by)
return [{{ class_name }}OutSchema.model_validate(obj).model_dump() for obj in obj_list]
@classmethod
async def page_{{ business_name_slug }}_service(cls, auth: AuthSchema, page_no: int, page_size: int, search: {{ class_name }}QueryParam | None = None, order_by: list[dict] | None = None) -> dict:
"""
分页查询(数据库分页)
参数:
- auth: AuthSchema - 认证信息
- page_no: int - 页码
- page_size: int - 每页数量
- search: {{ class_name }}QueryParam | None - 查询参数
- order_by: list[dict] | None - 排序参数
返回:
- dict - 分页查询结果
"""
search_dict = search.__dict__ if search else {}
order_by_list = order_by or [{'{{ pk_column_name }}': 'asc'}]
offset = (page_no - 1) * page_size
result = await {{ class_name }}CRUD(auth).page(
offset=offset,
limit=page_size,
order_by=order_by_list,
search=search_dict,
out_schema={{ class_name }}OutSchema
)
return result
@classmethod
async def create_{{ business_name_slug }}_service(cls, auth: AuthSchema, data: {{ class_name }}CreateSchema) -> dict:
"""
创建
参数:
- auth: AuthSchema - 认证信息
- data: {{ class_name }}CreateSchema - 创建数据
返回:
- dict - 创建结果
"""
{% for column in columns %}
{% if column.is_unique %}
obj = await {{ class_name }}CRUD(auth).get({{ column.column_name }}=data.{{ column.column_name }})
if obj:
raise CustomException(msg='创建失败,{{ column.column_comment }}已存在')
{% endif %}
{% endfor %}
obj = await {{ class_name }}CRUD(auth).create(data=data)
return {{ class_name }}OutSchema.model_validate(obj).model_dump()
@classmethod
async def update_{{ business_name_slug }}_service(cls, auth: AuthSchema, id: int, data: {{ class_name }}UpdateSchema) -> dict:
"""
更新
参数:
- auth: AuthSchema - 认证信息
- id: int - 数据ID
- data: {{ class_name }}UpdateSchema - 更新数据
返回:
- dict - 更新结果
"""
# 检查数据是否存在
obj = await {{ class_name }}CRUD(auth).get(id=id)
if not obj:
raise CustomException(msg='更新失败,该数据不存在')
# 检查唯一性约束
{% for column in columns %}
{% if column.is_unique %}
exist_obj = await {{ class_name }}CRUD(auth).get({{ column.column_name }}=data.{{ column.column_name }})
if exist_obj and getattr(exist_obj, '{{ pk_column_name }}') != id:
raise CustomException(msg='更新失败,{{ column.column_comment }}重复')
{% endif %}
{% endfor %}
obj = await {{ class_name }}CRUD(auth).update(id=id, data=data)
return {{ class_name }}OutSchema.model_validate(obj).model_dump()
@classmethod
async def delete_{{ business_name_slug }}_service(cls, auth: AuthSchema, ids: list[int]) -> None:
"""
删除
参数:
- auth: AuthSchema - 认证信息
- ids: list[int] - 数据ID列表
返回:
- None
"""
if len(ids) < 1:
raise CustomException(msg='删除失败,删除对象不能为空')
for id in ids:
obj = await {{ class_name }}CRUD(auth).get(id=id)
if not obj:
raise CustomException(msg=f'删除失败,ID为{id}的数据不存在')
await {{ class_name }}CRUD(auth).delete(ids=ids)
@classmethod
async def set_available_{{ business_name_slug }}_service(cls, auth: AuthSchema, data: BatchSetAvailable) -> None:
"""
批量设置状态
参数:
- auth: AuthSchema - 认证信息
- data: BatchSetAvailable - 批量设置状态数据
返回:
- None
"""
await {{ class_name }}CRUD(auth).set(ids=data.ids, status=data.status)
@classmethod
async def batch_export_{{ business_name_slug }}_service(cls, obj_list: list[dict]) -> bytes:
"""
批量导出
参数:
- obj_list: list[dict] - 数据列表
返回:
- bytes - 导出的Excel文件内容
"""
mapping_dict = {
{% for column in columns %}
'{{ column.column_name }}': '{{ column.column_comment }}',
{% endfor %}
}
# 复制数据并转换状态
data = obj_list.copy()
for item in data:
# 处理状态
item["status"] = "启用" if item.get("status") == 0 else "停用"
# 处理创建者
creator_info = item.get("created_id")
if isinstance(creator_info, dict):
item["created_id"] = creator_info.get("name", "未知")
else:
item["created_id"] = "未知"
return ExcelUtil.export_list2excel(list_data=data, mapping_dict=mapping_dict)
@classmethod
async def batch_import_{{ business_name_slug }}_service(cls, auth: AuthSchema, file: UploadFile, update_support: bool = False) -> str:
"""
批量导入
参数:
- auth: AuthSchema - 认证信息
- file: UploadFile - 上传的Excel文件
- update_support: bool - 是否支持更新存在数据
返回:
- str - 导入结果信息
"""
header_dict = {
{% for column in columns %}
'{{ column.column_comment }}': '{{ column.column_name }}',
{% endfor %}
}
try:
# 读取Excel文件
contents = await file.read()
df = pd.read_excel(io.BytesIO(contents))
await file.close()
if df.empty:
raise CustomException(msg="导入文件为空")
# 检查表头是否完整
missing_headers = [header for header in header_dict.keys() if header not in df.columns]
if missing_headers:
raise CustomException(msg=f"导入文件缺少必要的列: {', '.join(missing_headers)}")
# 重命名列名
df.rename(columns=header_dict, inplace=True)
# 验证必填字段(非主键且不允许为空的列)
{% for column in columns %}
{% if column.is_nullable is false and column.is_pk is false %}
errors = []
missing_rows = df[df['{{ column.column_name }}'].isnull()].index.tolist()
if missing_rows:
field_name = [k for k,v in header_dict.items() if v == '{{ column.column_name }}'][0]
rows_str = "、".join([str(i+1) for i in missing_rows])
errors.append(f"{field_name}不能为空,第{rows_str}行")
if errors:
raise CustomException(msg=f"导入失败,以下行缺少必要字段:\n{'; '.join(errors)}")
{% endif %}
{% endfor %}
error_msgs = []
success_count = 0
count = 0
for _index, row in df.iterrows():
count += 1
try:
data = {
{% for column in columns %}
"{{ column.column_name }}": row['{{ column.column_name }}'],
{% endfor %}
}
# 使用CreateSchema做校验后入库
create_schema = {{ class_name }}CreateSchema.model_validate(data)
# 检查唯一性约束
{% for column in columns %}
{% if column.is_unique %}
exists_obj = await {{ class_name }}CRUD(auth).get({{ column.column_name }}=create_schema.{{ column.column_name }})
if exists_obj:
if update_support:
await {{ class_name }}CRUD(auth).update(id=getattr(exists_obj, '{{ pk_column_name }}'), data=create_schema)
success_count += 1
else:
error_msgs.append(f"第{count}行: {{ column.column_comment }} {create_schema.{{ column.column_name }}} 已存在")
continue
{% endif %}
{% endfor %}
await {{ class_name }}CRUD(auth).create(data=create_schema)
success_count += 1
except Exception as e:
error_msgs.append(f"第{count}行: {str(e)}")
continue
result = f"成功导入 {success_count} 条数据"
if error_msgs:
result += "\n错误信息:\n" + "\n".join(error_msgs)
return result
except Exception as e:
logger.error(f"批量导入失败: {str(e)}")
raise CustomException(msg=f"导入失败: {str(e)}")
@classmethod
async def import_template_download_{{ business_name_slug }}_service(cls) -> bytes:
"""
下载导入模板
返回:
- bytes - Excel文件的二进制数据
"""
header_list = [
{% for column in columns %}
'{{ column.column_comment }}',
{% endfor %}
]
selector_header_list = []
option_list = []
{% for column in columns %}
{% if column.html_type == 'select' and column.dict_type %}
selector_header_list.append('{{ column.column_comment }}')
option_list.append({'{{ column.column_comment }}': []})
{% endif %}
{% endfor %}
return ExcelUtil.get_excel_template(
header_list=header_list,
selector_header_list=selector_header_list,
option_list=option_list
)
@@ -1,126 +0,0 @@
import { request } from "@utils";
// API 前缀来自分系统包 module_xxx → /xxx
// 对齐 module_example/demo:业务接口固定为 /{prefix}/{module_name}
const API_PATH = "/{{ api_route_prefix }}/{{ module_name }}";
const {{ class_name }}API = {
get{{ class_name }}List(query: {{ class_name }}PageQuery) {
return request<ApiResponse<PageResult<{{ class_name }}Table>>>({
url: `${API_PATH}/list`,
method: "get",
params: query,
});
},
get{{ class_name }}Detail(query: number) {
return request<ApiResponse<{{ class_name }}Table>>({
url: `${API_PATH}/detail/${query}`,
method: "get",
});
},
create{{ class_name }}(body: {{ class_name }}Form) {
return request<ApiResponse>({
url: `${API_PATH}/create`,
method: "post",
data: body,
});
},
update{{ class_name }}(id: number, body: {{ class_name }}Form) {
return request<ApiResponse>({
url: `${API_PATH}/update/${id}`,
method: "put",
data: body,
});
},
delete{{ class_name }}(body: number[]) {
return request<ApiResponse>({
url: `${API_PATH}/delete`,
method: "delete",
data: body,
});
},
batch{{ class_name }}(body: BatchType) {
return request<ApiResponse>({
url: `${API_PATH}/available/setting`,
method: "patch",
data: body,
});
},
export{{ class_name }}(body: {{ class_name }}PageQuery) {
return request<Blob>({
url: `${API_PATH}/export`,
method: "post",
data: body,
responseType: "blob",
});
},
downloadTemplate{{ class_name }}() {
return request<ApiResponse>({
url: `${API_PATH}/download/template`,
method: "post",
responseType: "blob",
});
},
import{{ class_name }}(body: FormData) {
return request<ApiResponse>({
url: `${API_PATH}/import`,
method: "post",
data: body,
headers: {
"Content-Type": "multipart/form-data",
},
});
},
};
export default {{ class_name }}API;
// ------------------------------
// TS 类型声明
// ------------------------------
/** 列表查询参数 */
export interface {{ class_name }}PageQuery extends PageQuery {
{% for column in columns %}
{# 主键列默认不参与查询 #}
{% if column.is_query and column.column_name != pk_column_name and column.column_name not in ['created_time', 'updated_time'] %}
{{ column.column_name }}?: {{
'string' if column.query_type == 'LIKE'
else (column.python_type | python_to_ts_type)
}};
{% endif %}
{% endfor %}
created_time?: string[];
updated_time?: string[];
created_id?: number;
updated_id?: number;
}
/** 列表展示项 */
export interface {{ class_name }}Table extends BaseType {
{% for column in columns %}
{% if column.column_name not in ['id', 'uuid', 'status', 'description', 'created_time', 'updated_time'] and column.column_name != pk_column_name %}
{{ column.column_name }}?: {{ column.python_type | python_to_ts_type }};
{% endif %}
{% endfor %}
created_by?: CommonType;
updated_by?: CommonType;
deleted_by?: CommonType;
}
/** 新增/修改表单参数 */
export interface {{ class_name }}Form extends BaseFormType {
{% for column in columns %}
{% if (column.is_insert or column.is_edit) and column.column_name not in ['uuid', 'status', 'description', 'created_time', 'updated_time', 'created_id', 'updated_id'] and column.column_name != pk_column_name %}
{{ column.column_name }}?: {{ column.python_type | python_to_ts_type }};
{% endif %}
{% endfor %}
}
@@ -1,826 +0,0 @@
{# =============================================================================
# {{ function_name }} 页面
# 架构:FaSearchBarWithAudit + FaTableHeader + FaTable + FaDialog
# 与 module_example/demo 完全对齐
# 页面路由:/{{ menu_route_first_segment }}/{{ module_name }}
# ============================================================================= #}
<template>
<div class="fa-full-height">
<FaSearchBarWithAudit
v-show="showSearchBar"
ref="searchBarRef"
v-model="searchForm"
:items="businessSearchItems"
:rules="searchBarRules"
:is-expand="false"
:show-expand="true"
:show-reset="true"
:show-search="true"
:disabled-search="false"
:default-expanded="false"
@search="handleSearch"
@reset="onResetSearch"
/>
<ElCard class="fa-table-card" :style="{ 'margin-top': showSearchBar ? '12px' : '0' }">
<FaTableHeader
v-model:columns="columnChecks"
v-model:showSearchBar="showSearchBar"
:loading="loading"
@refresh="refreshData"
>
<template #left>
<FaTableHeaderLeft
:remove-ids="selectedIds"
:perm-create="['{{ permission_prefix }}:create']"
:perm-import="['{{ permission_prefix }}:import']"
:perm-export="['{{ permission_prefix }}:export']"
:perm-delete="['{{ permission_prefix }}:delete']"
:perm-patch="['{{ permission_prefix }}:patch']"
:delete-loading="batchDeleting"
@add="openEditDialog('add')"
@import="openImport"
@export="openExport"
@delete="handleBatchDelete"
@more="runBatchStatus"
/>
</template>
</FaTableHeader>
<FaTable
ref="faTableRef"
:loading="loading"
:data="data"
:columns="columns"
:pagination="pagination"
@selection-change="onTableSelectionChange"
@pagination:size-change="handleSizeChange"
@pagination:current-change="handleCurrentChange"
/>
</ElCard>
<FaDialog
v-model="dialogVisible.visible"
:title="dialogVisible.title"
width="920px"
dialog-class="crud-embed-dialog"
modal-class="crud-embed-dialog"
:form-mode="dialogVisible.type"
:confirm-loading="submitLoading"
@cancel="handleCloseDialog"
@confirm="dialogVisible.type === 'detail' ? handleCloseDialog() : handleSubmit()"
>
<template v-if="dialogVisible.type === 'detail'">
<FaDescriptions
:column="4"
:data="detailFormData"
:items="detailItems"
max-height="70vh"
/>
</template>
<template v-else>
<FaForm
:key="formRenderKey"
scrollbar
max-height="70vh"
ref="dataFormRef"
v-model="formData"
:items="dialogFormItems"
:rules="rules"
label-suffix=":"
:label-width="100"
label-position="right"
:span="24"
:gutter="16"
:show-reset="false"
:show-submit="false"
class="crud-dialog-art-form"
/>
{% if table.sub %}
<ElDivider>{{ table.sub_table.function_name }}列表</ElDivider>
<div class="sub-table-section">
<div class="flex justify-between items-center mb-2">
<ElButton size="small" type="primary" @click="addSubRow">
新增{{ table.sub_table.function_name }}
</ElButton>
</div>
<ElTable :data="subTableData" size="small" border>
{% for col in table.sub_table.columns %}
{% if col.is_list and col.column_name != table.sub_table.pk_column.column_name %}
{% set col_comment = col.column_comment if col.column_comment else col.column_name %}
<ElTableColumn prop="{{ col.column_name }}" label="{{ col_comment }}" />
{% endif %}
{% endfor %}
<ElTableColumn label="操作" width="80" fixed="right">
<template #default="{ $index }">
<ElButton type="danger" size="small" link @click="removeSubRow($index)">
删除
</ElButton>
</template>
</ElTableColumn>
</ElTable>
</div>
{% endif %}
</ElScrollbar>
</template>
<template #footer>
<div class="dialog-footer" style="padding-right: var(--el-dialog-padding-primary)">
<ElButton @click="handleCloseDialog">取消</ElButton>
<ElButton v-if="dialogVisible.type !== 'detail'" type="primary" @click="handleSubmit">
确定
</ElButton>
<ElButton v-else type="primary" @click="handleCloseDialog">确定</ElButton>
</div>
</template>
</FaDialog>
<FaImportDialog
v-model="importVisible"
:content-config="importContentConfig"
default-template-file-name="{{ module_name }}_import_template.xlsx"
@upload="handleCrudImportUpload"
/>
<FaExportDialog
v-model="exportVisible"
:content-config="exportContentConfig"
:query-params="exportQueryParams"
:page-data="data"
:selection-data="selectedRows"
/>
</div>
</template>
<script setup lang="ts">
import { h, computed, ref, reactive, onMounted } from "vue";
import { useAuth } from "@/hooks/core/useAuth";
import { renderTableOperationCell, type TableOperationAction } from "@/utils/table";
import { useTable } from "@/hooks/core/useTable";
import { useImportExport } from "@/hooks/core/useImportExport";
import { useCrudDialog } from "@/hooks/core/useCrudDialog";
import { useTableSelection } from "@/hooks/core/useTableSelection";
import { cleanEmptyArrayParams, stripPaginationParams } from "@/utils/query";
import type { IContentConfig, IObject } from "@/components/modal/types";
import type { AuditSearchFormParams } from "@/components/forms/fa-search-bar/auditSearchFormItems";
import type { FormItem } from "@/components/forms/fa-form/index.vue";
import type { ColumnOption } from "@/types/component";
import {{ class_name }}API, {
type {{ class_name }}Form,
type {{ class_name }}PageQuery,
type {{ class_name }}Table,
} from "@/api/{{ package_name }}/{{ module_name }}";
import { ElTag, ElMessage } from "element-plus";
import { useDictStore } from "@/stores/modules/dict";
import { ResultEnum } from "@/enums/api/result.enum";
defineOptions({
name: "{{ class_name }}",
inheritAttrs: false,
});
const { hasAuth } = useAuth();
const dictStore = useDictStore();
type {{ class_name }}SearchFormParams = {
{% for column in columns %}
{% if column.is_query and column.column_name not in ['created_time', 'updated_time', 'created_id', 'updated_id'] and column.column_name != pk_column_name %}
{{ column.column_name }}?: string;
{% endif %}
{% endfor %}
} & AuditSearchFormParams;
function normalize{{ class_name }}Query(params: Record<string, unknown>): {{ class_name }}PageQuery {
const p = { ...params } as Record<string, unknown>;
if (Array.isArray(p.created_time) && p.created_time.length === 0) p.created_time = undefined;
if (Array.isArray(p.updated_time) && p.updated_time.length === 0) p.updated_time = undefined;
return p as unknown as {{ class_name }}PageQuery;
}
const searchForm = ref<{{ class_name }}SearchFormParams>({
{% for column in columns %}
{% if column.is_query and column.column_name not in ['created_time', 'updated_time', 'created_id', 'updated_id'] and column.column_name != pk_column_name %}
{{ column.column_name }}: undefined,
{% endif %}
{% endfor %}
created_id: undefined,
updated_id: undefined,
created_time: [],
updated_time: [],
});
/** 搜索区域默认展开展示 */
const showSearchBar = ref(true);
const searchBarRef = ref<InstanceType<typeof FaSearchBarWithAudit> | null>(null);
const searchBarRules: Record<string, unknown> = {};
const statusOptions = ref([
{ label: "启用", value: 0 },
{ label: "停用", value: 1 },
]);
/** 业务搜索项(审计四字段由 FaSearchBarWithAudit 自动追加) */
const businessSearchItems = computed(() => [
{% for column in columns %}
{# 主键列默认不参与搜索 #}
{% if column.is_query and column.column_name not in ['created_time', 'updated_time', 'created_id', 'updated_id'] and column.column_name != pk_column_name %}
{% set dict_type = column.dict_type %}
{% set column_comment = column.column_comment if column.column_comment else '' %}
{% set parentheseIndex = column_comment.find("") %}
{% set comment = column_comment[:parentheseIndex] if parentheseIndex != -1 else column_comment %}
{% if column.column_name == "status" %}
{
label: "状态",
key: "status",
type: "select",
props: {
placeholder: "请选择状态",
options: statusOptions.value,
clearable: true,
},
span: 6,
},
{% elif column.html_type == "input" %}
{
label: "{{ comment }}",
key: "{{ column.column_name }}",
type: "input",
placeholder: "请输入{{ comment }}",
clearable: true,
span: 6,
},
{% elif (column.html_type == "select" or column.html_type == "radio") and dict_type != "" %}
{
label: "{{ comment }}",
key: "{{ column.column_name }}",
type: "select",
props: {
placeholder: "请选择{{ comment }}",
options: [],
clearable: true,
},
span: 6,
},
{% elif column.html_type == "datetime" %}
{
label: "{{ comment }}",
key: "{{ column.column_name }}",
type: "date-picker",
props: {
type: "date",
valueFormat: "YYYY-MM-DD",
clearable: true,
placeholder: "请选择{{ comment }}",
},
span: 6,
},
{% endif %}
{% endif %}
{% endfor %}
]);
const faTableRef = ref<{ elTableRef?: { clearSelection: () => void } } | null>(null);
const selectedRows = ref<{{ class_name }}Table[]>([]);
const selectedIds = computed(() =>
selectedRows.value.map((r) => r.id).filter((id): id is number => id != null && !Number.isNaN(id))
);
const batchDeleting = ref(false);
function onTableSelectionChange(rows: {{ class_name }}Table[]) {
selectedRows.value = rows;
}
const PK = "{{ pk_column_name }}" as const;
const {
columns,
columnChecks,
data,
loading,
pagination,
searchParams,
getData,
replaceSearchParams,
resetSearchParams,
handleSizeChange,
handleCurrentChange,
refreshData,
refreshCreate,
refreshUpdate,
refreshRemove,
} = useTable({
core: {
apiFn: {{ class_name }}API.get{{ class_name }}List,
apiParams: {
page_no: 1,
page_size: 10,
},
columnsFactory: (): ColumnOption<{{ class_name }}Table>[] => [
{ type: "selection", width: 48, fixed: "left" },
{% for column in columns %}
{% if column.is_list and column.column_name != pk_column_name %}
{% set column_comment = column.column_comment if column.column_comment else '' %}
{% set parentheseIndex = column_comment.find("") %}
{% set comment = column_comment[:parentheseIndex] if parentheseIndex != -1 else column_comment %}
{% if column.column_name == "status" %}
{
prop: "status",
label: "状态",
width: 88,
formatter: (row: {{ class_name }}Table) => {
const ok = row.status === 0;
const cfg = ok
? { type: "success" as const, text: "启用" }
: { type: "info" as const, text: "停用" };
return h(ElTag, { type: cfg.type }, () => cfg.text);
},
},
{% elif column.column_name == "created_id" %}
{
prop: "created_by",
label: "{{ comment }}",
minWidth: 100,
formatter: (row: {{ class_name }}Table) => row.created_by?.name ?? "—",
},
{% elif column.column_name == "updated_id" %}
{
prop: "updated_by",
label: "{{ comment }}",
minWidth: 100,
formatter: (row: {{ class_name }}Table) => row.updated_by?.name ?? "—",
},
{% elif column.column_name == "created_time" %}
{ prop: "created_time", label: "{{ comment }}", width: 168, showOverflowTooltip: true },
{% elif column.column_name == "updated_time" %}
{ prop: "updated_time", label: "{{ comment }}", width: 168, showOverflowTooltip: true },
{% elif column.python_type == "bool" or column.html_type == "checkbox" %}
{
prop: "{{ column.column_name }}",
label: "{{ comment }}",
width: 80,
formatter: (row: {{ class_name }}Table) =>
h(ElTag, { type: row.{{ column.column_name }} ? "success" : "danger" }, () =>
row.{{ column.column_name }} ? "是" : "否"
),
},
{% elif column.html_type == "imageUpload" %}
{
prop: "{{ column.column_name }}",
label: "{{ comment }}",
minWidth: 120,
formatter: (row: {{ class_name }}Table) => {
if (!row.{{ column.column_name }}) return h("span", { class: "text-g-400" }, "—");
return h("el-image", {
src: row.{{ column.column_name }},
style: "width: 48px; height: 48px; border-radius: 4px; object-fit: cover;",
fit: "cover",
previewSrcList: [row.{{ column.column_name }}],
hideOnClickModal: true,
});
},
},
{% elif (column.html_type == "select" or column.html_type == "radio") and column.dict_type %}
{# 字典列在列配置中用 prop 占位,运行时由 dictStore 动态替换 #}
{ prop: "{{ column.column_name }}", label: "{{ comment }}", minWidth: 120, showOverflowTooltip: true },
{% else %}
{ prop: "{{ column.column_name }}", label: "{{ comment }}", minWidth: 120, showOverflowTooltip: true },
{% endif %}
{% endif %}
{% endfor %}
{
prop: "operation",
label: "操作",
width: 220,
fixed: "right",
align: "right",
formatter: (row: {{ class_name }}Table) => formatOperationCell(row),
},
],
},
});
/** 供 FaImportDialog / FaExportDialog 的列配置 */
const crudCols = computed(() =>
columns.value.map((c: ColumnOption<{{ class_name }}Table>) => {
const t = (c as { type?: string }).type;
return {
prop: c.prop,
label: c.label,
type: t === "selection" ? ("selection" as const) : ("default" as const),
show: true,
};
})
);
const exportQueryParams = computed(() => {
const sp = { ...(searchParams as object) } as Record<string, unknown>;
delete sp.current;
delete sp.size;
delete sp.page_no;
delete sp.page_size;
return normalize{{ class_name }}Query(sp);
});
const importContentConfig = computed<IContentConfig>(() => ({
permPrefix: "{{ permission_prefix }}",
cols: crudCols.value,
indexAction: async () => ({}),
importTemplate: () => {{ class_name }}API.downloadTemplate{{ class_name }}(),
}));
const exportContentConfig = computed(() => ({
permPrefix: "{{ permission_prefix }}",
cols: crudCols.value,
exportsBlobAction: async (params: IObject) => {
const merged = normalize{{ class_name }}Query({
...(exportQueryParams.value as unknown as Record<string, unknown>),
...params,
} as Record<string, unknown>);
const res = await {{ class_name }}API.export{{ class_name }}(merged as {{ class_name }}PageQuery);
return res.data as Blob;
},
}));
const { dialogVisible } = useCrudDialog();
const detailFormData = ref<{{ class_name }}Table>({});
const detailItems: import("@/components/others/fa-descriptions/index.vue").DescriptionsItem[] = [
{% for column in columns %}
{% if column.is_list %}
{% set column_comment = column.column_comment if column.column_comment else '' %}
{% set parentheseIndex = column_comment.find("") %}
{% set comment = column_comment[:parentheseIndex] if parentheseIndex != -1 else column_comment %}
{% if column.column_name == 'status' %}
{ label: "状态", prop: "status", tag: { map: { "0": { type: "success", text: "启用" }, "1": { type: "danger", text: "停用" } } } },
{% elif column.column_name == 'created_id' %}
{ label: "创建人", prop: "created_by.name" },
{% elif column.column_name == 'updated_id' %}
{ label: "更新人", prop: "updated_by.name" },
{% elif column.column_name == 'created_time' %}
{ label: "创建时间", prop: "created_time" },
{% elif column.column_name == 'updated_time' %}
{ label: "更新时间", prop: "updated_time" },
{% else %}
{ label: "{{ comment }}", prop: "{{ column.column_name }}" },
{% endif %}
{% endif %}
{% endfor %}
];
const formData = ref<{{ class_name }}Form>({
{% for column in columns %}
{% if column.is_insert or column.is_edit %}
{% if column.column_name not in ['uuid', 'created_time', 'updated_time', 'created_id', 'updated_id', 'is_deleted', 'deleted_time', 'deleted_id', 'tenant_id'] and column.column_name != pk_column_name %}
{% if column.column_name == "status" %}
{{ column.column_name }}: "0",
{% elif column.python_type == "bool" or column.html_type == "switch" %}
{{ column.column_name }}: false,
{% else %}
{{ column.column_name }}: undefined,
{% endif %}
{% endif %}
{% endif %}
{% endfor %}
});
const rules = reactive({
{% for column in columns %}
{% if column.is_insert or column.is_edit %}
{% if column.column_name not in ['uuid', 'created_time', 'updated_time', 'created_id', 'updated_id', 'is_deleted', 'deleted_time', 'deleted_id', 'tenant_id'] and column.column_name != pk_column_name %}
{{ column.column_name }}: [{ required: {% if not column.is_nullable %}true{% else %}false{% endif %}, message: "请填写{{ column.column_comment or column.column_name }}", trigger: "blur" }],
{% endif %}
{% endif %}
{% endfor %}
});
const dialogFormItems = computed<FormItem[]>(() => [
{% for column in columns %}
{% if column.is_insert or column.is_edit %}
{% set dict_type = column.dict_type %}
{% set column_comment = column.column_comment if column.column_comment else '' %}
{% set parentheseIndex = column_comment.find("") %}
{% set comment = column_comment[:parentheseIndex] if parentheseIndex != -1 else column_comment %}
{% if column.column_name not in ['uuid', 'created_time', 'updated_time', 'created_id', 'updated_id', 'is_deleted', 'deleted_time', 'deleted_id', 'tenant_id'] and column.column_name != pk_column_name %}
{% if column.column_name == "status" %}
{ key: "status", label: "状态", type: "radio", props: { options: [{ label: "启用", value: 0 }, { label: "停用", value: 1 }] } },
{% elif column.column_name == "description" %}
{ key: "description", label: "描述", type: "textarea", placeholder: "请输入描述" },
{% elif column.python_type == "int" or column.python_type == "float" or column.python_type == "Decimal" %}
{ key: "{{ column.column_name }}", label: "{{ comment }}", type: "input-number", placeholder: "请输入{{ comment }}"{% if column.python_type == "float" or column.python_type == "Decimal" %}, props: { step: 0.01, precision: 2 }{% endif %} },
{% elif column.html_type == "input" %}
{ key: "{{ column.column_name }}", label: "{{ comment }}", type: "input", placeholder: "请输入{{ comment }}" },
{% elif column.html_type == "textarea" %}
{ key: "{{ column.column_name }}", label: "{{ comment }}", type: "textarea", placeholder: "请输入{{ comment }}" },
{% elif dict_type != "" %}
{ key: "{{ column.column_name }}", label: "{{ comment }}", type: "select", placeholder: "请选择{{ comment }}", options: dictStore.getDictArray("{{ dict_type }}").map(d => ({ label: d.dict_label, value: d.dict_value })) },
{% elif column.html_type == "select" or column.html_type == "radio" %}
{ key: "{{ column.column_name }}", label: "{{ comment }}", type: "select", placeholder: "请选择{{ comment }}" },
{% elif column.html_type == "date" %}
{ key: "{{ column.column_name }}", label: "{{ comment }}", type: "date", placeholder: "请选择{{ comment }}" },
{% elif column.html_type == "datetime" %}
{ key: "{{ column.column_name }}", label: "{{ comment }}", type: "datetime", placeholder: "请选择{{ comment }}" },
{% elif column.html_type == "switch" %}
{ key: "{{ column.column_name }}", label: "{{ comment }}", type: "switch" },
{% elif column.html_type == "checkbox" %}
{ key: "{{ column.column_name }}", label: "{{ comment }}", type: "checkbox" },
{% else %}
{ key: "{{ column.column_name }}", label: "{{ comment }}", type: "input", placeholder: "请输入{{ comment }}" },
{% endif %}
{% endif %}
{% endif %}
{% endfor %}
]);
const dataFormRef = ref<{
resetFields: () => void;
clearValidate: () => void;
validate: (cb: (valid: boolean) => void) => void;
} | null>(null);
const submitLoading = ref(false);
const formRenderKey = ref(0);
const { importVisible, exportVisible, openImport, openExport } = useImportExport();
const initialFormData: {{ class_name }}Form = {
{% for column in columns %}
{% if column.is_insert or column.is_edit %}
{% if column.column_name not in ['uuid', 'created_time', 'updated_time', 'created_id', 'updated_id', 'is_deleted', 'deleted_time', 'deleted_id', 'tenant_id'] and column.column_name != pk_column_name %}
{% if column.column_name == "status" %}
{{ column.column_name }}: "0",
{% elif column.python_type == "bool" or column.html_type == "switch" %}
{{ column.column_name }}: false,
{% else %}
{{ column.column_name }}: undefined,
{% endif %}
{% endif %}
{% endif %}
{% endfor %}
};
const handleSearch = async (params: {{ class_name }}SearchFormParams) => {
await searchBarRef.value?.validate();
replaceSearchParams({
{% for column in columns %}
{% if column.is_query and column.column_name not in ['created_time', 'updated_time', 'created_id', 'updated_id'] and column.column_name != pk_column_name %}
{{ column.column_name }}: params.{{ column.column_name }},
{% endif %}
{% endfor %}
created_id: params.created_id ?? undefined,
updated_id: params.updated_id ?? undefined,
created_time:
Array.isArray(params.created_time) && params.created_time.length === 2
? params.created_time
: undefined,
updated_time:
Array.isArray(params.updated_time) && params.updated_time.length === 2
? params.updated_time
: undefined,
} as Record<string, unknown>);
getData();
};
const onResetSearch = async () => {
searchForm.value = {
{% for column in columns %}
{% if column.is_query and column.column_name not in ['created_time', 'updated_time', 'created_id', 'updated_id'] and column.column_name != pk_column_name %}
{{ column.column_name }}: undefined,
{% endif %}
{% endfor %}
created_id: undefined,
updated_id: undefined,
created_time: [],
updated_time: [],
};
await resetSearchParams();
};
function buildRowActions(row: {{ class_name }}Table): TableOperationAction[] {
const all: TableOperationAction[] = [
{
key: "detail",
label: "详情",
artType: "view",
perm: "{{ permission_prefix }}:detail",
run: () => void openDetailDialog(row),
},
{
key: "edit",
label: "编辑",
artType: "edit",
icon: "ri:edit-2-line",
perm: "{{ permission_prefix }}:update",
run: () => void openEditDialog("edit", row),
},
{
key: "delete",
label: "删除",
artType: "delete",
icon: "ri:delete-bin-4-line",
perm: "{{ permission_prefix }}:delete",
run: () => deleteRow(row),
},
];
return all.filter((a) => a.perm != null && hasAuth(a.perm));
}
function formatOperationCell(row: {{ class_name }}Table) {
return renderTableOperationCell(buildRowActions(row), {
wrapperClass: "inline-flex flex-wrap items-center justify-end gap-1",
});
}
async function openDetailDialog(row: {{ class_name }}Table) {
if (!row[PK]) return;
const response = await {{ class_name }}API.get{{ class_name }}Detail(row[PK] as number);
dialogVisible.type = "detail";
dialogVisible.title = "详情";
detailFormData.value = response.data.data ?? { ...row };
{% if table.sub %}
subTableData.value = (response.data.data as any)?.{{ sub_rel_list_name }} ?? [];
{% endif %}
dialogVisible.visible = true;
}
async function openEditDialog(type: "add" | "edit", row?: {{ class_name }}Table) {
dialogVisible.type = type === "add" ? "create" : "update";
if (type === "add") {
dialogVisible.title = "新增{{ function_name }}";
Object.assign(formData.value, initialFormData);
formData.value[PK] = undefined;
{% if table.sub %}
subTableData.value = [];
{% endif %}
formRenderKey.value += 1;
} else if (row?.[PK]) {
dialogVisible.title = "修改";
formRenderKey.value += 1;
const response = await {{ class_name }}API.get{{ class_name }}Detail(row[PK] as number);
Object.assign(formData.value, response.data.data);
{% if table.sub %}
subTableData.value = (response.data.data as any)?.{{ sub_rel_list_name }} ?? [];
{% endif %}
}
dialogVisible.visible = true;
}
async function resetForm() {
if (dataFormRef.value) {
dataFormRef.value.resetFields();
dataFormRef.value.clearValidate();
}
Object.assign(formData.value, initialFormData);
}
async function handleCloseDialog() {
dialogVisible.visible = false;
await resetForm();
}
async function handleSubmit() {
dataFormRef.value?.validate(async (valid: boolean) => {
if (!valid) return;
const submitData = { ...formData.value };
const id = formData.value[PK] as number | undefined;
try {
submitLoading.value = true;
if (id) {
await {{ class_name }}API.update{{ class_name }}(id, { [PK]: id, ...submitData });
await refreshUpdate();
} else {
await {{ class_name }}API.create{{ class_name }}(submitData);
await refreshCreate();
}
dialogVisible.visible = false;
await resetForm();
} catch (error: unknown) {
console.error(error);
} finally {
submitLoading.value = false;
}
});
}
const deleteRow = async (row: {{ class_name }}Table) => {
if (!row[PK]) return;
try {
await ElMessageBox.confirm(
`确定删除该{{ function_name }}吗?此操作不可恢复!`,
"删除确认",
{
confirmButtonText: "确定",
cancelButtonText: "取消",
type: "warning",
}
);
await {{ class_name }}API.delete{{ class_name }}([row[PK] as number]);
ElMessage.success("删除成功");
faTableRef.value?.elTableRef?.clearSelection();
await refreshRemove();
} catch {
ElMessage.info("已取消删除");
}
};
{% if table.sub %}
const subTableData = ref<any[]>([]);
function addSubRow() {
subTableData.value.push({
{% for col in table.sub_table.columns %}
{% if col.column_name not in ['id', 'uuid', 'status', 'description', 'created_time', 'updated_time', 'created_id', 'updated_id', 'is_deleted', 'deleted_time', 'deleted_id'] and col.column_name != table.sub_table.pk_column.column_name %}
{{ col.column_name }}: undefined,
{% endif %}
{% endfor %}
});
}
function removeSubRow(index: number) {
subTableData.value.splice(index, 1);
}
{% endif %}
async function handleBatchDelete() {
const ids = selectedIds.value;
if (ids.length === 0) return;
try {
await ElMessageBox.confirm(
`确定删除选中的 ${ids.length} 条数据吗?此操作不可恢复!`,
"批量删除",
{
confirmButtonText: "确定",
cancelButtonText: "取消",
type: "warning",
}
);
batchDeleting.value = true;
await {{ class_name }}API.delete{{ class_name }}(ids);
ElMessage.success("删除成功");
faTableRef.value?.elTableRef?.clearSelection();
await refreshRemove();
} catch {
ElMessage.info("已取消删除");
} finally {
batchDeleting.value = false;
}
}
async function runBatchStatus(status: string) {
const ids = selectedIds.value;
if (ids.length === 0) {
ElMessage.warning("请先在列表中勾选数据");
return;
}
try {
await ElMessageBox.confirm(
`确认对选中的 ${ids.length} 条数据${status === "0" ? "启用" : "停用"}`,
"批量设置",
{ confirmButtonText: "确定", cancelButtonText: "取消", type: "warning" }
);
await {{ class_name }}API.batch{{ class_name }}({ ids, status });
ElMessage.success("操作成功");
faTableRef.value?.elTableRef?.clearSelection();
await refreshData();
} catch {
// 用户取消
}
}
async function handleCrudImportUpload(uploadFormData: FormData) {
try {
const res = await {{ class_name }}API.import{{ class_name }}(uploadFormData);
if (res.data.code !== ResultEnum.SUCCESS) {
ElMessage.error(res.data.msg || "导入失败");
return;
}
ElMessage.success(res.data.msg || "导入成功");
importVisible.value = false;
await refreshData();
} catch (error) {
console.error("[Import]", error);
ElMessage.error("导入失败");
}
}
onMounted(async () => {
{% for column in columns %}
{% if column.is_query and column.dict_type and (column.html_type == "select" or column.html_type == "radio") %}
(() => {
const item = businessSearchItems.value.find((i) => i.key === "{{ column.column_name }}");
if (item && "props" in item && item.props) {
(item.props as Record<string, unknown>).options = dictStore.getDictArray("{{ column.dict_type }}").map(
(d: { dict_label: string; dict_value: string }) => ({
label: d.dict_label,
value: d.dict_value,
})
);
}
})();
{% endif %}
{% endfor %}
getData();
});
</script>
<style lang="scss" scoped></style>
@@ -1 +0,0 @@
@@ -1,331 +0,0 @@
import re
from app.common.constant import GenConstant
from app.plugin.module_generator.gencode.schema import (
GenTableColumnSchema,
GenTableOutSchema,
GenTableSchema,
)
from app.utils.string_util import StringUtil
class GenUtils:
"""代码生成器工具类"""
@classmethod
def init_table(cls, gen_table: GenTableSchema) -> None:
"""
初始化表信息
参数:
- gen_table (GenTableSchema): 业务表对象。
返回:
- None
"""
gen_table.class_name = cls.convert_class_name(gen_table.table_name or "")
# 导入时给出“可用默认值”,减少用户点开后看到空表单:
# - module_name:从表名推导(去掉 gen_/tb_ 前缀)
# - package_name:默认 module_{module_name}(仍可在前端改)
if gen_table.business_name is None:
gen_table.business_name = gen_table.table_name
if gen_table.module_name is None or not str(gen_table.module_name).strip():
tn = (gen_table.table_name or "").strip().lower()
if tn.startswith("gen_"):
tn = tn[4:]
elif tn.startswith("tb_"):
tn = tn[3:]
tn = re.sub(r"[^a-z0-9_]+", "_", tn)
tn = re.sub(r"_+", "_", tn).strip("_") or "module"
gen_table.module_name = tn
if gen_table.package_name is None or not str(gen_table.package_name).strip():
mn = (gen_table.module_name or "").strip()
if mn:
gen_table.package_name = mn if mn.startswith("module_") else f"module_{mn}"
fn = re.sub(r"(?:表|测试)", "", gen_table.table_comment or "")
fn = (fn or "").strip()
if not fn:
# 表注释为空时:用表名兜底,至少不为空
fn = (gen_table.table_name or "").strip()
gen_table.function_name = fn
@classmethod
def init_column_field(cls, column: GenTableColumnSchema, table: GenTableOutSchema) -> None:
"""
初始化列属性字段
参数:
- column (GenTableColumnSchema): 业务表字段对象。
- table (GenTableOutSchema): 业务表对象。
返回:
- None
"""
data_type = cls.get_db_type(column.column_type or "")
column_name = column.column_name or ""
if table.id is None:
raise ValueError("业务表ID不能为空")
column.table_id = table.id
column.python_field = cls.to_camel_case(column_name)
# 特殊处理几何类型,根据数据库类型选择不同的映射
from app.config.setting import settings
if data_type in [
"point",
"line",
"linestring",
"polygon",
"multipoint",
"multilinestring",
"multipolygon",
"geometrycollection",
"geometry",
]:
if settings.DATABASE_TYPE == "mysql":
column.python_type = "bytes"
elif settings.DATABASE_TYPE == "postgres":
column.python_type = "list"
else:
# 只有当python_type为None时才设置默认类型
column.python_type = StringUtil.get_mapping_value_by_key_ignore_case(GenConstant.DB_TO_PYTHON, data_type)
if column.column_length is None:
column.column_length = ""
if column.column_default is None:
column.column_default = ""
if column.html_type is None:
# 先按“字段名语义”推断(优先级高于通用字符串规则)
lower_name = column_name.lower()
if lower_name.endswith("status"):
column.html_type = GenConstant.HTML_RADIO
elif lower_name.endswith("type") or lower_name.endswith("sex"):
column.html_type = GenConstant.HTML_SELECT
elif lower_name.endswith("image"):
column.html_type = GenConstant.HTML_IMAGE_UPLOAD
elif lower_name.endswith("file"):
column.html_type = GenConstant.HTML_FILE_UPLOAD
elif lower_name.endswith("content"):
column.html_type = GenConstant.HTML_EDITOR
# 再按“数据类型”推断
elif cls.arrays_contains(GenConstant.COLUMNTYPE_TIME, data_type):
column.html_type = GenConstant.HTML_DATETIME
elif cls.arrays_contains(GenConstant.COLUMNTYPE_NUMBER, data_type):
column.html_type = GenConstant.HTML_INPUT
elif cls.arrays_contains(GenConstant.COLUMNTYPE_STR, data_type) or cls.arrays_contains(GenConstant.COLUMNTYPE_TEXT, data_type):
# 字符串长度超过500设置为文本域
column_length = cls.get_column_length(column.column_type or "")
column.html_type = GenConstant.HTML_TEXTAREA if column_length >= 500 or cls.arrays_contains(GenConstant.COLUMNTYPE_TEXT, data_type) else GenConstant.HTML_INPUT
else:
column.html_type = GenConstant.HTML_INPUT
# 默认新增字段:非主键且不在“新增不展示”黑名单中
# 说明:schema 默认值可能为 True/False;仅当调用方未显式配置时才做推断
if column.is_insert is None:
column.is_insert = bool((not column.is_pk) and (not cls.arrays_contains(GenConstant.COLUMNNAME_NOT_ADD_SHOW, column_name)))
# 默认编辑字段:非主键且不在“不编辑”黑名单中
if column.is_edit is None:
column.is_edit = bool((not cls.arrays_contains(GenConstant.COLUMNNAME_NOT_EDIT, column_name)) and (not column.is_pk))
# 默认列表字段:非主键且不在“不列表显示”黑名单中
if column.is_list is None:
column.is_list = bool((not cls.arrays_contains(GenConstant.COLUMNNAME_NOT_LIST, column_name)) and (not column.is_pk))
# 默认查询字段:非主键且不在“不查询”黑名单中
if column.is_query is None:
column.is_query = bool((not cls.arrays_contains(GenConstant.COLUMNNAME_NOT_QUERY, column_name)) and (not column.is_pk))
# 查询类型:仅当开启查询且 query_type 未显式配置时推断
if column.is_query:
if column.query_type is None:
if column_name.lower().endswith("name") or data_type in ["varchar", "char", "text"]:
column.query_type = GenConstant.QUERY_LIKE
else:
column.query_type = GenConstant.QUERY_EQ
else:
column.query_type = None
# 主键强约束:无论默认推断/历史配置如何,主键列不应出现在新增/编辑/列表/查询
if bool(column.is_pk):
column.is_insert = False
column.is_edit = False
column.is_list = False
column.is_query = False
column.query_type = None
@classmethod
def arrays_contains(cls, arr: list, target_value: str) -> bool:
"""
检查目标值是否在数组中
注意:从根本上解决问题,现在确保传入的参数都是正确的类型:
- arr 是列表类型,且在GenConstant中定义
- target_value 不会是None
参数:
- arr: 数组类型
- target_value: 目标值
返回:
- bool: 如果目标值在数组中,返回True;否则返回False
"""
# 从根本上解决问题,不再需要复杂的防御性检查
# 因为现在我们确保传入的arr是GenConstant中定义的列表常量
# 并且target_value在调用前已经被处理过不会是None
# 移除 COLLATE 子句和 UNSIGNED 标记(不区分大小写)
target_str = str(target_value)
# 移除 COLLATE 子句
collate_pattern = re.compile(r"\s+COLLATE\s+", re.IGNORECASE)
if collate_pattern.search(target_str):
target_str = collate_pattern.split(target_str)[0].strip()
# 移除 UNSIGNED 标记
unsigned_pattern = re.compile(r"\s+UNSIGNED", re.IGNORECASE)
if unsigned_pattern.search(target_str):
target_str = unsigned_pattern.sub("", target_str).strip()
# 转换为小写进行比较
target_str = target_str.lower()
# 对于包含括号的类型(如TINYINT(1)),需要特殊处理
# 先获取基本类型名称(不含括号)用于比较
target_base_type = target_str.split("(")[0] if "(" in target_str else target_str
for item in arr:
item_str = str(item).lower()
item_base_type = item_str.split("(")[0] if "(" in item_str else item_str
if target_base_type == item_base_type:
return True
return False
@classmethod
def convert_class_name(cls, table_name: str) -> str:
"""
表名转换成 Python 类名
参数:
- table_name (str): 业务表名。
返回:
- str: Python 类名。
"""
return StringUtil.convert_to_camel_case(table_name)
@classmethod
def replace_first(cls, input_string: str, search_list: list[str]) -> str:
"""
批量替换前缀
参数:
- input_string (str): 需要被替换的字符串。
- search_list (list[str]): 可替换的字符串列表。
返回:
- str: 替换后的字符串。
"""
for search_string in search_list:
if input_string.startswith(search_string):
return input_string.replace(search_string, "", 1)
return input_string
@classmethod
def get_db_type(cls, column_type: str) -> str:
"""
获取数据库类型字段
参数:
- column_type (str): 字段类型。
返回:
- str: 数据库类型。
"""
# 移除 COLLATE 子句(处理带引号和不带引号的情况,不区分大小写)
collate_pattern = re.compile(r"\s+COLLATE\s+", re.IGNORECASE)
if collate_pattern.search(column_type):
column_type = collate_pattern.split(column_type)[0].strip()
# 移除 UNSIGNED 标记(不区分大小写)
unsigned_pattern = re.compile(r"\s+UNSIGNED", re.IGNORECASE)
if unsigned_pattern.search(column_type):
column_type = unsigned_pattern.sub("", column_type).strip()
# 特殊处理tinyint(1),映射为boolean
if column_type.lower().startswith("tinyint(1)"):
return "boolean"
# 处理PostgreSQL数组类型(如 integer[], text[] 或 ARRAY[INTEGER]
if "[]" in column_type or column_type.upper().startswith("ARRAY["):
return "array"
# 提取基本类型:
# - 去掉括号参数:varchar(64) -> varchar
# - 去掉空格后的修饰:timestamp without time zone -> timestamp
# - 统一小写
base = column_type.split("(", 1)[0].strip()
if not base:
return ""
base = base.split(None, 1)[0].strip()
return base.lower()
@classmethod
def get_column_length(cls, column_type: str) -> int:
"""
获取字段长度
参数:
- column_type (str): 字段类型,例如 'varchar(255)''decimal(10,2)'
返回:
- int: 字段长度(优先取第一个长度值,无法解析时返回0)。
"""
if not column_type:
return 0
if "(" not in column_type or ")" not in column_type:
return 0
# 形如 varchar(255) / decimal(10,2) / numeric(20, 0)
inner = column_type.split("(", 1)[1].split(")", 1)[0].strip()
if not inner:
return 0
first = inner.split(",", 1)[0].strip()
try:
return int(first)
except ValueError:
return 0
@classmethod
def split_column_type(cls, column_type: str) -> list[str]:
"""
拆分列类型
参数:
- column_type (str): 字段类型。
返回:
- list[str]: 拆分结果。
"""
if "(" in column_type and ")" in column_type:
return column_type.split("(")[1].split(")")[0].split(",")
return []
@classmethod
def to_camel_case(cls, text: str) -> str:
"""
将字符串转换为驼峰命名
参数:
- text (str): 需要转换的字符串
返回:
- str: 驼峰命名
"""
parts = text.split("_")
return parts[0] + "".join(word.capitalize() for word in parts[1:])
@@ -1,794 +0,0 @@
import re
from datetime import datetime
from typing import Any
from jinja2 import Environment, FileSystemLoader, Template
from app.common.constant import GenConstant
from app.config.path_conf import TEMPLATE_DIR
from app.config.setting import settings
from app.plugin.module_generator.gencode.schema import (
GenTableColumnOutSchema,
GenTableOutSchema,
)
from app.plugin.module_generator.gencode.tools.gen_util import GenUtils
from app.utils.common_util import CamelCaseUtil, SnakeCaseUtil
from app.utils.string_util import StringUtil
class Jinja2TemplateUtil:
"""
模板处理工具类
"""
@classmethod
def normalize_db_column_type_for_mapping(cls, column_type: str | None) -> str:
"""
与 ``GenUtils.get_db_type`` 一致地去掉 COLLATE / UNSIGNED,便于与 ``DB_TO_SQLALCHEMY`` 键匹配。
参数:
- column_type (str | None): 原始列类型字符串。
返回:
- str: 规范化后的类型片段;空输入返回空字符串。
"""
ct = (column_type or "").strip()
if not ct:
return ""
collate_pattern = re.compile(r"\s+COLLATE\s+", re.IGNORECASE)
if collate_pattern.search(ct):
ct = collate_pattern.split(ct)[0].strip()
unsigned_pattern = re.compile(r"\s+UNSIGNED", re.IGNORECASE)
if unsigned_pattern.search(ct):
ct = unsigned_pattern.sub("", ct).strip()
return ct
# 项目路径
FRONTEND_PROJECT_PATH = "frontend"
BACKEND_PROJECT_PATH = "backend"
# 环境对象
_env = None
@classmethod
def get_env(cls):
"""
获取模板环境对象。
参数:
- 无
返回:
- Environment: Jinja2 环境对象。
"""
try:
if cls._env is None:
cls._env = Environment(
loader=FileSystemLoader(TEMPLATE_DIR),
autoescape=False, # 自动转义HTML
trim_blocks=True, # 删除多余的空行
lstrip_blocks=True, # 删除行首空格
keep_trailing_newline=True, # 保留行尾换行符
enable_async=True, # 开启异步支持
)
cls._env.filters.update(
{
"camel_to_snake": SnakeCaseUtil.camel_to_snake,
"snake_to_camel": CamelCaseUtil.snake_to_camel,
"get_sqlalchemy_type": cls.get_sqlalchemy_type,
"python_to_ts_type": cls.python_type_to_ts_type,
}
)
return cls._env
except Exception as e:
raise RuntimeError(f"初始化Jinja2模板引擎失败: {e}")
@classmethod
def get_template(cls, template_path: str) -> Template:
"""
获取模板。
参数:
- template_path (str): 模板路径。
返回:
- Template: Jinja2 模板对象。
异常:
- TemplateNotFound: 模板未找到时抛出。
"""
return cls.get_env().get_template(template_path)
@classmethod
def business_name_to_slug(cls, business_name: str | None) -> str:
"""
业务路径可含斜杠(如 ``demo/subdir``)用于目录与路由前缀;
Python 函数/方法名仅使用最后一段并规范为合法 snake_case 片段。
参数:
- business_name (str | None): 业务路径或名称。
返回:
- str: 用于 Python 标识的 slug,默认 entity。
"""
s = (business_name or "").strip().strip("/")
if not s:
return "entity"
if "/" in s:
s = s.split("/")[-1]
s = re.sub(r"[^a-zA-Z0-9_]", "_", s)
if not s:
return "entity"
if s[0].isdigit():
s = "_" + s
return s
@classmethod
def business_name_to_path(cls, business_name: str | None) -> str:
"""把 business_name 规范为可用于目录/路由的多段路径(保留 `/`)。
约定:`business_name` 允许 `a/b/c` 表示多级菜单目录。
- 目录/路由:使用完整多段
- 文件名/route_name:使用最后一段 slug(见 `business_name_to_slug`
参数:
- business_name (str | None): 业务路径或名称。
返回:
- str: 多段路径字符串(小写 slug),默认 entity。
"""
s = (business_name or "").strip().strip("/")
if not s:
return "entity"
# 每段都做一次轻度规范(与 schema 的 slug 规则一致:a-z0-9_
segs = []
for raw in [p for p in s.split("/") if p]:
seg = re.sub(r"[^a-zA-Z0-9_]", "_", raw).lower()
seg = re.sub(r"_+", "_", seg).strip("_") or "entity"
if seg[0].isdigit():
seg = "_" + seg
segs.append(seg)
return "/".join(segs) if segs else "entity"
@classmethod
def prepare_context(cls, gen_table: GenTableOutSchema) -> dict[str, Any]:
"""
准备模板变量。
参数:
- gen_table (GenTableOutSchema): 生成表的配置信息。
返回:
- Dict[str, Any]: 模板上下文字典。
"""
# 处理options为None的情况
# if not gen_table.options:
# raise ValueError('请先完善生成配置信息')
class_name = gen_table.class_name or ""
package_name = (gen_table.package_name or "").strip()
module_name = (gen_table.module_name or "").strip()
business_name = (gen_table.business_name or "").strip()
function_name = gen_table.function_name or ""
# 生成规则(对齐 module_example/demo):
# - 分系统根:package_name = module_xxx
# - 目录固定为:module_xxx / module_name(不再额外使用业务名作为目录层级)
# - 权限前缀固定为:module_xxx:module_name(操作在模板里再拼 :query/:create...
business_path = cls.business_name_to_path(business_name)
business_name_slug = cls.business_name_to_slug(business_name)
permission_prefix = ":".join([s for s in [package_name, module_name] if s])
api_route_prefix = cls.get_api_route_prefix(package_name)
_cols = gen_table.columns or []
table_column_names = frozenset(c.column_name for c in _cols if getattr(c, "column_name", None))
sub_class_name = ""
sub_model_class_name = ""
sub_rel_list_name = ""
parent_rel_name = ""
if gen_table.sub and gen_table.sub_table:
st = gen_table.sub_table
scn = (st.class_name or GenUtils.convert_class_name(gen_table.sub_table_name or "")).strip()
sub_class_name = scn
sub_model_class_name = f"{scn}Model"
sub_rel_list_name = f"{SnakeCaseUtil.camel_to_snake(scn)}_list"
parent_rel_name = SnakeCaseUtil.camel_to_snake(gen_table.class_name or "")
context = {
"table_name": gen_table.table_name or "",
"table_comment": gen_table.table_comment or "",
"function_name": function_name if StringUtil.is_not_empty(function_name) else "【请填写功能名称】",
"class_name": class_name,
"module_name": module_name,
"business_name": business_name,
"business_path": business_path,
"business_file": business_name_slug,
"business_name_slug": business_name_slug,
"base_package": cls.get_package_prefix(package_name),
"package_name": package_name,
"menu_route_first_segment": cls.get_menu_route_first_segment(gen_table),
"datetime": datetime.now().strftime("%Y-%m-%d %H:%M:%S"),
"pk_column": gen_table.pk_column,
"model_import_list": cls.get_model_import_list(gen_table),
"schema_import_list": cls.get_schema_import_list(gen_table),
"permission_prefix": permission_prefix,
"api_route_prefix": api_route_prefix,
"columns": gen_table.columns or [],
"table_column_names": table_column_names,
"table": gen_table,
"dicts": cls.get_dicts(gen_table),
"db_type": settings.DATABASE_TYPE,
"column_not_add_show": GenConstant.COLUMNNAME_NOT_ADD_SHOW,
"column_not_edit_show": GenConstant.COLUMNNAME_NOT_EDIT_SHOW,
"parent_menu_id": int(gen_table.parent_menu_id) if gen_table.parent_menu_id else None,
"is_sub_entity": False,
"sub_class_name": sub_class_name,
"sub_model_class_name": sub_model_class_name,
"sub_module_name": (gen_table.sub_table.module_name if gen_table.sub and gen_table.sub_table else ""),
"sub_rel_list_name": sub_rel_list_name,
"parent_rel_name": parent_rel_name,
"parent_list_rel_name": "",
"parent_table_name": "",
"parent_model_class_name": "",
# 数据表实际主键列名(用于生成前端行键等;ModelMixin 仍默认带 id 字段)
"pk_column_name": (gen_table.pk_column.column_name if gen_table.pk_column else None) or "id",
"parent_pk_column_name": (gen_table.pk_column.column_name if gen_table.pk_column else None) or "id",
"sub_table_fk_name": "",
}
return context
@classmethod
def get_menu_route_first_segment(cls, gen_table: GenTableOutSchema) -> str:
"""
前端页面路由首段(与写入菜单 ``route_path`` 第一段一致):始终为 ``module_xxx``。
懒加载 ``GenTableService`` 避免与 ``service`` 模块循环依赖。
参数:
- gen_table (GenTableOutSchema): 生成表配置。
返回:
- str: 路由首段(module_xxx)。
"""
from app.plugin.module_generator.gencode.service import GenTableService
pid = int(gen_table.parent_menu_id) if gen_table.parent_menu_id is not None else None
return GenTableService._menu_route_first_segment(
pid,
gen_table.package_name or "",
gen_table.module_name,
)
@classmethod
def prepare_sub_render_context(cls, parent: GenTableOutSchema, sub: GenTableOutSchema) -> dict[str, Any]:
"""
子表业务代码渲染上下文(与主表同模块、独立业务目录)。
参数:
- parent (GenTableOutSchema): 主表配置。
- sub (GenTableOutSchema): 子表配置。
返回:
- dict[str, Any]: 子表模板上下文字典。
"""
ctx = cls.prepare_context(sub)
scn = (sub.class_name or GenUtils.convert_class_name(sub.table_name or "")).strip()
ctx["is_sub_entity"] = True
ctx["parent_class_name"] = parent.class_name or ""
ctx["parent_model_class_name"] = f"{parent.class_name}Model"
ctx["parent_table_name"] = parent.table_name or ""
ctx["parent_pk_column_name"] = (parent.pk_column.column_name if parent.pk_column else None) or "id"
ctx["parent_rel_name"] = SnakeCaseUtil.camel_to_snake(parent.class_name or "parent")
ctx["parent_list_rel_name"] = f"{SnakeCaseUtil.camel_to_snake(scn)}_list"
ctx["sub_table_fk_name"] = (parent.sub_table_fk_name or "").strip()
ctx["model_import_list"] = cls.get_model_import_list(sub, is_sub_entity=True)
ctx["schema_import_list"] = cls.get_schema_import_list(sub)
return ctx
@classmethod
def get_template_list(cls):
"""
获取主表模板列表。
参数:
- 无
返回:
- List[str]: 模板路径列表。
"""
templates = [
"python/controller.py.j2",
"python/service.py.j2",
"python/crud.py.j2",
"python/schema.py.j2",
"python/model.py.j2",
"python/__init__.py.j2",
"ts/api.ts.j2",
"vue/index.vue.j2",
]
return templates
@classmethod
def get_sub_table_template_list(cls):
"""
获取子表模板列表(仅 model / schema / __init__,不含 controller/service/crud/vue/api)。
参数:
- 无
返回:
- List[str]: 子表模板路径列表。
"""
return [
"python/model.py.j2",
"python/schema.py.j2",
"python/__init__.py.j2",
]
@classmethod
def get_file_name(cls, template: str, gen_table: GenTableOutSchema):
"""
根据模板生成文件名。
参数:
- template (str): 模板路径字符串。
- gen_table (GenTableOutSchema): 生成表的配置信息。
返回:
- str: 模板生成的文件名。
异常:
- ValueError: 当无法生成有效文件名时抛出。
"""
package_name = (gen_table.package_name or "").strip()
module_name = (gen_table.module_name or "").strip()
if not package_name:
raise ValueError(f"无法为模板 {template} 生成文件名:包名未设置")
if not module_name:
raise ValueError(f"无法为模板 {template} 生成文件名:模块名未设置")
# 目录固定为:module_xxx/{module_name}
backend_base = f"{cls.BACKEND_PROJECT_PATH}/app/plugin/{package_name}"
frontend_view_base = f"{cls.FRONTEND_PROJECT_PATH}/src/views/{package_name}"
frontend_api_base = f"{cls.FRONTEND_PROJECT_PATH}/src/api/{package_name}"
backend_dir = f"{backend_base}/{module_name}"
view_dir = f"{frontend_view_base}/{module_name}"
api_path = f"{frontend_api_base}/{module_name}.ts"
template_mapping = {
"controller.py.j2": f"{backend_dir}/controller.py",
"service.py.j2": f"{backend_dir}/service.py",
"crud.py.j2": f"{backend_dir}/crud.py",
"schema.py.j2": f"{backend_dir}/schema.py",
"model.py.j2": f"{backend_dir}/model.py",
"__init__.py.j2": f"{backend_dir}/__init__.py",
"api.ts.j2": api_path,
"index.vue.j2": f"{view_dir}/index.vue",
}
# 查找匹配的模板路径
for key, path in template_mapping.items():
if key in template:
return path
# 遍历完所有映射都没找到匹配项,才抛出异常
raise ValueError(f"未找到模板 '{template}' 的路径映射")
@classmethod
def get_package_prefix(cls, package_name: str) -> str:
"""
获取包前缀。
参数:
- package_name (str): 包名。
返回:
- str: 包前缀。
"""
# 修复:当包名中不存在'.'时,直接返回原包名
return package_name[: package_name.rfind(".")] if "." in package_name else package_name
@classmethod
def get_schema_import_list(cls, gen_table: GenTableOutSchema):
"""
获取 schema 模板所需的 Python 导入语句集合。
参数:
- gen_table (GenTableOutSchema): 生成表配置(含主子表列)。
返回:
- set[str]: 导入语句字符串集合。
"""
columns = gen_table.columns or []
import_list = set()
has_datetime_import = False
has_date_import = False
has_time_import = False
has_datetime_str = False
has_date_str = False
has_time_str = False
for column in columns:
# 处理datetime类型的导入
if column.python_type and column.python_type in GenConstant.TYPE_DATE:
if column.python_type == "datetime":
has_datetime_import = True
elif column.python_type == "date":
has_date_import = True
elif column.python_type == "time":
has_time_import = True
elif column.python_type == GenConstant.TYPE_DECIMAL:
import_list.add("from decimal import Decimal")
# 检查是否需要DateTimeStr、DateStr、TimeStr
if column.column_name == "created_time" or column.column_name == "updated_time":
has_datetime_str = True
if gen_table.sub and gen_table.sub_table and gen_table.sub_table.columns:
sub_columns = gen_table.sub_table.columns or []
for sub_column in sub_columns:
# 处理datetime类型的导入
if sub_column.python_type and sub_column.python_type in GenConstant.TYPE_DATE:
if sub_column.python_type == "datetime":
has_datetime_import = True
elif sub_column.python_type == "date":
has_date_import = True
elif sub_column.python_type == "time":
has_time_import = True
elif sub_column.python_type == GenConstant.TYPE_DECIMAL:
import_list.add("from decimal import Decimal")
# 添加datetime导入
if has_datetime_import:
import_list.add("from datetime import datetime")
if has_date_import:
import_list.add("from datetime import date")
if has_time_import:
import_list.add("from datetime import time")
# 添加validator导入
if has_datetime_str:
import_list.add("from app.core.validator import DateTimeStr")
if has_date_str:
import_list.add("from app.core.validator import DateStr")
if has_time_str:
import_list.add("from app.core.validator import TimeStr")
return import_list
@classmethod
def get_model_import_list(cls, gen_table: GenTableOutSchema, *, is_sub_entity: bool = False) -> list[str]:
"""
获取 model 模板所需的 Python 导入语句列表(含合并后的 sqlalchemy 导入)。
参数:
- gen_table (GenTableOutSchema): 生成表配置。
- is_sub_entity (bool): 是否为子表独立生成(含外键与 relationship)。
返回:
- list[str]: 导入语句列表。
"""
columns = gen_table.columns or []
import_list = set()
has_datetime_import = False
has_date_import = False
has_time_import = False
for column in columns:
if column.column_type:
data_type = cls.get_db_type(column.column_type)
if data_type in GenConstant.COLUMNTYPE_GEOMETRY:
import_list.add("from geoalchemy2 import Geometry")
import_list.add(f"from sqlalchemy import {StringUtil.get_mapping_value_by_key_ignore_case(GenConstant.DB_TO_SQLALCHEMY, data_type)}")
# 处理datetime类型的导入
if column.python_type and column.python_type in GenConstant.TYPE_DATE:
if column.python_type == "datetime":
has_datetime_import = True
elif column.python_type == "date":
has_date_import = True
elif column.python_type == "time":
has_time_import = True
# 处理Decimal类型的导入
elif column.python_type == GenConstant.TYPE_DECIMAL:
import_list.add("from decimal import Decimal")
if gen_table.sub or is_sub_entity:
import_list.add("from sqlalchemy import ForeignKey")
if gen_table.sub and not is_sub_entity and gen_table.sub_table and gen_table.sub_table.columns:
sub_columns = gen_table.sub_table.columns or []
for sub_column in sub_columns:
if sub_column.column_type:
data_type = cls.get_db_type(sub_column.column_type)
import_list.add(f"from sqlalchemy import {StringUtil.get_mapping_value_by_key_ignore_case(GenConstant.DB_TO_SQLALCHEMY, data_type)}")
# 处理datetime类型的导入
if sub_column.python_type and sub_column.python_type in GenConstant.TYPE_DATE:
if sub_column.python_type == "datetime":
has_datetime_import = True
elif sub_column.python_type == "date":
has_date_import = True
elif sub_column.python_type == "time":
has_time_import = True
# 处理Decimal类型的导入
elif sub_column.python_type == GenConstant.TYPE_DECIMAL:
import_list.add("from decimal import Decimal")
# 添加datetime导入
if has_datetime_import:
import_list.add("from datetime import datetime")
if has_date_import:
import_list.add("from datetime import date")
if has_time_import:
import_list.add("from datetime import time")
merged = cls.merge_same_imports(list(import_list), "from sqlalchemy import")
if gen_table.sub or is_sub_entity:
merged.append("from sqlalchemy.orm import relationship")
return merged
@classmethod
def get_db_type(cls, column_type: str) -> str:
"""
获取数据库字段类型。
参数:
- column_type (str): 字段类型字符串。
返回:
- str: 数据库类型(去除长度等修饰)。
"""
# 移除 COLLATE 子句(处理带引号和不带引号的情况,不区分大小写)
collate_pattern = re.compile(r"\s+COLLATE\s+", re.IGNORECASE)
if collate_pattern.search(column_type):
column_type = collate_pattern.split(column_type)[0].strip()
# 移除 UNSIGNED 标记(不区分大小写)
unsigned_pattern = re.compile(r"\s+UNSIGNED", re.IGNORECASE)
if unsigned_pattern.search(column_type):
column_type = unsigned_pattern.sub("", column_type).strip()
# 处理PostgreSQL数组类型(如 integer[], text[]
if "[]" in column_type:
return "array"
# 提取基本类型
if "(" in column_type:
return column_type.split("(")[0]
return column_type
@classmethod
def merge_same_imports(cls, imports: list[str], import_start: str) -> list[str]:
"""
合并相同的导入语句。
参数:
- imports (list[str]): 导入语句列表。
- import_start (str): 导入语句的起始字符串。
返回:
- list[str]: 合并后的导入语句列表。
"""
merged_imports = []
imports_ = []
for import_stmt in imports:
if import_stmt.startswith(import_start):
imported_items = import_stmt.split("import")[1].strip()
imports_.extend(imported_items.split(", "))
else:
merged_imports.append(import_stmt)
if imports_:
# 去重并过滤空字符串,然后用逗号连接
unique_imports = [item for item in imports_ if item]
if len(unique_imports) > 0:
merged_datetime_import = f"{import_start} {', '.join(unique_imports)}"
merged_imports.append(merged_datetime_import)
return merged_imports
@classmethod
def get_dicts(cls, gen_table: GenTableOutSchema):
"""
获取字典列表。
参数:
- gen_table (GenTableOutSchema): 生成表的配置信息。
返回:
- str: 以逗号分隔的字典类型字符串。
"""
columns = gen_table.columns or []
dicts = set()
cls.add_dicts(dicts, columns)
# 处理sub_table为None的情况
if gen_table.sub_table is not None:
# 处理sub_table.columns为None的情况
sub_columns = gen_table.sub_table.columns or []
cls.add_dicts(dicts, sub_columns)
return ", ".join(dicts)
@classmethod
def add_dicts(cls, dicts: set[str], columns: list[GenTableColumnOutSchema]) -> None:
"""
添加字典类型到集合。
参数:
- dicts (set[str]): 字典类型集合。
- columns (list[GenTableColumnOutSchema]): 字段列表。
返回:
- set[str]: 更新后的字典类型集合。
"""
for column in columns:
super_column = column.super_column if column.super_column is not None else "0"
dict_type = column.dict_type or ""
html_type = column.html_type or ""
if (
not super_column
and StringUtil.is_not_empty(dict_type)
and StringUtil.equals_any_ignore_case(
html_type,
[
GenConstant.HTML_SELECT,
GenConstant.HTML_RADIO,
GenConstant.HTML_CHECKBOX,
],
)
):
dicts.add(f"'{dict_type}'")
@classmethod
def get_permission_prefix(cls, module_name: str | None, business_name: str | None) -> str:
"""
获取权限前缀。
参数:
- module_name (str | None): 模块名。
- business_name (str | None): 业务名。
返回:
- str: 权限前缀字符串。
"""
mn = (module_name or "").strip()
bn = (business_name or "").strip().replace("/", ":")
if not bn:
return mn
return f"{mn}:{bn}"
@classmethod
def python_type_to_ts_type(cls, python_type: str | None) -> str:
"""
将列上的 Python 类型(`get_db_type` + `DB_TO_PYTHON` 映射结果)转为前端 TS 类型片段。
与 JSON 序列化习惯一致:Decimal、日期时间多为字符串;dict/list 用宽松类型。
参数:
- python_type (str | None): Python 类型名。
返回:
- str: 前端 TypeScript 类型片段。
"""
if not python_type or not str(python_type).strip():
return "string"
p = str(python_type).strip()
mapping: dict[str, str] = {
"int": "number",
"float": "number",
"bool": "boolean",
"Decimal": "string",
"date": "string",
"time": "string",
"datetime": "string",
"timedelta": "string",
"dict": "Record<string, unknown>",
"list": "unknown[]",
"bytes": "string",
"str": "string",
}
return mapping.get(p, "string")
@classmethod
def get_api_route_prefix(cls, module_name: str | None) -> str:
"""
获取前端 API 路径首段,与 `discover` 中插件路由前缀一致(`module_xxx` → `xxx`)。
参数:
- module_name (str | None): 模块名,如 ``module_example``。
返回:
- str: 路由前缀,如 ``example``。
"""
if not module_name:
return ""
if module_name.startswith("module_"):
return module_name[7:]
return module_name
@classmethod
def get_sqlalchemy_type(cls, column: Any) -> str:
"""
获取 SQLAlchemy 类型。
参数:
- column (Any): 列对象或列类型字符串。
返回:
- str: SQLAlchemy 类型字符串。
"""
# 获取column_type和column_length
column_type = column
column_length = None
# 检查是否是对象
if hasattr(column, "column_type"):
column_type = column.column_type or ""
column_length = column.column_length or None
column_type = cls.normalize_db_column_type_for_mapping(column_type)
# MySQL:仅 tinyint(1) 映射为 Boolean;其余 tinyint 走 SmallInteger(见 GenConstant.DB_TO_SQLALCHEMY
ct_norm = (column_type or "").strip()
if settings.DATABASE_TYPE != "postgres" and ct_norm:
ct_lower = ct_norm.lower()
if ct_lower.startswith("tinyint(1)"):
return "Boolean"
# 首先尝试匹配完整类型(包括括号)
sqlalchemy_type = StringUtil.get_mapping_value_by_key_ignore_case(GenConstant.DB_TO_SQLALCHEMY, column_type)
# 特殊处理PostgreSQL类型
if settings.DATABASE_TYPE == "postgres":
if column_type.upper() == "BOOLEAN":
return "Boolean"
elif column_type.upper() == "REAL":
return "Float"
elif column_type.upper() == "DOUBLE PRECISION":
return "Float"
elif column_type.upper() == "TIMESTAMP":
return "DateTime"
elif column_type.upper() == "JSONB":
return "JSONB"
elif column_type.upper() == "UUID":
return "Uuid"
elif column_type.upper() == "BYTEA":
return "LargeBinary"
# get_mapping_value_by_key_ignore_case 未命中时返回 "",须与 None 同样视为未匹配
if not sqlalchemy_type and "(" in column_type:
# 如果没有匹配到,再尝试剥离括号
column_type_list = column_type.split("(")
col_type = column_type_list[0]
# 将 'character' 映射为 'char' 以匹配常量定义
if col_type.lower() == "character":
col_type = "char"
sqlalchemy_type = StringUtil.get_mapping_value_by_key_ignore_case(GenConstant.DB_TO_SQLALCHEMY, col_type)
# 如果是字符串类型且包含括号参数,保持原参数
if sqlalchemy_type in ["String", "CHAR"]:
sqlalchemy_type += "(" + column_type_list[1]
# 如果是Numeric或DECIMAL类型且包含括号参数,保持原参数
elif sqlalchemy_type in ["Numeric", "DECIMAL"]:
sqlalchemy_type += "(" + column_type_list[1]
elif not sqlalchemy_type:
# 处理没有括号的类型
col_type = column_type
# 将 'character' 映射为 'char' 以匹配常量定义
if col_type.lower() == "character":
col_type = "char"
sqlalchemy_type = StringUtil.get_mapping_value_by_key_ignore_case(GenConstant.DB_TO_SQLALCHEMY, col_type)
# 如果是字符串类型且没有指定长度,使用column_length或默认255
if sqlalchemy_type in ["String", "CHAR"]:
length = column_length if column_length and column_length.isdigit() else "255"
sqlalchemy_type += f"({length})"
else:
# 对于已经匹配到的类型,如果是字符串类型且column有长度信息,添加长度
if sqlalchemy_type in ["String", "CHAR"] and "(" not in sqlalchemy_type:
# 检查column_length是否有效
length = column_length if column_length and column_length.isdigit() else "255"
sqlalchemy_type += f"({length})"
# 如果没有找到匹配的类型,使用String(column_length)或String(255)作为默认类型
if not sqlalchemy_type:
length = column_length if column_length and column_length.isdigit() else "255"
sqlalchemy_type = f"String({length})"
return sqlalchemy_type