Skip to content

新建模块

本指南以 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

Released under the MIT License.