ARTICLE DETAIL

资讯详情

深耕网站视觉设计与运营推广的一线实战洞察。

FastAPI + Vue3实战:部门管理列表查询从0到1

FastAPI + Vue3实战:部门管理列表查询从0到1 1. 为什么部门管理是后台系统的第一块基石需求拆解与技术选型先说说我为什么选择从部门管理列表查询这个功能开始整个Web开发实战系列。在新接手的任何一个企业级后台系统里部门管理几乎都是最先被要求实现的基础模块之一。原因很简单部门是组织架构的基本单元几乎所有的用户、权限、角色都要挂靠在部门维度上。换句话说部门管理不是一个孤立的功能它是后续几十个功能模块的地基。我在刚开始接这类需求时也会觉得部门管理不就一个CRUD吗实际动手才发现光一个列表查询就牵扯到分页、搜索、状态过滤、树形层级展示、跨域联调、接口约定等一系列问题。尤其是当你面对的是真实企业场景而不是教学Demo时需求的边角细节会迅速膨胀。我见过不少新手在这上面栽跟头列表接口写好了但前端表格一多就卡搜索条件参数名和后端对不上联调时来回扯皮分页逻辑和全选交互打架选中数据跟着页码翻飞。这些问题单独看都不难但拼在一起足以让第一天的实战变成一场灾难。所以这一篇我打算把整个部门列表查询功能从0到1完整拆开覆盖技术选型、表结构设计、后端接口实现、前端页面交互、联调排错五个环节。技术栈我选的是FastAPI SQLAlchemy Vue 3 Element Plus这套组合目前在企业级Python Web开发中非常主流网上的资料也足够丰富适合作为实战系列的开篇。1.1 需求拆解列表查询绝不是查出来展示这么简单不要一上来就写代码。我习惯先把需求拆干净再决定怎么写。一个标准的部门列表查询页面至少要包含以下能力分页查询表格不能一次性加载全部数据通常使用页码加每页条数的方式。关键字搜索按部门名称或编码模糊匹配这是最常见的检索方式。状态筛选部门有启用和停用两种状态需要支持按状态过滤。排序按创建时间或自定义排序号排列。关联信息展示列表中显示创建时间、更新状态等信息。选中交互表格支持多选配合全选按钮方便后续批量操作。除此之外还要考虑部门之间的上下级关系。虽然Day1只做列表查询但表结构上必须为未来的树形层级留好接口否则后面做部门树、子部门管理时会非常痛苦。1.2 技术选型的逻辑为什么是FastAPI Vue 3这套组合我见过很多团队在技术选型上走极端要么迷恋最新最热要么死守祖宗之法。我的选择逻辑其实很简单团队上手快、生态成熟、能满足前后端分离的协作要求。后端选择FastAPI核心原因是它是目前Python Web框架里开发效率和性能平衡得最好的一个。自带OpenAPI文档意味着前后端联调时前端可以直接查看接口文档不用后端反复截图说明参数格式。异步支持和Pydantic的数据校验也省去了大量手写参数校验的代码。相比DjangoFastAPI更轻量适合快速迭代相比Flask它又内置了数据校验和API文档省去拼接第三方组件的精力。前端选择Vue 3加Element Plus是因为Vue的响应式数据模型非常适合表格这类交互密集型页面Element Plus的el-table组件对分页、多选、排序的支持很完整能让我们把精力集中在业务逻辑而不是重复造轮子上。提示技术选型没有绝对的对错关键是一套组合能否让前后端各自专注自己的领域减少协作摩擦。我的选择仅供参考重点在于整个实战的思路。2. 部门表结构设计看似简单的表坑全在细节里表结构设计通常是整个功能开发中最无聊但最影响后续的部分。部门表看起来就是名称加编码但我在实际项目中踩过不少坑比如没有预留层级字段导致后面做树结构时只能改表、排序字段缺失导致顺序控制全靠前端硬编码、状态字段类型设计不合理导致查询效率低下。这节我直接把设计思路和最终的表结构拆开讲。2.1 字段设计的核心既要满足现状也要为未来留余地先看最终的表结构设计我用的是MySQL考虑到企业级开发中InnoDB和utf8mb4几乎是标配。CREATE TABLE sys_dept ( id bigint NOT NULL AUTO_INCREMENT COMMENT 部门ID, parent_id bigint NOT NULL DEFAULT 0 COMMENT 父部门ID0表示顶级部门, dept_name varchar(64) NOT NULL COMMENT 部门名称, dept_code varchar(32) NOT NULL COMMENT 部门编码, sort int NOT NULL DEFAULT 0 COMMENT 显示顺序, status tinyint NOT NULL DEFAULT 1 COMMENT 状态1启用0停用, create_by varchar(32) DEFAULT NULL COMMENT 创建人, create_time datetime DEFAULT NULL COMMENT 创建时间, update_by varchar(32) DEFAULT NULL COMMENT 更新人, update_time datetime DEFAULT NULL COMMENT 更新时间, deleted tinyint NOT NULL DEFAULT 0 COMMENT 逻辑删除0未删除1已删除, PRIMARY KEY (id), KEY idx_parent_id (parent_id), KEY idx_dept_code (dept_code) ) ENGINEInnoDB AUTO_INCREMENT1 DEFAULT CHARSETutf8mb4 COMMENT部门表;逐个字段说下设计理由。parent_id这是为树形层级预留的字段顶级部门的parent_id设为0。为什么不用pid这种命名因为后续字段查询、ORM映射时parent_id的语义更清晰团队里不同人看代码时不会产生歧义。索引上对parent_id建了普通索引因为后续按父部门查子部门会是一个非常高频的查询条件。dept_code部门编码看起来很冗余但在很多企业系统中部门编码会关联到考勤、财务、OA等外部系统所以必须作为独立字段存在同时加上唯一约束虽然上面DDL没有写建议实际生产环境加上UNIQUE KEY。这里有个小建议编码一旦生成尽量别允许修改否则会导致关联数据紊乱。sort显示顺序字段。很多人会忽略这个结果就是部门列表的顺序在前端写死后面新增部门只能插在最后需求一变就得改代码。sort配合order_by就能灵活控制排序默认值是0数值越小越靠前。deleted逻辑删除字段。真实系统里部门往往不能物理删除因为历史数据中可能有几十个表引用了部门ID。逻辑删除用0和1标识注意和status的语义区分status控制是否启用deleted控制是否存在。2.2 容易被忽略的约定时间字段与逻辑删除的规范我在不少项目里见过create_time用字符串存储的情况短时间看没什么问题但一旦涉及时间范围查询、排序字符串的字典序和时间的自然序就会产生偏差。建议统一用datetime类型ORM映射到DateTime字段。还有一个容易被忽略的点逻辑删除字段应该参与所有查询条件的组装。一个常见的Bug就是查询列表时忘记加deleted 0导致已删除的部门出现在列表中。为了不依赖人为记忆我习惯在ORM层定义一个默认的query过滤器或者在Service层封装一个get_dept_list方法统一在内部拼接deleted 0条件避免每个接口重复写。3. 后端接口实现从ORM模型到统一响应格式的完整链路这节进入核心编码。我将按照项目初始化、ORM模型定义、Schema定义、接口逻辑、统一响应格式的顺序来讲每一步都会说明为什么要这样做。3.1 项目初始化与目录结构我习惯用FastAPI的Application Factory模式组织目录虽然部门管理只是一个小模块但好的结构能让你在系列后续加入用户、角色、菜单模块时不用推倒重来。project_root/ ├── app/ │ ├── api/ │ │ ├── __init__.py │ │ └── dept.py │ ├── core/ │ │ ├── config.py │ │ └── resp.py │ ├── db/ │ │ ├── __init__.py │ │ └── session.py │ ├── models/ │ │ ├── __init__.py │ │ └── dept.py │ ├── schemas/ │ │ ├── __init__.py │ │ └── dept.py │ └── main.py ├── requirements.txt └── README.md快速创建FastAPI项目mkdir dept_demo cd dept_demo python -m venv venv source venv/bin/activate # Windows下为 venv\Scripts\activate pip install fastapi uvicorn sqlalchemy pymysql python-dotenv3.2 ORM模型定义代码要对齐表结构SQLAlchemy模型和表结构一一对应代码如下# app/models/dept.py from sqlalchemy import Column, BigInteger, String, Integer, DateTime, SmallInteger from datetime import datetime from sqlalchemy.orm import declarative_base Base declarative_base() class Dept(Base): __tablename__ sys_dept id Column(BigInteger, primary_keyTrue, autoincrementTrue, comment部门ID) parent_id Column(BigInteger, nullableFalse, default0, comment父部门ID) dept_name Column(String(64), nullableFalse, comment部门名称) dept_code Column(String(32), nullableFalse, comment部门编码) sort Column(Integer, nullableFalse, default0, comment显示顺序) status Column(SmallInteger, nullableFalse, default1, comment状态1启用0停用) create_by Column(String(32), comment创建人) create_time Column(DateTime, defaultdatetime.now, comment创建时间) update_by Column(String(32), comment更新人) update_time Column(DateTime, defaultdatetime.now, onupdatedatetime.now, comment更新时间) deleted Column(SmallInteger, nullableFalse, default0, comment逻辑删除0未删除1已删除)这里有几个细节需要注意。create_time的defaultdatetime.now是Python侧在执行INSERT时自动生成时间不是在数据库侧。update_time的onupdatedatetime.now会在ORM更新行时自动刷新时间戳。两者配合创建和更新的时间都不用手动维护。注意onupdate只在SQLAlchemy ORM层面生效也就是通过session更新才会自动触发。如果项目里有原生SQL直接UPDATE这个字段不会自动更新。在纯SQLAlchemy项目里问题不大但如果你后面引入了pandas、SQLAlchemy Core等其他写入渠道就要留意这一点。3.3 Pydantic Schema数据校验和响应约束FastAPI的Pydantic Schema是一道非常好的边界。它把前端传什么参数和后端返回什么字段分别约束好接口调用方一眼就能明白数据结构。# app/schemas/dept.py from pydantic import BaseModel from typing import Optional from datetime import datetime class DeptQuery(BaseModel): page: int 1 page_size: int 10 dept_name: Optional[str] None status: Optional[int] None class DeptOut(BaseModel): id: int parent_id: int dept_name: str dept_code: str sort: int status: int create_time: Optional[datetime] None update_time: Optional[datetime] None class Config: from_attributes True class DeptPageResult(BaseModel): total: int items: list[DeptOut]我通常会把查询参数、单项返回、分页返回分别定义成三个模型。这样做的优点是当后续部门新增、编辑、详情等接口加入时可以复用DeptOut而查询参数模型随着搜索条件增加可以独立扩展不影响其他接口。3.4 列表查询接口分页、搜索、过滤逻辑接口的核心逻辑放在Service层这里为了展示清晰直接在路由中实现。# app/api/dept.py from fastapi import APIRouter, Depends from sqlalchemy.orm import Session from sqlalchemy import select, func from app.db.session import get_db from app.models.dept import Dept from app.schemas.dept import DeptQuery, DeptPageResult router APIRouter(prefix/api/dept, tags[部门管理]) router.post(/list, response_modelDeptPageResult) def list_dept(query: DeptQuery, db: Session Depends(get_db)): # 1. 构建基础条件逻辑删除过滤 conditions [Dept.deleted 0] # 2. 可选搜索条件 if query.dept_name: conditions.append(Dept.dept_name.like(f%{query.dept_name.strip()}%)) if query.status is not None: conditions.append(Dept.status query.status) # 3. 查询总数 total db.scalar( select(func.count(Dept.id)).where(*conditions) ) # 4. 分页查询 items db.scalars( select(Dept) .where(*conditions) .order_by(Dept.sort.asc(), Dept.create_time.desc()) .offset((query.page - 1) * query.page_size) .limit(query.page_size) ).all() return DeptPageResult(totaltotal, itemsitems)这段代码有几个细节可以展开。为什么用POST而不是GET虽然语义上查询用GET更符合REST规范但在真实项目中列表查询的过滤条件会越来越多若全部挂在URL Query参数上既长又难维护。用POST加JSON Body的方式参数结构由Pydantic校验前端、后端、接口文档三方看到的都是同一个JSON结构协作效率高很多。这是我在企业级项目里的一个习惯不一定适合所有团队但稳定性确实很好。模糊搜索的拼法dept_name.like(f%{query.dept_name.strip()}%)先strip()去首尾空格防止用户误输入空格导致查询无结果。这种处理看似小事但能显著减少前端提交参数时我明明输入了名字却查不到的反馈。总数与列表分开查很多人会图省事先查列表再len(items)当作total这样当数据量超过一页时total永远是当前页的数量分页组件的总页数就会算错。必须用func.count单独查总数。排序策略先用sort升序相同排序号再按create_time降序。这样既保证手工自定义顺序生效又能让最新创建的部门在同级中靠前。3.5 统一响应格式让前端不再频繁取错字段企业级开发中前后端接口如果各写各的响应格式前端处理起来会非常痛苦。我通常在项目里定义一套标准的响应结构{ code: 0, message: success, data: { total: 100, items: [] } }对应的实现方式可以在全局注册一个响应模型所有接口统一返回。# app/core/resp.py from typing import Any, Generic, TypeVar from pydantic import BaseModel T TypeVar(T) class ApiResponse(BaseModel, Generic[T]): code: int 0 message: str success data: T | None None这样的好处是前端封装请求层时只需判断code 0不需要针对每个接口单独解析各种奇怪的结构。后续增加业务错误码时也只需要修改code字段的取值约定接口整体骨架不变。4. 前端列表页实现Vue 3表格、搜索栏与全选交互后端接口就绪后前端的工作主要分三块API封装、列表组件、全选交互。4.1 API封装统一管理请求和响应拦截用Vite创建一个Vue 3项目后第一步就是把axios封装好。我习惯在src/api目录下按模块划分请求方法。// src/api/request.js import axios from axios import { ElMessage } from element-plus const request axios.create({ baseURL: /api, timeout: 10000, }) request.interceptors.response.use( (response) { const res response.data if (res.code ! 0) { ElMessage.error(res.message || 请求失败) return Promise.reject(new Error(res.message)) } return res.data }, (error) { ElMessage.error(error.message || 网络异常) return Promise.reject(error) } ) export default request// src/api/dept.js import request from ./request export function getDeptList(data) { return request({ url: /dept/list, method: post, data, }) }响应拦截器里直接返回res.data业务组件里拿到的就是包裹后的业务数据不需要每个页面再手动拆res.data.data。4.2 页面组件加载、展示、分页列表页的逻辑可以这样组织template div classdept-page el-card el-form :inlinetrue :modelqueryParams submit.prevent el-form-item label部门名称 el-input v-modelqueryParams.deptName placeholder请输入部门名称 clearable keyup.enterhandleSearch / /el-form-item el-form-item label状态 el-select v-modelqueryParams.status placeholder全部 clearable el-option label启用 :value1 / el-option label停用 :value0 / /el-select /el-form-item el-form-item el-button typeprimary clickhandleSearch查询/el-button el-button clickhandleReset重置/el-button /el-form-item /el-form /el-card el-card el-table refdeptTableRef v-loadingloading :datadeptList row-keyid selection-changehandleSelectionChange el-table-column typeselection width55 / el-table-column propdeptName label部门名称 min-width160 / el-table-column propdeptCode label部门编码 min-width120 / el-table-column propsort label排序 width80 / el-table-column propstatus label状态 width80 template #default{ row } el-tag :typerow.status 1 ? success : info {{ row.status 1 ? 启用 : 停用 }} /el-tag /template /el-table-column el-table-column propcreateTime label创建时间 min-width180 / /el-table el-pagination v-model:current-pagequeryParams.page v-model:page-sizequeryParams.pageSize :totaltotal :page-sizes[10, 20, 50, 100] layouttotal, sizes, prev, pager, next, jumper size-changehandleSearch current-changehandleSearch / /el-card /div /template对应的脚本部分script setup import { ref, reactive, onMounted } from vue import { getDeptList } from /api/dept const loading ref(false) const deptList ref([]) const total ref(0) const selectedRows ref([]) const queryParams reactive({ page: 1, pageSize: 10, deptName: , status: null, }) const fetchList async () { loading.value true try { const data await getDeptList({ page: queryParams.page, page_size: queryParams.pageSize, dept_name: queryParams.deptName || undefined, status: queryParams.status ?? undefined, }) deptList.value data.items total.value data.total } finally { loading.value false } } const handleSearch () { queryParams.page 1 fetchList() } const handleReset () { queryParams.deptName queryParams.status null queryParams.page 1 fetchList() } const handleSelectionChange (rows) { selectedRows.value rows } onMounted(() { fetchList() }) /script4.3 全选按钮的真实作用域跨页选中与前端筛选的矛盾热搜词里出现了vue查询列表全选按钮这确实是列表页里一个典型的细节坑。el-table自带的全选按钮默认只全选当前页的数据而很多业务需求期望的是全选所有符合筛选条件的数据。具体来说用户当前在第2页点全选往往本意是把查询结果里所有部门都选中但组件默认行为只是选中当前页。如果后续要做批量启停用、批量转移少了数据就会引发严重问题。我的处理方案是如果是Day1这样数据量级不大、分页存在但全选需求不复杂的场景先用selection-change记录当前页选中的行并在表格下方清晰地提示已选择 n 项。如果需要跨页全选则需要额外增加一个跨页全选的开关思路大致如下维护一个isAcrossPageSelect布尔值。当用户点击全选时先判断当前页是否全部选中如果是则提示用户是否要全选所有筛选结果。确认后把当前筛选条件下的所有ID一次性拉取到前端存入selectedIds集合同时对所有页的行显示勾选状态。这个方案涉及el-table的row-key与reserve-selection配合实现起来稍复杂。Day1阶段我建议先做单页内的全选但数据模型和接口要预留根据筛选条件查询所有ID的能力比如后端增加一个list_ids接口。等系列后续做批量操作时再扩展跨页全选。这里还有一个常见的隐藏Bug当用户修改搜索条件或重置筛选后之前选中的行还在selectedRows里但页面上已经看不到了后续操作时会把不符合条件的行也一起处理掉。解决办法是在handleSearch和handleReset时主动清空selectedRowsconst handleSearch () { queryParams.page 1 selectedRows.value [] fetchList() }清空选中这一行代码能避免大量我明明没选它但它被操作了的线上反馈。4.4 Loading与竞态处理快速操作时的表格闪烁问题列表页还有一个经常被忽略的体验问题用户快速切换页码、频繁点击查询时前一次的请求可能比后一次的请求晚返回导致表格先展示旧结果又被旧请求覆盖页面数据错乱。解决方案有两种简单起见可以用请求序号控制let requestSeq 0 const fetchList async () { const currentSeq requestSeq loading.value true try { const data await getDeptList({ ... }) if (currentSeq requestSeq) { deptList.value data.items total.value data.total } } finally { if (currentSeq requestSeq) { loading.value false } } }这样只有最后一次请求才会真正更新表格数据。这个技巧写起来冗余但面对高频操作时非常有效。5. 前后端联调实测我在Day1遇到的典型问题与排查思路代码写完之后联调是真正检验成果的环节。这里把我在类似实战中反复遇到的几类问题整理出来每一条都是实际推到过线上的坑希望帮你提前绕开。5.1 跨域问题前端页面发请求直接报错第一次联调时前端npm run dev跑在http://localhost:5173后端FastAPI跑在http://localhost:8000前端一请求就报跨域错误。我在后端用CORSMiddleware解决# app/main.py from fastapi.middleware.cors import CORSMiddleware app.add_middleware( CORSMiddleware, allow_origins[http://localhost:5173], allow_credentialsTrue, allow_methods[*], allow_headers[*], )生产环境中allow_origins建议配置为具体域名不要直接使用[*]尤其当接口需要携带Cookie凭证时通配符和allow_credentialsTrue是冲突的。5.2 时区与日期格式表格里显示的时间差了8小时联调时发现页面上创建时间比数据库时间少了8小时。原因是数据库连接的engine参数没有配置时区导致datetime在取出来时被当作UTC时间处理。解决方法是连接串中显式加上时区参数DATABASE_URL mysqlpymysql://user:password127.0.0.1:3306/dept_db?charsetutf8mb4 engine create_engine(DATABASE_URL, pool_pre_pingTrue)同时保持create_time字段不使用数据库侧的CURRENT_TIMESTAMP作为默认值而是完全交给Python侧的datetime.now生成。这样写入和读取都走应用服务器本地时间不容易出现跨时区的歧义。5.3 分页参数从1开始还是从0开始前后端最容易发生分歧的一个约定。前端Element Plus的el-pagination默认页码从1开始后端接口也按page1作为第一页。但不少后端框架习惯从0开始。如果一个项目里既有从0开始的分页接口又有从1开始的前端就只能写一堆page-1的判断。我的建议是统一约定所有列表接口的page从1开始。5.4 查询结果集过大的性能隐患Day1阶段数据量小表现不明显但部门表在企业中通常不会超过几千条分页查询其实压力不大。如果后续开发类似操作日志这样的表数据规模到百万级就要考虑在查询条件上做更严格的索引设计以及限制单次最大分页深度比如page_size最大值100防止有人手动拼一个大页面把数据库拖垮。这个限制在后端Pydantic中就加上from pydantic import Field class DeptQuery(BaseModel): page: int Field(1, ge1) page_size: int Field(10, ge1, le100)ge和le让参数校验在接口入口就完成避免非法值进入数据库查询。5.5 一个让我排查了很久的数据没更新问题有次联调发现修改部门名称后接口返回成功但列表数据始终是旧值。排查后发现是SQLAlchemy的Session缓存问题在同一请求中先查询了部门再通过另一个入口修改了同一行数据第二次查询时走了缓存。解决办法是确保修改操作和查询操作使用同一个数据库Session或者在修改后调用db.commit()后立即db.expire_all()。实际开发中我习惯把Service层的查询和写操作拆开每个事务使用独立的Session既避免缓存问题也让事务边界更清晰。6. Day1完成后的下一步把列表查询能力沉淀为通用模板Day1做完了不要急着进入下一个功能模块。部门管理列表查询虽然简单但它包含的骨架是前后端所有列表页共通的搜索表单、表格渲染、分页、选中、Loading、异常处理。我在实际项目中通常会把这套结构抽成通用页面模板后续的岗位管理、用户列表、角色列表等模块直接套用只是字段和API不同。做这套通用模板时有几个值得注意的取舍。搜索条件是否要做成配置化对于中小型项目手写模板比配置化更直观因为配置化意味着抽象层级多了一层接手的人理解成本变高。是否引入状态管理Day1这样的页面完全不需要Pinia或Vuex本地状态足够。等出现跨页面共享筛选条件、跨页面共享选中数据时才考虑引入。接口要不要包一层通用CRUD我见过不少项目封装了自动生成分页查询接口的基类出发点是好但过度抽象会让每个接口都要读一遍基类源码才能理解。建议在Day1阶段先保留显式的SQLAlchemy查询写法等确认业务模式重复出现再做二次封装。最后再分享一个我自己用着很顺的小技巧。写完接口后不要只依赖FastAPI自动生成的/docs页面我习惯用curl命令先直接请求一遍带真实数据的接口快速确认返回字段名、类型、总数字段是否和Schema一致。尤其是create_time字段返回的是带T的ISO格式还是带空格的数据库原生格式前端如果不做格式化显示出来会非常丑。提前用curl发现这些问题比前端联调时一个个找要高效得多。部门列表查询的整个闭环到这就完整走通了从表结构设计、后端接口到前端页面交互再到联调排错覆盖了一个企业级Web模块从0到1的全部关键步骤。如果能在Day1就把这套流程跑顺后续所有类似模块的开发都会快很多。
返回列表