得先声明一下,这篇不是什么高深的理论文章,就是一套我已经落地跑过的“员工管理系统”完整实操记录。业务背景很简单:公司人事还在用Excel管员工档案,部门一多、离职入职一频繁,表格就乱成一锅粥。于是我用Python的Django做核心后端,用Flask补了一个轻量级的辅助服务,前端交给Vue,整个开发过程都在PyCharm里完成的。下面把设计和实现过程完整拆给你看,该给的代码、配置、避坑记录一个都不会少。
1. 先把需求揉碎:员工管理系统到底在管什么
很多新手拿到“员工管理”这个题目就一头扎进代码里,结果做着做着就发现逻辑越搅越乱。我建议你先别碰键盘,找个会议室把人事、行政、部门主管拉过来聊半个钟头,把真实的业务流程画出来。这套系统的需求其实就是这么聊出来的。
当时梳理出来的核心业务点有四个:
- 员工档案管理:员工基本信息、联系方式、身份证号、入职日期、学历、紧急联系人等,必须支持增删改查。
- 部门结构管理:公司有多个部门,还会出现“一部三组”这种层级关系,需要树形结构去维护。
- 离职与异动记录:员工调岗、转正、离职这些状态不能直接硬删数据,要有状态流转和操作留痕。
- 工资信息对接:前端要看某个月各部门的工资汇总,直接让后端算好再返回,而不是把几条明细全部丢给前端去加。
顺着需求往下走,就是数据库表设计。我最终落地的核心表有5张:
| 表名 | 主要字段 | 说明 |
|---|---|---|
| department | id, name, parent_id, manager_id | 部门表,自关联实现树形 |
| employee | id, emp_no, name, gender, phone, id_card, department_id, position, hire_date, status | 员工档案主表,status标记在职/离职/试用 |
| attendance | id, employee_id, work_date, status | 考勤记录表 |
| salary | id, employee_id, month, base_salary, bonus, deduction | 工资明细表,month格式示例:2025-06 |
| user | id, username, password, employee_id, is_superuser | 系统登录账户,与员工一对一关联 |
这里有个小设计经验:员工编号emp_no不要用自增ID直接暴露给前端。我加了一个YYYYMMDD加三位序列号的生成策略,比如“20250615001”,好处是人事部门光看编号就能判断入职批次,而且后续做导入导出时不会和Excel里的数据冲突。
数据库层面,因为项目中有些报表要跨表联查,比如“查每个部门在职人数”,我后来增加了一张department_staff_count的视图。视图在Django里不用去改模型文件,直接用原生SQL建在MySQL里面即可,ORM查询时当普通模型用managed = False映射就行。
2. 技术选型不纠结:Django和Flask是“主角+配角”的关系
看到标题里有“django flask”两个关键词,很多人第一反应是问“到底用哪个”。我的回答是:都用了,但不是平起平坐地各写一半业务。主后端是Django,Flask只在一个独立的小服务里承担活儿。
说下这么设计的理由。员工管理系统本质上是一个“后台管理系统+报表服务”的组合,这类系统最忌讳的就是从零搭造轮子。Django自带Admin后台、ORM、用户认证、中间件、迁移机制,这让我在两周内就把业务骨架搭完了,尤其是Admin界面可以直接给人事部门做数据录入,后期还能用django-simpleui或者django-jazzmin美化,不用自己写一堆后台模板。Flask适合做嵌入式的轻量服务,启动快、依赖少,占用资源低。我就用Flask单独起了两个接口:一是工资Excel导出,二是员工批量导入时的数据清洗。这两个功能单独拆出来之后,Django进程不会因为大文件上传或复杂Excel计算阻塞常规API请求。
雇一整个项目里,整体调用链路是这么串起来的:Vue前端按下按钮,请求打到Django,Django在收到导出请求时,转发给Flask服务,Flask内部用openpyxl拼好Excel文件、返回二进制流,Flow再由Django流回前端。Django扮演了“API网关+业务核心”,Flask扮演了“文件处理工人”。这套组合还有一个好处:Flask服务可以单独部署到另一台机器上,如果后续需要处理更重的Excel计算(比如十万行工资数据),直接给Flask扩容就行,不影响主业务。
如果你只想用一个框架,明确告诉你:核心管理功能完全用Django就能搞定,Flask只是锦上添花。Flask适合的场景是:你已经有一套主业务系统(不一定是Django),但不想为了“导出一个Excel”就引入一整个重型框架。
3. Django后端实操:从建虚拟环境到API接口全流程
3.1 PyCharm里从零初始化项目
我开发时用的是PyCharm Professional,但社区版也完全能跑这套项目,没有哪个功能是专业版独占的。打开PyCharm后,我是这样创建项目的:
- 新建项目,选择Virtualenv环境,Python解释器选系统里已装好的Python 3.10。
- 在Terminal里依次执行命令安装依赖包:
pip install django djangorestframework django-cors-headers mysqlclient openpyxl这里专门提一下mysqlclient。Windows装它经常出幺蛾子,报错多半是缺少VC++编译环境。我现在都是直接下载对应Python版本的whl文件,用pip离线安装,五秒钟搞定,比让pip现场编译省心一百倍。
- 创建Django项目和两个应用:
django-admin startproject employee_system cd employee_system python manage.py startapp employee python manage.py startapp department创建应用这个动作,正常情况下没人废话,但很多初学者会犯一个错:把十个业务模块塞进同一个app里。我建议按业务域拆分,员工相关接口放employee应用,部门相关放department应用,以后做权限控制、代码维护都清晰。
3.2 数据模型设计与迁移
下面直接放出员工主表的模型代码,这是整个系统最核心的一张表:
from django.db import models from department.models import Department class Employee(models.Model): STATUS_CHOICES = ( ('probation', '试用期'), ('active', '在职'), ('resigned', '离职'), ) emp_no = models.CharField(max_length=20, unique=True, verbose_name='员工编号') name = models.CharField(max_length=50, verbose_name='姓名') gender = models.CharField(max_length=10, choices=(('male', '男'), ('female', '女')), verbose_name='性别') phone = models.CharField(max_length=20, blank=True, verbose_name='手机号') id_card = models.CharField(max_length=18, blank=True, verbose_name='身份证号') department = models.ForeignKey(Department, on_delete=models.PROTECT, verbose_name='所属部门') position = models.CharField(max_length=50, blank=True, verbose_name='岗位') hire_date = models.DateField(verbose_name='入职日期') status = models.CharField(max_length=20, choices=STATUS_CHOICES, default='probation', verbose_name='状态') created_at = models.DateTimeField(auto_now_add=True, verbose_name='创建时间') updated_at = models.DateTimeField(auto_now=True, verbose_name='更新时间') class Meta: db_table = 'employee' indexes = [models.Index(fields=['status']), models.Index(fields=['department'])] ordering = ['-hire_date'] def __str__(self): return f'{self.emp_no} {self.name}'设置里我特意加了on_delete=models.PROTECT,意思是如果部门下面还有员工,这个部门就不允许在界面上被删掉。表面上看是给操作添堵,实际上保护了数据的完整性——否则某天管理员手一抖把部门删了,底下几十个员工全变成“无部门”孤儿数据,那才叫灾难。
3.3 DRF视图集与路由
员工管理接口我用的是viewsets.ModelViewSet,因为它的逻辑和RESTful风格完美匹配,增删改查都有现成实现,只要在数据校验上做定制即可:
from rest_framework import viewsets, filters from rest_framework.pagination import PageNumberPagination from .models import Employee from .serializers import EmployeeSerializer class EmployeePagination(PageNumberPagination): page_size = 15 page_size_query_param = 'page_size' max_page_size = 100 class EmployeeViewSet(viewsets.ModelViewSet): queryset = Employee.objects.select_related('department').all() serializer_class = EmployeeSerializer pagination_class = EmployeePagination filter_backends = [filters.SearchFilter, filters.OrderingFilter] search_fields = ['name', 'emp_no', 'phone'] ordering_fields = ['hire_date', 'created_at'] def perform_destroy(self, instance): # 员工离职不等于删除记录,改为软删除 instance.status = 'resigned' instance.save()请注意perform_destroy这个重写。常规做法是直接instance.delete(),但在员工管理系统里,“删除”这个动作是有业务含义的——离职员工的数据要留着,财务以后对账还要翻历史记录。所以我把删除动作改成了状态置为离职。这也是做业务系统时一个非常重要的意识:数据库记录能别硬删就别硬删,加个状态字段比啥都管用。
然后在urls.py里注册:
from rest_framework.routers import DefaultRouter from employee.views import EmployeeViewSet router = DefaultRouter() router.register(r'employees', EmployeeViewSet, basename='employee') urlpatterns = [ path('api/', include(router.urls)), ]这套配置做完,即使不写一行前端代码,Django自带的接口文档就已经能访问了(/api/employees/返回JSON数据)。我开发时习惯先用Postman把接口调通,再去做Vue页面,这样查错范围能缩短一半。
3.4 Django Admin界面美化与数据兜底
再聊一句Admin。系统刚上线那阵子,前端Vue页面还没做完,但人事那边已经急着录入数据了。我直接把模型注册进Admin,再把Admin后台用django-jazzmin换了个皮肤,一个简易后台就能先给业务部门用起来。注册代码很简单:
from django.contrib import admin from .models import Employee @admin.register(Employee) class EmployeeAdmin(admin.ModelAdmin): list_display = ('emp_no', 'name', 'department', 'position', 'status', 'hire_date') list_filter = ('status', 'department') search_fields = ('name', 'emp_no') date_hierarchy = 'hire_date'生命周期里,Admin后台不光是前期补数据的方式,也是后期开发者自查数据库内容的工具箱——查一条脏数据、改一个错误状态,直接进Admin改比连数据库敲SQL快得多。
4. Flask侧翼服务:用openpyxl实现工资Excel导出
4.1 为什么单独拆一个Flask进程
刚开始我的Excel导出功能直接写在Django里,功能没两天就写完了,但发现一个问题:导出工资表时,数据量大一点就会占住Django的worker进程,浏览器等到超时,其他API也卡住。Django同步框架下,这种耗时任务会阻塞请求处理线程。所以我决定把导出任务单独拆出去,用Flask开一个独立服务。
有的朋友会说“你这场景交给Celery异步任务不就行了”。确实是方案之一,但当时后端服务部署在只有2G内存的小ECS上,Celery要额外跑一个worker进程、还要配Redis,复杂度瞬间上去了。对于一个导出功能,Ligrape解决方案——独立Flask服务——部署成本低、责任边界清晰,之后把服务一停,完全不影响主系统,维护起来心理负担小。
4.2 Flask代码实现
from flask import Flask, request, send_file, jsonify from openpyxl import Workbook from openpyxl.styles import Font, PatternFill import io app = Flask(__name__) @app.route('/export/salary', methods=['POST']) def export_salary(): data = request.get_json() month = data.get('month') rows = data.get('rows', []) wb = Workbook() ws = wb.active ws.title = f'{month}工资表' headers = ['工号', '姓名', '部门', '基本工资', '奖金', '扣款', '实发工资'] ws.append(headers) header_font = Font(bold=True) fill = PatternFill(start_color='CCCCFF', end_color='CCCCFF', fill_type='solid') for col_idx, h in enumerate(headers, 1): cell = ws.cell(row=1, column=col_idx) cell.font = header_font cell.fill = fill total = 0.0 for row in rows: actual = round(float(row['base_salary']) + float(row['bonus']) - float(row['deduction']), 2) ws.append([row['emp_no'], row['name'], row['department'], row['base_salary'], row['bonus'], row['deduction'], actual]) total += actual ws.append(['', '', '合计', '', '', '', round(total, 2)]) output = io.BytesIO() wb.save(output) output.seek(0) filename = f'salary_{month}.xlsx' return send_file(output, as_attachment=True, download_name=filename, mimetype='application/vnd.openxmlformats-officedocument.spreadsheetml.sheet') if __name__ == '__main__': app.run(host='0.0.0.0', port=5001)关键点在io.BytesIO。如果你把Excel先存到服务器临时文件再读回来,会产生一堆需要清理的垃圾文件;用BytesIO把文件流直接放在内存里返回,请求结束内存自动释放,干净利落。然后Django端只需要用一个requests.post把数据转给Flask,再把响应流原样返回给前端:
import requests from django.http import HttpResponse def forward_salary_export(request): month = request.GET.get('month') salary_rows = SalaryService.get_rows_by_month(month) resp = requests.post('http://127.0.0.1:5001/export/salary', json={ 'month': month, 'rows': salary_rows, }, timeout=30) file_name = f'salary_{month}.xlsx' response = HttpResponse( resp.content, content_type='application/vnd.openxmlformats-officedocument.spreadsheetml.sheet' ) response['Content-Disposition'] = f'attachment; filename="{file_name}"' return response平时用的时候,Django进程和Flask进程在同一台机器上时,地址就是127.0.0.1:5001。如果哪天Flask服务迁到独立机器,只要把127.0.0.1改成对应内网IP,理论上Django代码一行不用动,服务就解耦了。
5. Vue前端与接口联调:从页面骨架到Token鉴权
5.1 Vue项目初始化与依赖
前端我直接用Vue CLI先搭:
vue create employee-web cd employee-web npm install element-plus axios vue-router@4 npm install -D sass sass-loader装依赖的时候常有人卡在node-sass编译上,我的经验是尽量用sass(dart-sass),它不需要本地编译,兼容性比node-sass好太多。这个坑踩过的人都知道,版本不对直接报错一大片。
组件库选了Element Plus,后台管理界面那一套表格、表单、弹窗、消息提示,开箱即用,开发效率拉满。
5.2 路由与页面骨架
员工管理主页面我用el-card加el-table组合,左侧放部门树,右侧放员工表格。部门树要展开,设计上用了el-tree,数据接口是后端返回的列表,前端JS手动组装成树形:
function buildTree(list, parentId = null) { const tree = [] list.forEach(item => { if (item.parent_id === parentId) { const children = buildTree(list, item.id) if (children.length) { item.children = children } tree.push(item) } }) return tree }这块逻辑堪称面试精典——树形数据组装。对于部门层级不多、最多三四层的企业场景来说,前端组装足够,后端没必要专门递归一次。
员工表格列配置关键代码如下:
<el-table :data="employeeList" v-loading="loading" border stripe> <el-table-column prop="emp_no" label="员工编号" width="130" /> <el-table-column prop="name" label="姓名" width="100" /> <el-table-column prop="department.name" label="部门" width="140" /> <el-table-column prop="position" label="岗位" width="120" /> <el-table-column prop="status" label="状态" width="100"> <template #default="scope"> <el-tag :type="statusType(scope.row.status)"> {{ statusText(scope.row.status) }} </el-tag> </template> </el-table-column> <el-table-column prop="hire_date" label="入职日期" width="120" /> <el-table-column label="操作" min-width="200" fixed="right"> <template #default="scope"> <el-button size="small" @click="openEdit(scope.row)">编辑</el-button> <el-button size="small" type="danger" @click="resign(scope.row)">离职</el-button> </template> </el-table-column> </el-table>注意这里的prop="department.name",它依赖后端序列化器把关联部门信息嵌套返回,不然表格里只能显示一个部门ID,毫无意义。所以序列化器里要加上department = DepartmentSerializer(read_only=True)。
5.3 登录授权与Axios请求封装
系统的登录逻辑是:用户在登录页输入用户名密码,Vue请求/api/auth/login/,Django用DRF自带的TokenAuthentication签发token,Vue把token存在localStorage里。之后每个请求都在拦截器里把token塞进请求头:
axios.interceptors.request.use(config => { const token = localStorage.getItem('token') if (token) { config.headers.Authorization = `Token ${token}` } return config }) axios.interceptors.response.use( response => response, error => { if (error.response && error.response.status === 401) { router.push('/login') localStorage.removeItem('token') } return Promise.reject(error) } )判断是否登录成功,前端只认HTTP状态码200和返回体里的token字段;如果401,直接踢回登录页。这算是后台系统最基础也最必要的防御手段,没有token校验的系统等于把接口裸奔在公网上,任何人拿Postman都能删数据。
5.4 Vue构建与本地联调的CORS问题
联调阶段最容易出的问题就是跨域。开发环境我用Vite的proxy代理,生产环境用Nginx反代。配置在vue.config.js:
module.exports = { devServer: { proxy: { '/api': { target: 'http://127.0.0.1:8000', changeOrigin: true } } } }这比在Django里配django-cors-headers省心,因为生产环境走同域(Nginx把/api转给Django),开发环境用代理,两边都不用担心cookie和CORS的坑。不过需要注意,Django的ALLOWED_HOSTS里如果配置的是localhost,而Vue代理转发请求头里的Host是localhost:8000,一般没事,但如果你后面前端部署域名和Django域名不一样,必须把域名加进ALLOWED_HOSTS,否则Django直接拒绝访问。
6. PyCharm配置与全流程踩坑实录
6.1 PyCharm里的环境配置
下面这些操作是我在另一台全新电脑上重装环境时走了一遍的流程,照着做能少踩不少坑。
用PyCharm打开项目之后,第一件事是配置解释器。如果直接用系统Python,很容易装一堆依赖污染全局环境。我现在一律用PyCharm自带的Virtualenv创建方式:Settings -> Project -> Python Interpreter -> Add Interpreter -> Virtualenv Environment -> New。
创建好虚拟环境后,把Django项目的运行配置加一下:Run -> Edit Configurations -> 新增“Django Server”配置,在Parameters里写runserver 0.0.0.0:8000,这样局域网内手机和同事电脑都能通过IP访问。很多人的PyCharm只写runserver,默认只监听127.0.0.1,手机永远没法调试。
6.2 高频踩坑点与解决方法
列几个这套系统中真正绊倒过我的问题,按频率排序:
| 问题 | 表现 | 解决方法 |
|---|---|---|
| mysqlclient安装失败 | pip install报错error: Microsoft C++ Build Tools | 下载whl离线安装 |
| CORS跨域 | 浏览器请求报blocked by CORS | devServer代理或Nginx同域反代 |
| 时区数据混乱 | 入职日期总差一天 | Django设置USE_TZ = False,时间字段统一用DATE |
| Vue打包后路由刷新404 | 直接访问子路径报404 | Nginx配置try_files $uri $uri/ /index.html; |
| FileField上传后图片不显示 | 图片路径404 | settings.py配置MEDIA_URL和MEDIA_ROOT,Nginx加静态路由 |
其中“Vue打包后路由刷新404”这个问题,第一次部署时让我排查了整整一个下午。原因是Vue Router用的history模式,刷新/employee这个地址时,Nginx不知道去哪找对应的页面文件,于是直接404。解决办法是让Nginx把所有非静态资源的请求都回退到index.html:
location / { root /usr/share/nginx/html/employee-web; index index.html; try_files $uri $uri/ /index.html; } location /api { proxy_pass http://127.0.0.1:8000; proxy_set_header Host $host; proxy_set_header X-Real-IP $remote_addr; }请记住这个经典配置。很多后台管理系统上线后“整个页面都能打开,一刷新就白屏”的诡异问题,基本都是这个导致的。还有一种情况是无意中让Vue的base路径配置错误,如果你把前端部署在域名子路径下(比如/admin/),vue.config.js里要同步设置publicPath: '/admin/'。
6.3 员工导入时的数据清洗工具
员工批量导入功能我放在了Flask服务里,因为数据清洗逻辑作为独立工具更清晰。平时上传一个几百行的Excel,里面姓名前后带着空格、手机号格式不统一、部门名称和系统里对不上。Flask服务这端先把Excel逐行读出来,做标准化后再调Django的导入API。
具体实现思路是这样的:Excel传入后,先用openpyxl读取每行数据,姓名用strip()去掉空格,手机号用正则1[3-9]\d{9}校验,部门名称去系统部门表里做精确匹配,匹配不到的部门先给标记为“待确认部门”,再返回一份清洗报告。整个过程不修改真实数据,报告确认无误后才真正导入数据库。
这段逻辑如果写在Django主服务里也能实现,但当时考虑到它可能要处理大文件和复杂循环,不想影响主API响应。放在Flask里,主服务挂了Flask还在跑,数据清洗还能继续用,两个进程互不干扰,维护起来边界清楚。
7. 一次真实上线事故复盘:从卡顿到优化
项目上线首周,人事反馈“导出工资表特别慢”,刚开始没当回事,想着几百行数据能慢到哪去。后来自己亲自测了一次,发现导出一次需要将近6秒,确实到了让人崩溃的程度。
定位过程大概分了四步。第一步,先确认慢在前端还是后端。打开浏览器开发者工具,发现等待接口返回的时间占了大头,基本可以排除前端渲染慢。第二步,看Django日志,发现导出工资时Django端所有接口的响应都变慢了,说明是Django进程被导出任务占住。第三步,把数据量放大到全公司500人试了一下,Django端光是构造JSON再转交Flask就要处理差不多2秒。第四步,排查是慢在数据库还是慢在网络,给SQL加上EXPLAIN一看,问题出在salary表按month字段范围查询时没用上索引,数据库全表扫了500条数据居然扫了很久。
优化办法很简单,给salary表加上组合索引(month, employee_id)。加完之后导出耗时从6秒降到了1秒以内。说实话这个优化根本不复杂,但它提醒了我:当一个系统开始卡,先别怀疑框架,先查数据库有没有走索引,查接口有没有N+1查询,这两个地方通常是绝大多数性能问题的根源。
8. 最后分享两个提升幸福感的小配置
折腾完这套系统之后,有几个小而美的配置我强烈建议你顺手加上,能省后面很多事。
一个是Git版本管理。刚建项目时就把.gitignore写好,把venv/、node_modules/、__pycache__/、*.pyc、db.sqlite3全排除掉,不然每次提交代码几百MB的依赖包一起传上去,仓库直接废掉。
另一个是Django项目里的环境变量管理。不要直接把数据库密码、SECRET_KEY硬编码在settings.py里,用python-dotenv读取.env文件。这样项目传到Git或者发给同事时,.env不进版本库,数据库密码不会泄露,换环境也只要改一个文件。配合PyCharm的EnvFile插件,本地调试时自动加载.env里的变量,特别顺手。
这套员工管理系统的技术栈和代码算不上什么黑科技,但它是那种“很落地、能治病”的项目。你拿它当毕设、当公司内部小工具、当练手项目都合适,核心的Django建模、DRF接口、Vue联调、Flask旁路服务,整条链路走一遍之后,你会发现“前后端分离”这几个字不再是个概念,而是你脑子里清清楚楚的一张地图。