作为一个前后端都写、常年拿Python和Vue组合做内部管理系统的老手,这套公司停车管理系统其实算得上是一个特别典型的“全栈练手+实用落地”项目。它既有清晰的后端业务逻辑(车辆入场、出场计费、车位状态流转),又有前端需要的可视化交互(车位地图、预约、记录查询),还牵扯到权限、并发、部署等一系列真实开发里躲不开的问题。
这篇文章我会从项目整体的需求和选型拆起,然后给出一套可以直接复制的数据库设计方案、Django后端接口实现、Vue前端页面对接方式,最后重点讲讲我在实际联调和部署时踩过、也替你们排掉的那些坑。无论你是打算用PyCharm从零写一个毕设,还是给公司行政部做一个能用的内部工具,这套思路都适用。
1. 项目整体设计与思路拆解
1.1 先搞清楚停车系统到底要管什么
我在动手前先不急着建项目,而是去公司停车场转了一圈,又把行政部负责车位的同事拉过来聊了半小时。很多新手写管理系统容易陷入“堆功能”的误区,其实公司内部停车系统的核心痛点就那么几个:
- 车位总数有限,谁占了哪个位置,管理员基本靠记忆,经常出现两辆车抢一个车位。
- 外部访客车辆或临时车辆混入,出场时说不清停了多久,收费靠估。
- 月底对账困难,想统计每辆车停了多少次、总时长、总费用,Excel手工整理费时费力。
- 员工想提前看还有没有车位,不想开到地库才发现满了。
所以我最终把需求收敛成三个角色、五个核心功能:
管理员端负责维护车位、查看实时占用、处理异常记录;员工端能看车位状态、预约空闲车位、查询自己的停车历史;访客车辆则走临停登记和出场计费。围绕这些需求,再去设计数据模型和接口,整个项目就不会跑偏。
这类项目的通用做法是:后台管理用Django或Flask提供REST接口,前端用Vue做单页应用,数据库用MySQL或SQLite。我这边最终选的是Django + Vue + MySQL的组合,原因后面说,但如果你更想用Flask,我也会在关键节点标出两个框架的写法差异。
1.2 技术选型:Django和Flask到底怎么选
标题里同时出现了Django和Flask,很多刚接触的人会纠结。我的建议是:先看你的业务复杂度,再决定框架。
| 对比维度 | Django | Flask |
|---|---|---|
| 项目结构 | 自带app划分,结构统一,适合中大型项目 | 灵活自由,一个文件就能起服务,适合小原型 |
| ORM模型 | 自带ORM,迁移工具完善 | 需要搭配SQLAlchemy或直接写SQL |
| 自带后台 | 自带Admin管理界面,省很多事 | 无后台,需要自己拼 |
| 认证权限 | 内置User模型和认证体系 | 需要第三方扩展或手写 |
| 学习曲线 | 稍陡,概念多但规范 | 平缓,入门快 |
| 适合场景 | 有前后台、多表关联、权限复杂的业务系统 | API服务、微服务、快速验证想法 |
停车管理系统虽然看着不大,但牵扯到用户、车位、停车记录、计费规则、预约关系,还分管理员和普通员工权限,用Django那一套成熟的app结构会顺手很多。自带ORM能省掉大量手写SQL的麻烦,Admin后台甚至可以在前端页面没做出来之前,先帮管理员录入车位数据。所以最终正式版本我用了Django,但在文中我也会给出Flask下同样接口的简化写法,方便那些更熟悉Flask的朋友参考。
开发环境我统一用的PyCharm,专业版社区版都能跑,创建项目时记得选好虚拟环境解释器,后面跑Django命令和Vue代理都会舒服很多。
2. 数据库设计:把停车这件事建模
2.1 三张核心表和一对扩展表
停车系统的数据库设计不需要太炫技,但一定要把关系理清楚。我最终保留了这样几张表:
User:直接继承Django自带的AbstractUser,再加一个phone字段,用来区分管理员和普通员工。ParkingLot:停车场表,记录停车场名称、总车位数、地址。如果公司只有一个停车场,这表可以省略,但为了以后扩充分公司场景,我建议保留。ParkingSpace:车位表,核心字段是车位编号、所属停车场、车位类型(普通/充电/无障碍)、当前状态(空闲/占用/维护中)、每小时收费标准。ParkingRecord:停车记录表,核心字段是关联的车辆和车位、入场时间、出场时间、应收费用、计费状态。Reservation:预约表,记录员工预约的车位、预约时间段、状态。
以下是简化后的Django模型定义,方便你直接抄进项目里:
from django.db import models from django.contrib.auth.models import AbstractUser class User(AbstractUser): phone = models.CharField(max_length=20, blank=True) is_admin = models.BooleanField(default=False) class Meta: db_table = 'sys_user' class ParkingLot(models.Model): name = models.CharField(max_length=50) address = models.CharField(max_length=255, blank=True) total_spaces = models.IntegerField(default=0) class ParkingSpace(models.Model): STATUS_CHOICES = ( ('free', '空闲'), ('occupied', '占用'), ('maintenance', '维护中'), ) space_no = models.CharField(max_length=20, unique=True) lot = models.ForeignKey(ParkingLot, on_delete=models.CASCADE) space_type = models.CharField(max_length=20, default='normal') status = models.CharField(max_length=20, choices=STATUS_CHOICES, default='free') hourly_rate = models.DecimalField(max_digits=6, decimal_places=2, default=5.00) class ParkingRecord(models.Model): license_plate = models.CharField(max_length=20, db_index=True) user = models.ForeignKey(User, null=True, blank=True, on_delete=models.SET_NULL) space = models.ForeignKey(ParkingSpace, on_delete=models.PROTECT) entry_time = models.DateTimeField() exit_time = models.DateTimeField(null=True, blank=True) amount = models.DecimalField(max_digits=8, decimal_places=2, default=0) is_finished = models.BooleanField(default=False)有几个设计细节值得展开说:license_plate加db_index是因为查询某辆车的历史记录非常频繁,索引能明显提升速度;amount用DecimalField而不是FloatField,因为金额计算涉及精度,Float的二进制浮点误差在累计对账时会出大问题;ParkingRecord关联车位用PROTECT而不是CASCADE,避免误删车位时把历史收费记录一起删光。
2.2 车位状态流转和计费参数
车位本身是一个有限状态机:空闲(free)→ 占用(occupied)→ 空闲(free),中间还有一个维护中(maintenance)状态用于管理员暂时锁定损坏车位。入场时把车位从free改成occupied,出场时改回free,这两步操作必须放在同一事务里,避免出现车都出去了车位还显示占用的情况。
计费参数我建议不要写死在代码里,而是放到车位表或一张独立的配置表里。每家公司的规则差异很大,常见计费模式有这么几种:
- 按小时计费,不足一小时按一小时算。
- 前30分钟免费,超出后按小时累计。
- 单日封顶价格,比如24小时内最多收30元。
- 员工车辆免费,访客车辆收费。
我实现的版本把hourly_rate放到了车位上,同时预留了一个max_daily字段。出场结算时先判断该车辆是否绑定公司员工,绑定员工就不算钱,只记录时长;访客车辆再套用计费公式:
import math from decimal import Decimal from django.utils import timezone def calc_fee(record, max_daily=None): if not record.exit_time: record.exit_time = timezone.now() duration_minutes = (record.exit_time - record.entry_time).total_seconds() / 60 if duration_minutes <= 30: return Decimal('0.00') hours = math.ceil(duration_minutes / 60) fee = Decimal(hours) * record.space.hourly_rate if max_daily: fee = min(fee, Decimal(str(max_daily))) return fee这里math.ceil用来实现“不足一小时按一小时”,这个需求我在实际对接行政部时被明确要求过,别看逻辑简单,不少开源项目里反而忽略了。
3. 后端接口与核心业务实现
3.1 用DRF还是手写JSONResponse
Django写接口有两种常见姿势:直接返回JsonResponse,或者引入django-rest-framework。我个人的结论是:只要项目超过三个接口,就老老实实用DRF。手写JsonResponse的坑在于序列化和反序列化都要自己处理,请求参数校验也得手写try-except,项目一旦膨胀就变成一团乱麻。
DRF自带的ModelViewSet配合Router,几行代码就能把车位表的增删改查暴露成标准REST接口。对于停车记录这种只读场景,用ReadOnlyModelViewSet会更安全,避免有人拿POST往记录表里乱塞数据。
接口列表我大概规划成了这样:
| 方法 | 路由 | 功能说明 |
|---|---|---|
| POST | /api/auth/login/ | 登录获取Token |
| GET | /api/spaces/ | 获取车位列表和状态 |
| POST | /api/records/entry/ | 车辆入场登记 |
| POST | /api/records/exit/ | 车辆出场结算 |
| GET | /api/records/?plate=xxx | 按车牌查询历史记录 |
| POST | /api/reservations/ | 预约车位 |
| GET | /api/stats/overview/ | 统计总车位、占用率、今日收入 |
3.2 入场和出场:事务与并发的关键
入场登记的逻辑看起来简单,但实际写起来要小心并发问题。假设两个员工同时看到同一个空闲车位,各自刷卡入场,如果不加锁,两条入场记录都成功,车位就被占用了两次。
我在Django里的处理方式是通过select_for_update()锁定车位行,确保同一时间只有一个请求能改这个车位的状态:
from django.db import transaction from rest_framework.decorators import api_view from rest_framework.response import Response @api_view(['POST']) @transaction.atomic def vehicle_entry(request): space_id = request.data.get('space_id') plate = request.data.get('license_plate') space = ParkingSpace.objects.select_for_update().get(id=space_id) if space.status != 'free': return Response({'message': '该车位已被占用'}, status=400) space.status = 'occupied' space.save() record = ParkingRecord.objects.create( license_plate=plate, user=request.user if request.user.is_authenticated else None, space=space, entry_time=timezone.now() ) return Response({'record_id': record.id})select_for_update()必须在事务内使用,所以这里用@transaction.atomic包住了整个视图函数。实际生产环境里如果数据库是MySQL,还要注意表引擎必须是InnoDB才支持行锁,MyISAM是不行的。
出场结算类似,先把记录查出来,计算费用,更新车位状态和记录字段,全部放在一个事务里。如果遇到停车场断电导致出场中断,可以在管理端加一个“手动补录出场”的功能,用record_id反查原始记录,避免重复计费。
3.3 Django执行查询与删除对象
我在调试时经常需要处理停车记录和车位的清理工作,这也是新人最容易翻车的地方。Django删除对象有对象级和查询集级两种方式,别看它们都能删,行为有本质差别。
对象级删除是record.delete(),会触发Django的信号和级联操作,适合单条删除。查询集删除是ParkingRecord.objects.filter(entry_time__lt=some_date).delete(),这种方式不会调用每个对象的delete()方法,但会收集所有受影响对象然后批量执行SQL DELETE,效率高,适合清理历史数据。
我踩过的坑是:想软删除却用了硬删除。停车记录往往涉及财务对账,直接物理删除后数据找不回来,所以我最后在ParkingRecord表上加了一个is_deleted布尔字段,查询时默认过滤掉,管理员在后台还能看到被删记录。对外接口一律走“假删除”,只有超管能真正物理清理旧数据。
3.4 实时推送车位状态:WebSocket的取舍
前端停车总览页面如果靠手动刷新看车位状态,体验会很差。热词里也提到了“Django WebSocket实现后台有数据前端推送”,这确实是最优雅的方案。
Django本身不支持异步长连接,需要引入channels组件,把项目从WSGI模式切换成ASGI模式。我简化后的实现思路是:在ParkingRecord模型保存后,通过channels的channel_layer把最新车位状态广播到指定组,Vue前端通过WebSocket订阅这个组,收到消息后直接更新本地状态。
不过这里我要给个实用建议:如果公司内网机器不多,纯轮询其实也够用。我当时先用Vue每5秒请求一次/api/spaces/,后来才升级成WebSocket。轮询的好处是代码简单、调试方便、不需要额外维护长连接;坏处是频繁请求有资源浪费,但内网系统完全扛得住。如果你是想学习WebSocket,那建议走channels方案,但需要接受部署时要多配一层ASGI服务的事实。
我最终的生产版本是折中方案:前端默认轮询,同时预留WebSocket通道用在“预约成功”的消息提醒上,因为这类消息实时性要求更高。
4. 用Vue搭建前端页面
4.1 环境配置和项目初始化
前端我使用Vue 3 + Vite + Element Plus这套组合。Node环境和Vue CLI的安装卡过很多人,我的建议是:Node直接去官网下载LTS版本,一路下一步装好;然后npm设置镜像源;最后用npm create vite@latest初始化项目,选择Vue模板即可。PyCharm里直接Open这个前端目录,也能识别到Node解释器,调试起来非常方便。
项目里我习惯建一个src/api/request.js做axios封装,统一写入baseURL和Token:
import axios from 'axios' const request = axios.create({ baseURL: '/api', timeout: 10000 }) request.interceptors.request.use(config => { const token = localStorage.getItem('token') if (token) { config.headers.Authorization = `Token ${token}` } return config }) export default requestbaseURL设为/api之后,开发环境通过Vite的proxy把请求转发到Django后端,生产环境则由Nginx统一处理转发,前端代码里不需要写死后端IP,部署时换环境很方便。
4.2 停车总览页:地图从哪儿来
总览页是停车系统的门面,也是我认为这个项目最出效果的部分。我设计了三种可视化方式:
- 列表模式:只显示车位号、区域、状态、车牌号,最朴素但信息最全。
- 网格地图模式:用一个静态SVG模拟停车场的车位布局,空闲车位显示绿色,占用显示红色,点击车位能弹出操作面板。
- 地图服务模式:用腾讯地图或高德地图的JavaScript API,在地图上打标记点表示车位。
实际项目里我选了网格地图模式,原因很现实:内网环境不一定能访问外网地图资源,而且公司地库的车位位置用自定义SVG反而更直观。如果你确实想用地图服务,腾讯地图的JS SDK对Vue比较友好,注册key之后引入JSONP组件即可,注意离线部署时你需要把相关JS文件一并打包。
4.3 预约车位和路由传参
预约车位的流程是:员工在总览页看到空闲车位,点击“预约”,跳转到预约确认页,这时要把spaceId和spaceNo带到新页面去。
Vue路由传参有两种方式,query和params。我的建议是优先用query,因为刷新页面后参数还保留在URL里,刷新不丢数据;params传参刷新后容易失效,除非配上props和动态路由。实际操作中我第一次就用params踩了刷新后参数丢失的坑,后来全部改成query:
router.push({ path: '/reserve', query: { spaceId: space.id, spaceNo: space.space_no } })在预约页面的onMounted里从route.query取出参数,填充到表单里,员工只需要选择预约时间段和车牌号即可。
4.4 登录页和权限拦截
前端路由守卫是必须做的。我在main.js或路由配置里加一个beforeEach钩子,先判断本地有没有Token,没有就跳转到登录页;有Token再判断目标路由是否需要管理员权限,然后根据userInfo.is_admin决定放行还是拦截。
router.beforeEach((to, from, next) => { const token = localStorage.getItem('token') if (!token && to.meta.public !== true) { next('/login') } else { next() } })登录接口返回的Token我用的Django REST framework自带的Token认证,简单够用,不需要上JWT这么复杂。员工信息里的is_admin字段在登录时一并返回,存在localStorage或Pinia里,供角色判断使用。
5. 联调踩坑与部署实录
5.1 跨域问题:开发环境用代理,别直接开CORS
联调时第一个碰到的就是跨域。前端跑在Vite的5173端口,后端Django跑在8000端口,浏览器认为这是两个源,直接访问会被拦截。
网上很多教程会让你装django-cors-headers然后把CORS_ALLOW_ALL_ORIGINS = True一开,图省事。我的建议是:开发时开一个Vite proxy就够了,不用动后端CORS配置。在vite.config.js里这样写:
server: { proxy: { '/api': { target: 'http://localhost:8000', changeOrigin: true } } }这样前端请求/api/spaces/时,Vite开发服务器会把请求转发给Django,浏览器层面始终认为请求是同源的,不需要后端开启跨域。生产环境同理,Nginx的反代也承担这个职责。
5.2 时区问题:停车场凌晨时段怎么算
停车计费最容易被忽略的是时区问题。Django默认的USE_TZ = True会让时间字段以UTC格式存库。你本来想把晚上8点存进去,结果从前端传过来的ISO字符串被解释成了UTC时间,数据库里存的是凌晨,显示出来正确,但计算时长就全乱了。
解决方式是:前端传本地时间字符串,后端在入口统一用timezone.localtime()处理展示;计算时长时确保两个时间对象都是带时区的aware对象。我在项目里专门写了一个parse_local_time工具函数,所有接口的时间入参都经过它转换。
另外,跨天的停车记录对“日封顶”费用的计算有影响。我的规则是看入场时间所在的自然日,超出凌晨24点重新计费,这个边界要和行政部确认清楚再写代码,规则不同会导致对账差钱。
5.3 并发抢车位:从锁到唯一约束
虽然前面用了select_for_update(),但总有极端情况。比如两个请求同时进入,A拿到了锁,改完状态提交,B本来在等待锁,A提交后B拿到锁再读一次状态,这时已经变成occupied,就被拦截了。这个流程没问题。
但还有一种并发场景发生在“预约后入场”:员工A预约了车位,员工B直接开车入场也扫了这个车位。预约状态和实际状态是两套数据,容易不一致。我最后在预约表上加了一个唯一约束,同一车位在同一时间段只能有一条有效预约,同时在入场接口里额外检查“该车位当前是否被预约占用”,如果存在未过期的预约且不是本人到场,就直接拒绝。
5.4 Django和Flask的部署差异
部署时有人会问,如果改用Flask是不是更容易?其实两者部署思路差不多:Django用uwsgi或gunicorn启动,Flask也用gunicorn启动,前面再挂一层Nginx处理静态文件和反向代理。区别在于Django的迁移文件要在生产环境跑一遍python manage.py migrate,而Flask如果用SQLAlchemy,也需要执行对应的建表语句。
我上线时用的一键部署脚本大概是这样的:
cd /opt/parking-backend source venv/bin/activate pip install -r requirements.txt python manage.py migrate python manage.py collectstatic --noinput gunicorn -w 3 -b 127.0.0.1:8000 parking_project.wsgi:applicationNginx里把/api开头的请求转发到8000端口,把前端构建后的dist目录作为静态文件根目录,这样访问根路径就是Vue页面,请求/api就是后端服务,一个域名全搞定。
前端构建时有一个坑:Vue Router如果用了history模式,Nginx需要加一个try_files配置,否则刷新页面就404:
location / { root /opt/parking-frontend/dist; try_files $uri $uri/ /index.html; }这句话的意思是把所有用户请求都先尝试找真实文件,找不到就返回index.html,让Vue Router接管路由。
5.5 PyCharm下的调试技巧
最后补一点PyCharm的使用心得。这个项目的开发我基本都在PyCharm里完成,有三件事非常提升效率:
用“Run Configuration”启动Django项目,而不是每次手敲python manage.py runserver。在配置里设置好环境变量DEBUG=True、数据库连接信息,启动后可以直接打断点调试。
断点调试时要注意DRF的请求对象在request.data里,而不是普通POST参数字典。想看前端到底传了什么,在视图函数第一行打一个断点,展开request.data一目了然。
写一个简单的API测试脚本,用requests库模拟前端的所有接口调用,每次改完后端不用开浏览器点页面,直接跑脚本看返回,特别适合验证计费逻辑。测试脚本本身也放在项目里,以后回归测试还能用。
最后分享两个小技巧
这个项目做完之后最让我受益的不是技术栈有多高级,而是发现了“设计先行”的重要性。前面花了一天时间梳理状态流转和计费规则,后面写代码几乎是流水线作业;相反,有几个没想清楚的细节比如“员工车要不要收费”“跨天怎么算封顶”,是在开发中途才去问行政部的,结果返工了两轮。
另一个小技巧是:前端总览页的自动刷新别用整页刷新,而是只更新状态数据。我用Pinia存车位列表,定时器里调用接口拉最新数据后替换数组,配合CSS过渡动画,页面看起来就是实时在跳动,但后端压力很小。
这套系统的下一步我打算加入车牌识别摄像头的对接,同时把预约提醒接到企业微信机器人上。停车管理虽然是个小场景,但把“设计模型、并发控制、权限分离、前后端联调、部署上线”这一整条链路完整走一遍后,你再去写任何内部管理系统,都会思路清晰很多。