新建模块
本指南以 Demo 模块为例,演示如何创建一个完整的业务模块,包含后端 API 和前端页面。
概述
Demo 模块展示了平台的核心能力:
- 数据权限(行权限):根据用户角色控制可查看的数据范围
- 字段权限(列权限):根据角色配置隐藏或脱敏特定字段
- Excel 导入导出:支持带数据权限的导出
一、后端开发
1.1 目录结构
在 backend-fastapi/ 下创建模块目录:
zq_demo/
├── __init__.py
├── model.py # 数据模型
├── schema.py # Pydantic Schema
├── service.py # 业务逻辑层
├── api.py # API 路由
└── router.py # 路由注册1.2 定义 Model
python
# zq_demo/model.py
from sqlalchemy import Column, String, Text, Boolean, Integer
from app.base_model import BaseModel
class Demo(BaseModel):
"""
Demo模型 - 演示数据权限和字段权限
继承 BaseModel 自动获得:
- id: UUID 主键
- sort: 排序字段
- is_deleted: 软删除标记
- sys_creator_id: 创建人ID(用于数据权限)
- sys_dept_id: 部门ID(用于数据权限)
- sys_create_datetime: 创建时间
- sys_update_datetime: 更新时间
"""
__tablename__ = "demos"
title = Column(String(100), nullable=False, comment="标题")
content = Column(Text, nullable=True, comment="内容")
status = Column(Integer, default=1, comment="状态:0=草稿,1=发布,2=归档")
priority = Column(Integer, default=0, comment="优先级:0=低,1=中,2=高")
is_active = Column(Boolean, default=True, comment="是否激活")规范:
- 继承
app.base_model.BaseModel __tablename__使用小写复数形式- 所有字段添加
comment说明 - 字符串字段指定长度
- 使用逻辑外键,不创建数据库外键约束
1.3 定义 Schema
python
# zq_demo/schema.py
from datetime import datetime
from typing import Optional
from pydantic import BaseModel, ConfigDict, field_validator
from app.base_schema import CSTDatetime
class DemoBase(BaseModel):
"""基础 Schema - 包含业务字段"""
title: str
content: Optional[str] = None
status: int = 1
priority: int = 0
is_active: bool = True
@field_validator("status")
@classmethod
def validate_status(cls, v):
if v not in [0, 1, 2]:
raise ValueError("状态必须为 0(草稿)、1(发布) 或 2(归档)")
return v
class DemoCreate(DemoBase):
"""创建 Schema"""
pass
class DemoUpdate(BaseModel):
"""更新 Schema - 所有字段可选"""
title: Optional[str] = None
content: Optional[str] = None
status: Optional[int] = None
priority: Optional[int] = None
is_active: Optional[bool] = None
class DemoResponse(BaseModel):
"""
响应 Schema - 定义字段权限
必填字段(不可隐藏):直接声明类型
可选字段(可隐藏):使用 Optional
"""
# 必填字段 - 前端不可隐藏
id: str
status: int
# 可选字段 - 前端可以隐藏
title: Optional[str] = None
content: Optional[str] = None
priority: int = 0
is_active: bool = True
sort: Optional[int] = 0
is_deleted: Optional[bool] = False
sys_create_datetime: Optional[CSTDatetime] = None
sys_update_datetime: Optional[CSTDatetime] = None
sys_creator_id: Optional[str] = None
sys_dept_id: Optional[str] = None
model_config = ConfigDict(from_attributes=True)1.4 实现 Service
python
# zq_demo/service.py
from io import BytesIO
from typing import Tuple, Dict, Any, Optional
from sqlalchemy.ext.asyncio import AsyncSession
from app.base_service import BaseService
from app.field_metadata_generator import generate_field_metadata_from_schema
from zq_demo.model import Demo
from zq_demo.schema import DemoCreate, DemoUpdate, DemoResponse
class DemoService(BaseService[Demo, DemoCreate, DemoUpdate]):
"""
Demo 服务层
继承 BaseService 自动获得:
- create / get_by_id / get_list / update / delete / batch_delete
- check_unique: 唯一性检查
- get_list_with_data_scope: 带数据权限的列表查询
- apply_field_permissions_auto: 自动应用字段权限
- export_to_excel / import_from_excel: Excel 导入导出
"""
model = Demo
# 资源显示名称(用于前端显示)
RESOURCE_DISPLAY_NAME = "Demo示例"
# 从 Response Schema 生成字段元数据
FIELD_METADATA = generate_field_metadata_from_schema(DemoResponse, Demo)
# Excel 导入导出配置
excel_columns = {
"title": "标题",
"content": "内容",
"status": "状态",
"priority": "优先级",
"is_active": "是否激活",
}
excel_sheet_name = "Demo列表"
@classmethod
def _export_converter(cls, item: Any) -> Dict[str, Any]:
"""导出数据转换器"""
status_map = {0: "草稿", 1: "发布", 2: "归档"}
priority_map = {0: "低", 1: "中", 2: "高"}
return {
"title": item.title,
"content": item.content or "",
"status": status_map.get(item.status, "未知"),
"priority": priority_map.get(item.priority, "未知"),
"is_active": "是" if item.is_active else "否",
}
@classmethod
def _import_processor(cls, row: Dict[str, Any]) -> Optional[Demo]:
"""导入数据处理器"""
title = row.get("title")
if not title:
return None
status_map = {"草稿": 0, "发布": 1, "归档": 2}
priority_map = {"低": 0, "中": 1, "高": 2}
return Demo(
title=str(title),
content=str(row.get("content") or ""),
status=status_map.get(row.get("status", "发布"), 1),
priority=priority_map.get(row.get("priority", "低"), 0),
is_active=row.get("is_active", "是") in ("是", "true", "True", "1", True)
)1.5 实现 API
python
# zq_demo/api.py
from typing import Optional
from fastapi import APIRouter, Depends, HTTPException, Query, UploadFile, File
from fastapi.responses import StreamingResponse
from sqlalchemy.ext.asyncio import AsyncSession
from app.database import get_db
from app.config import settings
from app.base_schema import PaginatedResponse, ResponseModel
from zq_demo.schema import DemoCreate, DemoUpdate, DemoResponse
from zq_demo.service import DemoService
from zq_demo.model import Demo
router = APIRouter(prefix="/demos", tags=["Demo管理"])
@router.post("", response_model=DemoResponse, summary="创建Demo")
async def create_demo(demo: DemoCreate, db: AsyncSession = Depends(get_db)):
"""创建新Demo(自动记录创建人和部门)"""
if not await DemoService.check_unique(db, field="title", value=demo.title):
raise HTTPException(status_code=400, detail="标题已存在")
return await DemoService.create(db=db, data=demo)
@router.get("", response_model=PaginatedResponse[DemoResponse], summary="获取Demo列表")
async def get_demos(
page: int = Query(default=1, ge=1),
page_size: int = Query(default=settings.PAGE_SIZE, ge=1, le=settings.PAGE_MAX_SIZE, alias="pageSize"),
title: Optional[str] = Query(None, description="标题(模糊搜索)"),
status: Optional[int] = Query(None, description="状态筛选"),
db: AsyncSession = Depends(get_db)
):
"""获取Demo列表(自动应用数据权限和字段权限)"""
# 构建过滤条件
filters = []
if title:
filters.append(Demo.title.ilike(f"%{title}%"))
if status is not None:
filters.append(Demo.status == status)
# 1. 应用数据权限
items, total = await DemoService.get_list_with_data_scope(
db=db, page=page, page_size=page_size, filters=filters
)
# 2. 转换为响应格式
response_items = [DemoResponse.model_validate(item) for item in items]
# 3. 应用字段权限
response_dicts = [item.model_dump() for item in response_items]
filtered_items = await DemoService.apply_field_permissions_auto(data=response_dicts, db=db)
return PaginatedResponse(items=filtered_items, total=total)
@router.get("/export/excel", summary="导出Excel")
async def export_excel(db: AsyncSession = Depends(get_db)):
"""导出Demo数据到Excel(自动应用数据权限)"""
output = await DemoService.export_to_excel_with_data_scope(
db=db, data_converter=DemoService._export_converter
)
return StreamingResponse(
output,
media_type="application/vnd.openxmlformats-officedocument.spreadsheetml.sheet",
headers={"Content-Disposition": "attachment; filename=demo_export.xlsx"}
)
@router.get("/import/template", summary="下载导入模板")
async def download_template():
output = DemoService.get_import_template()
return StreamingResponse(
output,
media_type="application/vnd.openxmlformats-officedocument.spreadsheetml.sheet",
headers={"Content-Disposition": "attachment; filename=demo_template.xlsx"}
)
@router.post("/import/excel", response_model=ResponseModel, summary="导入Excel")
async def import_excel(
file: UploadFile = File(..., description="Excel文件(.xlsx)"),
db: AsyncSession = Depends(get_db)
):
if not file.filename.endswith(".xlsx"):
raise HTTPException(status_code=400, detail="只支持.xlsx格式")
content = await file.read()
success, fail = await DemoService.import_from_excel(db, content)
return ResponseModel(message=f"成功{success}条,失败{fail}条", data={"success": success, "fail": fail})
@router.get("/{demo_id}", response_model=DemoResponse, summary="获取详情")
async def get_demo(demo_id: str, db: AsyncSession = Depends(get_db)):
db_demo = await DemoService.get_by_id(db, record_id=demo_id)
if db_demo is None:
raise HTTPException(status_code=404, detail="Demo不存在")
response = DemoResponse.model_validate(db_demo)
filtered_data = await DemoService.apply_field_permissions_auto(data=response.model_dump(), db=db)
return filtered_data
@router.put("/{demo_id}", response_model=DemoResponse, summary="更新Demo")
async def update_demo(demo_id: str, demo: DemoUpdate, db: AsyncSession = Depends(get_db)):
if demo.title and not await DemoService.check_unique(db, field="title", value=demo.title, exclude_id=demo_id):
raise HTTPException(status_code=400, detail="标题已存在")
db_demo = await DemoService.update(db, record_id=demo_id, data=demo)
if db_demo is None:
raise HTTPException(status_code=404, detail="Demo不存在")
return db_demo
@router.delete("/{demo_id}", response_model=ResponseModel, summary="删除Demo")
async def delete_demo(
demo_id: str,
hard: bool = Query(default=False, description="是否物理删除"),
db: AsyncSession = Depends(get_db)
):
success = await DemoService.delete(db, record_id=demo_id, hard=hard)
if not success:
raise HTTPException(status_code=404, detail="Demo不存在")
return ResponseModel(message="删除成功")1.6 注册路由
python
# zq_demo/router.py
from fastapi import APIRouter
from zq_demo.api import router as demo_router
router = APIRouter()
router.include_router(demo_router, prefix="", tags=["Demo"])在 main.py 中注册:
python
from zq_demo.router import router as zq_demo_router
app.include_router(zq_demo_router, prefix="/api/zq_demo")1.7 数据库迁移
bash
cd backend-fastapi
alembic revision --autogenerate -m "add demo table"
alembic upgrade head二、前端开发
2.1 目录结构
在 web/apps/web-ele/src/views/_core/ 下创建模块目录:
demo/
├── index.vue # 列表页面
└── modules/
└── demo-form-dialog.vue # 表单弹窗2.2 定义 API 类型和接口
typescript
// api/core/demo.ts
import { requestClient } from '#/api/request';
export interface Demo {
id: string;
title: string;
content?: string;
status: number;
priority: number;
is_active: boolean;
sys_create_datetime?: string;
}
export interface DemoListParams {
page: number;
pageSize: number;
title?: string;
status?: number;
priority?: number;
}
export function getDemoListApi(params: DemoListParams) {
return requestClient.get<{ items: Demo[]; total: number }>('/zq_demo/demos', { params });
}
export function createDemoApi(data: Partial<Demo>) {
return requestClient.post<Demo>('/zq_demo/demos', data);
}
export function updateDemoApi(id: string, data: Partial<Demo>) {
return requestClient.put<Demo>(`/zq_demo/demos/${id}`, data);
}
export function deleteDemoApi(id: string) {
return requestClient.delete(`/zq_demo/demos/${id}`);
}
export function exportDemoExcelApi() {
return requestClient.get('/zq_demo/demos/export/excel', { responseType: 'blob' });
}
export function downloadDemoTemplateApi() {
return requestClient.get('/zq_demo/demos/import/template', { responseType: 'blob' });
}
export function importDemoExcelApi(file: File) {
const formData = new FormData();
formData.append('file', file);
return requestClient.post('/zq_demo/demos/import/excel', formData);
}2.3 列表页面
vue
<!-- views/_core/demo/index.vue -->
<script lang="ts" setup>
import type { Demo } from '#/api/core/demo';
import { ref } from 'vue';
import { Page } from '@vben/common-ui';
import { $t } from '@vben/locales';
import { Download, Edit, Plus, Trash2, Upload } from '@vben/icons';
import { ElButton, ElMessage, ElMessageBox, ElTag } from 'element-plus';
import {
createDemoApi, deleteDemoApi, downloadDemoTemplateApi,
exportDemoExcelApi, getDemoListApi, importDemoExcelApi, updateDemoApi,
} from '#/api/core/demo';
import { useZqTable } from '#/components/zq-table';
import DemoFormDialog from './modules/demo-form-dialog.vue';
defineOptions({ name: 'DemoManager' });
const demoFormDialogRef = ref<InstanceType<typeof DemoFormDialog>>();
const fetchDemoList = async (params: any) => {
const res = await getDemoListApi({
page: params.page.currentPage,
pageSize: params.page.pageSize,
title: params.form?.title,
status: params.form?.status,
});
return { items: res.items, total: res.total };
};
const [Grid, gridApi] = useZqTable({
gridOptions: {
columns: [
{ key: 'title', title: $t('demos.demo.title'), minWidth: 200 },
{ key: 'content', title: $t('demos.demo.content'), minWidth: 250, showOverflow: true },
{ key: 'status', title: $t('demos.demo.status'), width: 100, align: 'center', slots: { default: 'cell-status' } },
{ key: 'priority', title: $t('demos.demo.priority'), width: 100, align: 'center', slots: { default: 'cell-priority' } },
{ key: 'is_active', title: $t('demos.demo.isActive'), width: 100, align: 'center', slots: { default: 'cell-is_active' } },
{ key: 'sys_create_datetime', title: $t('demos.demo.createTime'), width: 170, slots: { default: 'cell-create_time' } },
{ key: 'actions', title: $t('common.action'), width: 150, fixed: 'right', align: 'center', slots: { default: 'cell-actions' } },
],
border: true,
stripe: true,
showSelection: true,
showIndex: true,
proxyConfig: { autoLoad: true, ajax: { query: fetchDemoList } },
pagerConfig: { enabled: true, pageSize: 20 },
toolbarConfig: { search: true, refresh: true, zoom: true, custom: true },
},
formOptions: {
schema: [
{ fieldName: 'title', label: $t('demos.demo.title'), component: 'Input' },
{
fieldName: 'status', label: $t('demos.demo.status'), component: 'Select',
componentProps: {
options: [
{ label: $t('common.all'), value: '' },
{ label: $t('demos.demo.statusDraft'), value: 0 },
{ label: $t('demos.demo.statusPublished'), value: 1 },
{ label: $t('demos.demo.statusArchived'), value: 2 },
],
},
},
],
showCollapseButton: false,
submitOnChange: false,
},
});
type TagType = 'danger' | 'info' | 'success' | 'warning';
const statusMap: Record<number, { label: string; type: TagType }> = {
0: { label: $t('demos.demo.statusDraft'), type: 'info' },
1: { label: $t('demos.demo.statusPublished'), type: 'success' },
2: { label: $t('demos.demo.statusArchived'), type: 'warning' },
};
const priorityMap: Record<number, { label: string; type: TagType }> = {
0: { label: $t('demos.demo.priorityLow'), type: 'info' },
1: { label: $t('demos.demo.priorityMedium'), type: 'warning' },
2: { label: $t('demos.demo.priorityHigh'), type: 'danger' },
};
function handleCreate() { demoFormDialogRef.value?.open(); }
function handleEdit(row: Demo) { demoFormDialogRef.value?.open(row); }
async function handleDelete(row: Demo) {
try {
await ElMessageBox.confirm($t('demos.demo.deleteConfirm'), $t('demos.demo.delete'), { type: 'warning' });
await deleteDemoApi(row.id);
ElMessage.success($t('demos.demo.deleteSuccess'));
gridApi.reload();
} catch {}
}
function handleFormSuccess() { gridApi.reload(); }
function formatDate(dateStr?: string): string { return dateStr ? new Date(dateStr).toLocaleString() : '-'; }
async function handleExport() {
const blob = await exportDemoExcelApi();
const url = window.URL.createObjectURL(blob as Blob);
const link = document.createElement('a');
link.href = url;
link.download = `demo_export_${Date.now()}.xlsx`;
link.click();
window.URL.revokeObjectURL(url);
}
</script>
<template>
<Page auto-content-height>
<Grid>
<template #toolbar-actions>
<ElButton type="primary" :icon="Plus" @click="handleCreate">{{ $t('demos.demo.create') }}</ElButton>
<ElButton :icon="Download" @click="handleExport">{{ $t('demos.demo.exportExcel') }}</ElButton>
</template>
<template #cell-status="{ row }">
<ElTag :type="statusMap[row.status]?.type || 'info'" size="small">{{ statusMap[row.status]?.label }}</ElTag>
</template>
<template #cell-priority="{ row }">
<ElTag :type="priorityMap[row.priority]?.type || 'info'" size="small">{{ priorityMap[row.priority]?.label }}</ElTag>
</template>
<template #cell-is_active="{ row }">
<ElTag :type="row.is_active ? 'success' : 'info'" size="small">{{ row.is_active ? $t('common.enabled') : $t('common.disabled') }}</ElTag>
</template>
<template #cell-create_time="{ row }">{{ formatDate(row.sys_create_datetime) }}</template>
<template #cell-actions="{ row }">
<ElButton link type="primary" :icon="Edit" @click="handleEdit(row)">{{ $t('common.edit') }}</ElButton>
<ElButton link type="danger" :icon="Trash2" @click="handleDelete(row)">{{ $t('common.delete') }}</ElButton>
</template>
</Grid>
<DemoFormDialog ref="demoFormDialogRef" @success="handleFormSuccess" />
</Page>
</template>2.4 表单弹窗
vue
<!-- views/_core/demo/modules/demo-form-dialog.vue -->
<script lang="ts" setup>
import type { Demo } from '#/api/core/demo';
import { computed, ref } from 'vue';
import { ZqDialog } from '#/components/zq-dialog';
import { $t } from '@vben/locales';
import { ElButton } from 'element-plus';
import { useVbenForm, z } from '#/adapter/form';
import { createDemoApi, updateDemoApi } from '#/api/core/demo';
const emit = defineEmits(['success']);
const formData = ref<Demo>();
const visible = ref(false);
const confirmLoading = ref(false);
const getTitle = computed(() => formData.value?.id ? $t('demos.demo.edit') : $t('demos.demo.create'));
const [Form, formApi] = useVbenForm({
layout: 'vertical',
schema: [
{
component: 'Input', fieldName: 'title', label: $t('demos.demo.title'),
rules: z.string().min(1, $t('ui.formRules.required', [$t('demos.demo.title')])).max(100),
},
{
component: 'Textarea', fieldName: 'content', label: $t('demos.demo.content'),
componentProps: { rows: 4 },
},
{
component: 'Select', fieldName: 'status', label: $t('demos.demo.status'), defaultValue: 1,
componentProps: {
options: [
{ label: $t('demos.demo.statusDraft'), value: 0 },
{ label: $t('demos.demo.statusPublished'), value: 1 },
{ label: $t('demos.demo.statusArchived'), value: 2 },
],
},
},
{
component: 'Select', fieldName: 'priority', label: $t('demos.demo.priority'), defaultValue: 0,
componentProps: {
options: [
{ label: $t('demos.demo.priorityLow'), value: 0 },
{ label: $t('demos.demo.priorityMedium'), value: 1 },
{ label: $t('demos.demo.priorityHigh'), value: 2 },
],
},
},
{
component: 'RadioGroup', fieldName: 'is_active', label: $t('demos.demo.isActive'), defaultValue: true,
componentProps: {
options: [
{ label: $t('common.enabled'), value: true },
{ label: $t('common.disabled'), value: false },
],
},
},
],
showDefaultActions: false,
});
async function onSubmit() {
const { valid } = await formApi.validate();
if (valid) {
confirmLoading.value = true;
const data = await formApi.getValues();
try {
await (formData.value?.id ? updateDemoApi(formData.value.id, data) : createDemoApi(data));
visible.value = false;
emit('success');
} finally {
confirmLoading.value = false;
}
}
}
function open(data?: Demo) {
visible.value = true;
if (data) {
formData.value = data;
formApi.setValues(data);
} else {
formData.value = undefined;
formApi.resetForm();
}
}
defineExpose({ open });
</script>
<template>
<ZqDialog v-model="visible" :title="getTitle" :confirm-loading="confirmLoading" @confirm="onSubmit">
<Form class="mx-4" />
<template #footer-left>
<ElButton type="primary" @click="formApi.resetForm(); formApi.setValues(formData || {})">{{ $t('common.reset') }}</ElButton>
</template>
</ZqDialog>
</template>2.5 添加国际化
在 locales/langs/zh-CN.json 中添加:
json
{
"demos": {
"demo": {
"title": "标题",
"content": "内容",
"status": "状态",
"priority": "优先级",
"isActive": "是否激活",
"createTime": "创建时间",
"statusDraft": "草稿",
"statusPublished": "已发布",
"statusArchived": "已归档",
"priorityLow": "低",
"priorityMedium": "中",
"priorityHigh": "高",
"create": "新建",
"edit": "编辑",
"delete": "删除",
"deleteConfirm": "确定要删除这条记录吗?",
"deleteSuccess": "删除成功",
"exportExcel": "导出Excel",
"importExcel": "导入Excel",
"downloadTemplate": "下载模板",
"importSuccess": "导入成功"
}
}
}2.6 注册路由
在 router/routes/modules/ 中添加路由配置:
typescript
{
path: '/demo',
name: 'Demo',
component: () => import('#/views/_core/demo/index.vue'),
meta: { title: 'Demo管理', icon: 'lucide:box' },
}三、权限配置
3.1 数据权限
在角色管理中配置数据范围:
- 全部数据:查看所有记录
- 本部门及下级:查看本部门及下级部门的数据
- 本部门:仅查看本部门的数据
- 本人:仅查看自己创建的数据
3.2 字段权限
在字段权限管理中配置:
- read:可读(默认)
- hidden:隐藏(不返回该字段)
- masked:脱敏(返回脱敏后的值)
注意
- 必填字段(非 Optional)不会被隐藏
- 超级管理员不受数据权限限制
- 字段权限配置会被缓存到 Redis