1. 为什么是Python+Vue:航司管理系统的需求拆解与选型逻辑
1.1 先从业务说起:航司管理网站到底要管什么
接到这个项目的时候,需求方给我的描述其实很简单:要一个航空公司管理网站,能管航班、管乘客、管订单,最好还能看几眼报表。但"简单"背后涉及的东西一点都不少。我习惯先把业务对象捋清楚再谈技术,因为后面建表、写接口、画页面全都要靠这份清单。
一个典型的航空公司管理系统,核心业务对象大致是这几类:
- 航班信息:航班号、起降机场、起飞到达时间、机型、票价、舱位余量、航班状态(计划、起飞、到达、取消);
- 乘客信息:姓名、证件号码、联系电话,必要时还要区分乘客类型(成人/儿童);
- 订单信息:订单号、所属航班、乘客列表、座位等级、实付金额、订单状态(已支付、已出票、已退票);
- 基础数据:机场信息、机型信息、航线信息;
- 统计报表:上座率、每日营收、热门航线排行等。
从使用角色上看,这类网站通常分两类:一类是给普通用户查航班、订票用的门户;另一类是给航司内部员工做运营管理的后台。项目标题里写的是"管理网站",所以我默认以内部管理系统为主,兼顾查询和订票功能来设计。如果你拿到的是课程设计或毕设题目,这样做也最稳妥——业务面覆盖广,技术上该练的都练到了。
有了这份业务清单,再回头看技术选型,思路就会清楚很多:这套系统表多、关联复杂、后台管理操作频繁,还要有统计报表。这不是一个几十行的脚本,而是一个需要长期维护的中型Web系统,选型必须围绕"快速搭建、稳定迭代、容易招人接手"这几个关键词展开。
1.2 Django和Flask之争:这次我为什么选Django
项目标题里同时出现了Django和Flask,不少人第一反应是"这俩到底用哪个?"我的答案是:做这类重后台、多表关联的管理系统,默认走Django;如果项目只是轻量API服务,再考虑Flask。这不是说Flask不好,而是两者定位不同,硬要互相替换只会让自己多写一堆本来不用写的代码。
先看一组直观对比:
| 维度 | Django | Flask |
|---|---|---|
| ORM | 自带,且支持关系映射、迁移、查询集懒加载 | 不自带,通常配Flask-SQLAlchemy |
| Admin后台 | 自带,注册模型即可生成可用的管理界面 | 没有现成方案,需要自己写或用第三方插件 |
| 用户认证 | 自带User模型与权限体系 | 需要配合Flask-Login、PyJWT等自行组装 |
| 数据库迁移 | 内置makemigrations/migrate | 需要Flask-Migrate |
| 学习曲线 | 略陡,但体系完整 | 平坦,但坑要自己填 |
| 适合场景 | 后台管理、内容系统、业务复杂的Web应用 | 微服务接口、简单原型、定制化高的项目 |
就这个航司项目来说,Django的ORM能让我用很少的代码完成航班、订单、乘客之间的复杂关联查询;Django Admin在开发阶段直接帮我顶了一个"简易后台",验收演示的时候也方便;用户登录和Token认证用simplejwt一接就行。整套东西是"电池齐全"的,不用到处找轮子。
那Flask在这篇文章里还提不提?提,而且要重点说。很多课程设计或者个人项目的题目习惯写成"Python+Flask",是因为Flask上手快、代码量少,适合答辩演示。我的处理方式是:主体用Django实现,但搞清楚"如果换成Flask,同一个业务分布要怎么改写"。这样既拿到了Django开发效率的红利,也保留了Flask方案的对照参考。第三节和第五节我会各留一段讲这个问题。
1.3 Vue在这套系统里解决什么问题
后端定了Python,前端选Vue算是一个很自然的决定。Vue在国内社区活跃、中文资料多,配Element Plus组件库之后,后台管理类界面的开发速度非常快。
具体到航司管理系统,Vue解决的核心问题有三个:
第一,交互体验。传统Django模板渲染是整页刷新,操作一下航班列表就要重新加载整个页面。Vue做成单页应用(SPA)后,切换路由只是局部刷新组件,体感上流畅一大截,而且查航班、筛选日期这类高频操作再也不用等白屏。
第二,组件复用。航班搜索框、分页表格、订单状态标签,这些在管理系统里到处出现。Vue把每个模块拆成组件,写一次到处用,后期改样式只改一处,比复制粘贴模板舒服得多。
第三,前后端解耦。后端只输出JSON接口,前端只管渲染页面,两边只要把接口约定好,可以并行开发。我在这个项目里就是先定接口文档,后端撸Django,前端同时搭组件,最后联调一天就收工。
可能有人问:Vue项目本身要在Node环境跑,PyCharm对这种前后端混合项目支持好吗?这个我在下一节详细说,因为初始化环节确实藏了几个坑。
2. 初始化细节:PyCharm虚拟环境、Django项目结构与应用划分
2.1 PyCharm里创建项目的三个易错点
先声明一下,我用的PyCharm Professional版,但下面这套流程换成社区版也能走通,只是有些前端插件和数据库工具没有,不影响核心开发。
**第一个易错点:虚拟环境没选对。**很多新手在PyCharm里直接新建项目,解释器默认选了全局的Python,后续用pip装Django装到了全局环境里。结果就是多个项目互相污染,今天装的包明天另一个项目也用得上,再改天一个项目升级包,另一个项目就莫名其妙的跑不起来了。正确做法是:创建项目时选择"New environment using Virtualenv",指定一个专用的Python解释器版本(这个项目我用的是Python 3.10,兼容性最稳),后面所有依赖都装进这个虚拟环境。
**第二个易错点:用IDE向导创建Django项目不如命令行干净。**PyCharm的Django项目向导不是不好,而是它会产生一些IDE特有的配置文件,初学者容易搞不清哪些文件是项目的、哪些是工具的。我习惯直接在Terminal里操作:
mkdir airline_management cd airline_management python -m venv venv # Windows激活方式: venv\Scripts\activate # Linux/macOS激活方式: source venv/bin/activate pip install django djangorestframework django-cors-headers djangorestframework-simplejwt django-admin startproject config .项目目录放到上一级,生成的结构是这样:
airline_management/ ├── config/ # 项目配置文件目录 │ ├── settings.py │ ├── urls.py │ ├── asgi.py │ └── wsgi.py ├── manage.py ├── venv/用config而不是airline_management当项目配置目录名,是我个人习惯。因为项目根目录本身已经叫airline_management了,再嵌套一层同名目录,后面import路径容易绕晕。
**第三个易错点:数据库驱动问题。**如果计划开发阶段就上MySQL,Windows用户大概率会在安装mysqlclient时卡住,需要装一堆编译工具或者下载预编译whl包,非常劝退。我的建议是开发阶段先用默认的SQLite,模型设计、接口调试全都跑通之后,部署前再切换MySQL。SQLite和MySQL在Django里的切换成本很低,只需改settings里的DATABASES配置和后端驱动依赖,业务代码完全不用动。
2.2 Django项目结构与应用划分
Django的哲学是一个项目包含多个应用(app),每个app负责一块独立业务。我在这个航司系统里划分了四个app,边界清清楚楚:
python manage.py startapp flights python manage.py startapp orders python manage.py startapp users python manage.py startapp stats- flights:航班、机型、机场。负责航班查询、航班增删改查、航班状态维护;
- orders:订单、乘客。负责订票、退票、订单列表与详情;
- users:用户登录认证。可以扩展员工信息、角色权限;
- stats:聚合统计报表,只读接口,服务前端图表。
这四个app划分的逻辑是"高内聚低耦合"。flights不依赖orders,orders里的外键引用flights的模型;users独立;stats只读其他app的数据做聚合。这样一个人开发也好,多人协作也好,改一个模块不容易误伤另一个模块。
创建完app后,别忘了去config/settings.py的INSTALLED_APPS里注册,同时顺手把DRF、corsheaders也加进去:
INSTALLED_APPS = [ # Django自带app,略 "rest_framework", "rest_framework_simplejwt", "corsheaders", "flights", "orders", "users", "stats", ]另外,settings里有几个关键点:时区要设成Asia/Shanghai、USE_TZ保持True(这个组合的含义后面有专门的坑讲);允许所有host开发期设为["*"],部署时再收紧;加一个简单的CORS配置允许前端开发服务器访问。
2.3 把Vue前端脚手架搭起来
后端初始化完成之后,开一个终端创建前端项目。我用的是Vite,而不是官方Vue CLI,原因很简单:Vite启动快、配置少、生态已经是当前主流。我用Node 18以上的环境,执行:
npm create vite@latest frontend -- --template vue cd frontend npm install npm install vue-router pinia element-plus axios echarts这一套装完,前端的基础依赖就齐了。frontend目录和项目根目录平级,后面Vite做代理转发、部署时Nginx反代都方便。
目录结构我按功能模块来组织:
frontend/src/ ├── api/ # 所有接口请求封装,按业务模块分文件 ├── components/ # 公共组件(表格封装、状态标签等) ├── router/ # 路由配置 ├── stores/ # Pinia状态管理(用户信息、Token) ├── views/ # 页面级组件 │ ├── login/ │ ├── dashboard/ │ ├── flights/ │ ├── orders/ │ └── stats/ └── utils/ # axios实例、格式化工具pyCharm打开这个混合项目时,建议直接把airline_management作为根目录打开,前后端代码都在同一个工作区里,切换文件方便。如果PyCharm提示安装Vue插件,装一下就好,代码高亮和语法检查会舒服很多。
3. 后端实现:数据模型、API与Token认证的完整落地
3.1 数据模型设计:航班系统的四张核心表
业务表的设计是这套系统的地基。我建议动手写代码前先把ER图(实体关系图)画出来,哪怕用纸画也行。航司系统的核心表关系不复杂,但字段容易漏,比如航班状态、舱位余量这种"运营天天要看"的数据,一开始没设计进去后面补字段很麻烦。
我最终的模型设计简化后如下:
# flights/models.py from django.db import models class Aircraft(models.Model): code = models.CharField("机型编码", max_length=10, unique=True) name = models.CharField("机型名称", max_length=50) economy_seats = models.IntegerField("经济舱座位数", default=180) business_seats = models.IntegerField("公务舱座位数", default=20) def total_seats(self): return self.economy_seats + self.business_seats def __str__(self): return f"{self.code} {self.name}" class Flight(models.Model): STATUS_CHOICES = [ ("scheduled", "计划"), ("boarding", "登机"), ("departed", "已起飞"), ("arrived", "已到达"), ("cancelled", "已取消"), ] flight_number = models.CharField("航班号", max_length=10, unique=True) aircraft = models.ForeignKey(Aircraft, on_delete=models.PROTECT, verbose_name="执飞机型") departure_city = models.CharField("出发城市", max_length=30) arrival_city = models.CharField("到达城市", max_length=30) departure_time = models.DateTimeField("起飞时间") arrival_time = models.DateTimeField("到达时间") economy_price = models.DecimalField("经济舱票价", max_digits=8, decimal_places=2) business_price = models.DecimalField("公务舱票价", max_digits=8, decimal_places=2) status = models.CharField("航班状态", max_length=20, choices=STATUS_CHOICES, default="scheduled") sold_seats = models.IntegerField("已售座位数", default=0) class Meta: ordering = ["departure_time"] def remaining_seats(self, cabin_class="economy"): # 简化计算:不区分舱位时直接用总座位减已售 return self.aircraft.total_seats() - self.sold_seats# orders/models.py from django.db import models from django.conf import settings from flights.models import Flight class Passenger(models.Model): name = models.CharField("姓名", max_length=50) id_card = models.CharField("证件号", max_length=30) phone = models.CharField("联系电话", max_length=20) class Meta: unique_together = ("id_card", "phone") def __str__(self): return self.name class Order(models.Model): STATUS_CHOICES = [ ("paid", "已支付"), ("ticketed", "已出票"), ("refunded", "已退票"), ("cancelled", "已取消"), ] order_no = models.CharField("订单号", max_length=30, unique=True) user = models.ForeignKey(settings.AUTH_USER_MODEL, on_delete=models.CASCADE, verbose_name="下单用户") flight = models.ForeignKey(Flight, on_delete=models.PROTECT, verbose_name="航班") passengers = models.ManyToManyField(Passenger, verbose_name="乘客") cabin_class = models.CharField("舱位等级", max_length=10, choices=[("economy", "经济舱"), ("business", "公务舱")]) total_amount = models.DecimalField("订单金额", max_digits=10, decimal_places=2) status = models.CharField("订单状态", max_length=20, choices=STATUS_CHOICES, default="paid") created_at = models.DateTimeField("下单时间", auto_now_add=True) class Meta: ordering = ["-created_at"]简单解释几个设计决策:
第一,订单号不要用自增ID。虽然自增主键能用,但业务上订单号通常需要体现可读性,比如加日期前缀(20250612xxxxxx),后面客服查订单、对账单都方便。我这边是下单时用日期加随机数生成。
第二,乘客和订单是多对多关系。一个订单可能包含多名乘客(比如一家人出行),一个乘客也可能多次订票。用中间表是最灵活的方案,查"某乘客坐过哪些航班"也是一条查询搞定。
第三,航班已售座位数字段单独存。虽然算余票可以总座位 - 已售实时算,但每次用户查航班都去count订单表,量一大数据库就扛不住了。所以在Flight表上冗余一个sold_seats字段,下单时在Django的F()表达式配合事务里做原子更新,既保证不超卖,查询又只读一个整数字段,性能好很多。这也算是"用空间换时间"的经典做法。
唯一需要注意的坑是:on_delete=models.PROTECT,意思是"有订单引用的航班不允许直接删"。这个约束是业务上必须的——航班删了订单怎么办?后端会直接拒绝删除,提示前端改为"取消航班"操作。
3.2 用DRF写航班查询与订单API
模型建好之后,写REST API。Django REST Framework(DRF)的核心思路是:序列化器定义数据进出格式,视图集定义增删改查行为,路由自动注册URL。三个文件一写,接口就有了。
航班查询接口的序列化器:
# flights/serializers.py from rest_framework import serializers from .models import Flight, Aircraft class FlightSerializer(serializers.ModelSerializer): aircraft_code = serializers.CharField(source="aircraft.code", read_only=True) remaining_seats = serializers.SerializerMethodField() class Meta: model = Flight fields = ["id", "flight_number", "aircraft_code", "departure_city", "arrival_city", "departure_time", "arrival_time", "economy_price", "business_price", "status", "remaining_seats"] def get_remaining_seats(self, obj): return obj.remaining_seats()视图集加上查询参数支持:
# flights/views.py from rest_framework import viewsets, filters from django_filters.rest_framework import DjangoFilterBackend from .models import Flight from .serializers import FlightSerializer class FlightViewSet(viewsets.ModelViewSet): queryset = Flight.objects.select_related("aircraft").all() serializer_class = FlightSerializer filter_backends = [DjangoFilterBackend, filters.SearchFilter] filterset_fields = ["departure_city", "arrival_city", "status"] search_fields = ["flight_number"]这里用select_related("aircraft")做一个预取,是因为序列化时要用到机型编号,不预取的话每条航班都会多出一条查询机型的SQL,列表页数据量一大,数据库直接被N+1查询拖垮。这是一个非常经典的性能优化点。
路由注册:
# config/urls.py from rest_framework.routers import DefaultRouter from flights.views import FlightViewSet router = DefaultRouter() router.register("flights", FlightViewSet) urlpatterns = [ path("api/", include(router.urls)), ]完成之后,访问/api/flights/?departure_city=北京&arrival_city=上海就能拿到筛选后的航班列表。DjangoFilterBackend还自动支持?status=cancelled这种精确过滤,后端几乎不用写额外代码。
订单接口的核心是"创建订单"这个动作。这里不能只做一个普通的POST,它涉及三步业务逻辑:生成订单号、校验余票、更新sold_seats。我把这步写在序列化器的create方法里,用事务包住:
# orders/serializers.py 部分代码 from django.db import transaction from django.utils import timezone from rest_framework import serializers from .models import Order, Passenger from flights.models import Flight class OrderCreateSerializer(serializers.ModelSerializer): passengers = PassengerSerializer(many=True) class Meta: model = Order fields = ["flight", "cabin_class", "passengers"] @transaction.atomic def create(self, validated_data): flight = validated_data["flight"] passengers_data = validated_data.pop("passengers") cabin_class = validated_data["cabin_class"] if flight.sold_seats >= flight.aircraft.total_seats(): raise serializers.ValidationError("该航班已满员,无法预订") order_no = timezone.now().strftime("%Y%m%d%H%M%S") + str(random.randint(1000, 9999)) total_amount = flight.business_price if cabin_class == "business" else flight.economy_price order = Order.objects.create( order_no=order_no, user=self.context["request"].user, flight=flight, cabin_class=cabin_class, total_amount=total_amount, ) passengers = [Passenger.objects.create(**p) for p in passengers_data] order.passengers.set(passengers) # 原子更新余票,避免并发超卖 Flight.objects.filter(pk=flight.pk).update(sold_seats=models.F("sold_seats") + len(passengers)) return order这段里最有价值的就是那行update(sold_seats=F("sold_seats") + len(passengers))。这是一个数据库层面的原子操作,两个用户同时抢最后一张票时,数据库会串行化这个UPDATE,而不是各自先SELECT再UPDATE——前者安全,后者必超卖。这种细节是"看起来能跑"和"真正能上线"的分水岭。
3.3 Token认证与权限控制的落地
管理系统必须要身份认证,不然任何人都能删航班改价格。我选择用JWT(JSON Web Token)方案,具体库是restframework-simplejwt。JWT的思路是:用户登录成功后,后端签发一个带签名和过期时间的Token,前端每次请求带上它,后端验签通过即认为用户已登录。
配置很简单:
# config/settings.py 追加 from datetime import timedelta REST_FRAMEWORK = { "DEFAULT_AUTHENTICATION_CLASSES": [ "rest_framework_simplejwt.authentication.JWTAuthentication", ], "DEFAULT_PERMISSION_CLASSES": [ "rest_framework.permissions.IsAuthenticated", ], "DEFAULT_PAGINATION_CLASS": "rest_framework.pagination.PageNumberPagination", "PAGE_SIZE": 10, } SIMPLE_JWT = { "ACCESS_TOKEN_LIFETIME": timedelta(hours=2), "REFRESH_TOKEN_LIFETIME": timedelta(days=7), }然后在urls.py里挂JWT的登录和刷新接口:
from rest_framework_simplejwt.views import TokenObtainPairView, TokenRefreshView urlpatterns += [ path("api/auth/login/", TokenObtainPairView.as_view(), name="token_obtain_pair"), path("api/auth/refresh/", TokenRefreshView.as_view(), name="token_refresh"), ]权限控制上,默认全局要求登录。航班的查询接口虽然是查航班,但管理系统里也应该要求登录,无需匿名开放。航班和订单的写操作,可以进一步限制为只有管理员角色:
from rest_framework.permissions import IsAdminUser class FlightViewSet(viewsets.ModelViewSet): permission_classes = [IsAdminUser] # 覆盖全局配置,只允许管理员操作如果不想用Django自带的User表,也可以扩展自定义用户模型(继承AbstractUser),加role字段区分管理员和运营人员。毕设演示时建议做这个扩展,答辩老师问到权限设计能多讲几句。
3.4 一个容易忽略的坑:时区
时区问题看着小,坑起人来一点不含糊。典型现象就是我开发时把航班起飞时间存进去,前端一看,时间整整少了8小时。原因在于Django的USE_TZ = True会把所有datetime按UTC时间存进数据库,而中国在东八区,浏览器里用本地时间解析就错位了。
解决方案有两层。后端层面,settings里设置TIME_ZONE = "Asia/Shanghai",同时USE_TZ = True。此时DRF输出的时间字段如果开启了datetime格式化,会按UTC输出还是按本地时区输出?答案是:取决于序列化器有没有指定时区,默认情况下DRF会用Django的TIME_ZONE设置来格式化输出。前端拿到的是带时区的ISO字符串,再用new Date()格式化一次,就会转成浏览器本地时间,这个坑就绕过去了。
另一种更稳妥的做法是后端直接不输出时间而输出时间戳(整数),前端自己格式化。时间戳没有时区歧义,只是不够直观,调试时看着费劲。我的建议是:接口输出ISO字符串,前端用dayjs统一格式化显示,这样最自然。
4. 前端实现:Vue路由、状态管理与航班业务页面搭建
4.1 前端项目骨架:路由和状态管理先想清楚
前端起步之前,先把路由和状态管理设计好,比直接写页面省心得多。这个系统的路由表大概是这个结构:
// frontend/src/router/index.js import { createRouter, createWebHistory } from "vue-router"; const routes = [ { path: "/login", component: () => import("../views/login/LoginView.vue") }, { path: "/", component: () => import("../layouts/BasicLayout.vue"), redirect: "/dashboard", children: [ { path: "dashboard", component: () => import("../views/dashboard/DashboardView.vue"), meta: { title: "仪表盘" } }, { path: "flights", component: () => import("../views/flights/FlightListView.vue"), meta: { title: "航班管理" } }, { path: "orders", component: () => import("../views/orders/OrderListView.vue"), meta: { title: "订单管理" } }, { path: "stats", component: () => import("../views/stats/StatsView.vue"), meta: { title: "统计报表" } }, ], }, ];路由守卫里做一个非常关键的判断:没有Token就踢回登录页。这是前端安全的第一道防线,虽然真正的安全在后端接口,但前端拦截能大幅改善体验,避免用户眼睁睁看着接口报401。
router.beforeEach((to, from, next) => { const token = localStorage.getItem("access_token"); if (to.path !== "/login" && !token) { next("/login"); } else { next(); } });pinia负责存用户信息和当前登录状态。Token放localStorage里可以避免刷新页面就丢登录态,但注意JWT是明文可解码的,千万别把密码之类敏感信息也存进去。
axios实例封装是前端联调的枢纽,核心代码如下:
// frontend/src/utils/request.js import axios from "axios"; import { ElMessage } from "element-plus"; import router from "../router"; const request = axios.create({ baseURL: "/api", timeout: 15000, }); request.interceptors.request.use((config) => { const token = localStorage.getItem("access_token"); if (token) { config.headers.Authorization = `Bearer ${token}`; } return config; }); request.interceptors.response.use( (response) => response.data, (error) => { if (error.response?.status === 401) { localStorage.removeItem("access_token"); router.push("/login"); ElMessage.error("登录已过期,请重新登录"); } else { ElMessage.error(error.response?.data?.detail || "请求失败"); } return Promise.reject(error); } );统一在这里加请求头、统一处理错误码,业务页面里只管调接口拿数据,代码干净很多。
4.2 航班管理页:从列表到表单的Element Plus写法
后台管理页面90%的形态都是"表格+搜索+弹窗表单",航班管理页就是最典型的例子。用Element Plus来写,前三十分钟基本就能把框架搭完。核心就两件事:表格渲染和表单提交。
列表页数据结构大概是:
<!-- frontend/src/views/flights/FlightListView.vue 核心片段 --> <template> <div> <el-form :inline="true" :model="queryParams"> <el-form-item label="出发城市"> <el-input v-model="queryParams.departure_city" placeholder="请输入" clearable /> </el-form-item> <el-form-item label="到达城市"> <el-input v-model="queryParams.arrival_city" placeholder="请输入" clearable /> </el-form-item> <el-form-item> <el-button type="primary" @click="fetchList">查询</el-button> <el-button type="success" @click="openDialog">新增航班</el-button> </el-form-item> </el-form> <el-table :data="tableData" v-loading="loading" border stripe> <el-table-column prop="flight_number" label="航班号" width="120" /> <el-table-column prop="departure_city" label="出发城市" /> <el-table-column prop="arrival_city" label="到达城市" /> <el-table-column label="起飞时间" width="180"> <template #default="{ row }">{{ formatTime(row.departure_time) }}</template> </el-table-column> <el-table-column prop="economy_price" label="经济舱票价" width="110" /> <el-table-column prop="status" label="状态" width="100"> <template #default="{ row }"> <el-tag :type="statusType(row.status)">{{ statusLabel(row.status) }}</el-tag> </template> </el-table-column> <el-table-column prop="remaining_seats" label="余票数" width="90" /> <el-table-column label="操作" width="150" fixed="right"> <template #default="{ row }"> <el-button link type="primary" @click="openDialog(row)">编辑</el-button> <el-button link type="danger" @click="cancelFlight(row)">取消航班</el-button> </template> </el-table-column> </el-table> <el-pagination v-model:current-page="queryParams.page" :page-size="queryParams.page_size" :total="total" layout="total, prev, pager, next" @current-change="fetchList" /> </div> </template>新增/编辑航班用el-dialog加el-form弹窗完成。表单里的机型选项从/api/aircrafts/下拉加载,起降城市用输入框加校验(必填、不同城市),时间选择用el-date-picker的datetime类型。
这套写法本身不难,但我想强调两点经验:
第一,把表格的loading态做出来。接口快的时候感觉不到区别,但航班列表一旦加了筛选条件,或者后端在重新生成统计数据,没有loading的页面就会让用户觉得"点了没反应"。v-loading一行代码的事,体验提升是实打实的。
第二,删除操作别用DELETE,用"取消"。航班的业务规则是有订单关联的航班不允许删除,所以我在前端也只暴露"取消航班"操作,本质是PATCH把status改为cancelled。这样既符合业务语义,也避免了后端PROTECT外键导致的报错弹窗。做管理系统时一定要记住:操作按钮的名字要跟业务语言一致,不要跟技术术语一致。用户不关心DELETE还是PATCH,他们只关心"取消航班"按钮点了能不能用。
4.3 统计报表:用ECharts展示上座率与营收
统计页直接裸写表格太干瘪了,Vue+ECharts的组合可以很快做出漂亮的图表。后端在stats这个app里提供聚合接口,前端每加载一次页面就调一次接口,拿数据丢给ECharts渲染。
后端的聚合统计用Django ORM的annotate就能完成,不需要额外引入复杂框架。比如按月统计订单营收:
# stats/views.py from django.db.models import Sum, Count, F from django.utils import timezone from rest_framework.views import APIView from rest_framework.response import Response from orders.models import Order class RevenueStatsView(APIView): def get(self, request): current_year = timezone.now().year monthly_revenue = ( Order.objects .filter(created_at__year=current_year, status__in=["paid", "ticketed"]) .annotate(month=F("created_at__month")) .values("month") .annotate(total=Sum("total_amount")) .order_by("month") ) return Response(list(monthly_revenue))前端ECharts的配置则是经典的柱状图。这里有个小技巧:created_at__month在SQLite和MySQL里都能用,ORM帮我们屏蔽了数据库差异,这是不用Flask直接拼SQL的一个好处。
统计页我还加了一块"热门航线Top5"的横向条形图,思路完全一样,只是聚合字段改成departure_city和arrival_city组合。可视化图表对管理系统的加分效果非常明显,演示时领导一眼就能看懂运营状况,比甩一张表格有说服力得多。
4.4 前后端联调时的CORS配置与本地代理
前后端分离开发时,前端跑在5173端口(Vite默认),后端跑在8000端口(Django默认),浏览器直接从前端发请求到后端会触发跨域问题。解决跨域有两个环节要配合。
第一个环节是Django后端开CORS白名单。安装django-cors-headers后settings里配置:
CORS_ALLOWED_ORIGINS = [ "http://localhost:5173", ]CORS_ALLOWED_ORIGINS指定允许跨域访问的来源。开发期也可以图省事用CORS_ALLOW_ALL_ORIGINS = True,但上线前一定要改成白名单,否则任何网站都能调你的接口。
第二个环节是Vite开发服务器做代理。更优雅的方式是让前端请求同源,由Vite帮忙转发到后端。在frontend/vite.config.js里配置:
export default defineConfig({ plugins: [vue()], server: { proxy: { "/api": { target: "http://localhost:8000", changeOrigin: true, }, }, }, });这样前端axios的baseURL直接写/api就行,浏览器看到的请求是发给5173端口的同源请求,没有跨域问题。这个方案开发期比CORS更干净,因为上线后Nginx也是这么干的——前端静态资源和/api反代指向后端,前端代码完全不用改。
5. 联调、部署与实测踩坑:这些细节文档里不会写
5.1 排查链路:本地联调时接口报错怎么一步步定位
前后端联调的第一天,我预期是顺利的,结果一上来就遇到"航班列表加载不出来"。我没有急着改代码,而是按一套固定排查链路走,这也是我觉得新手最应该养成的习惯。链路是:浏览器Network面板看请求状态 -> 后端日志看异常堆栈 -> 数据库工具验证数据。
当时的现象是:浏览器控制台报500错误。第一步,打开Network面板,确认接口路径是/api/flights/,请求头带了Authorization: Bearer xxx,响应体里有一段DRF的报错信息。第二步,切到PyCharm的Run窗口,找到了完整的堆栈——原来是序列化器里get_remaining_seats访问了self.aircraft.total_seats(),而aircraft是外键,列表查询时没做select_related预取,由于某条测试数据的机型被删成了NULL,访问到了空对象。第三步,确认数据后修复:一是在视图queryset里加上select_related("aircraft"),二是给外键加on_delete=PROTECT,阻止未来出现空外键数据。
这套链路里的关键点是用后端日志而不是用肉眼看代码去找问题。DRF和Django的报错信息其实非常详细,会告诉你在哪个文件哪一行出了什么错,跟着堆栈走,90%的问题都能定位。
5.2 如果换成Flask:同样的业务怎么改
虽然主体用了Django,但我还是想认真回答标题里Flask这个选项。如果你因为课程要求或者个人偏好必须用Flask,同一个航司管理系统应该怎么组织?我给出一个对照方案。
Flask的核心理念是"微框架",不绑定ORM、不绑定认证方案,所有组件自己选型组装。同样的业务,技术栈会变成:
- Flask + Flask-SQLAlchemy:ORM负责模型和查询;
- Flask-Migrate:数据库迁移;
- PyJWT:生成和验证Token;
- Flask-CORS:解决跨域。
用一个蓝图(Blueprint)组织航班相关接口,大概长这样:
# flask_version/flights.py(简化示例) from flask import Blueprint, request, jsonify from flask_jwt_extended import jwt_required from .models import Flight from .schemas import flight_schema, flights_schema flights_bp = Blueprint("flights", __name__, url_prefix="/api/flights") @flights_bp.get("/") @jwt_required() def list_flights(): departure = request.args.get("departure_city") query = Flight.query if departure: query = query.filter(Flight.departure_city == departure) flights = query.all() return jsonify(flights_schema.dump(flights))对比下来,Flask版本最明显的差异是:没有Django Admin了,想给运营人员一个管理界面,要么自己写HTML页面,要么再配一个如Flask-Admin的插件,但成熟度相比Django Admin差一截。这也是我一再强调"重后台场景选Django"的原因。Flask的真正优势在于:你就需要一个提供几个JSON接口的轻服务时,它启动快、依赖少、部署方便,放在微服务架构里很舒服。
如果你是因为项目标题里同时出现Django和Flask才纠结的,我的建议很直接:用Django做主体,写清楚技术选型对比,然后口头或文档里说明"如果用Flask,蓝图怎么划分、模型怎么组织"。这比硬用Flask拼一个Django Admin同等体验的后台要明智得多。
5.3 部署时的几个要点
本地跑通只是第一步,真正能给别人用,还得部署到服务器上。这个项目的部署架构很标准:Nginx托管Vue构建出来的静态文件,同时把/api开头的请求反代到Django。
前端构建:
cd frontend npm run build构建产物在frontend/dist目录,把里面的文件扔到服务器Nginx的站点目录就行。
Django侧需要做几件事:
第一,settings.py里改掉DEBUG = False,配置ALLOWED_HOSTS为你的域名或服务器IP。这一步忘记改,页面会显示500且访问不了任何接口,这是部署最常碰到的第一个报错。
第二,执行collectstatic收集静态文件,Django Admin等后台页面需要这些文件才能正常显示样式。
第三,数据库切换。SQLite过渡到MySQL的步骤是:装驱动、建库、改DATABASES配置、执行migrate、导数据。Django的ORM保证业务代码基本不用动,但有一个坑要注意:MySQL不支持SQLite的某些字段操作,比如修改字段类型会提示需要手动加转换语句,所以模型结构尽量在上线前定死,别上线后再频繁改字段。
第四,生产环境用gunicorn启动Django服务,别再用runserver。runserver是开发服务器,既慢又不安全。启动命令大概是:
gunicorn config.wsgi:application --bind 127.0.0.1:8000 --workers 3Nginx反代配置片段:
server { listen 80; server_name your_domain.com; location / { root /var/www/frontend/dist; 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; } }这里try_files $uri $uri/ /index.html是SPA部署的关键:前端路由是history模式,用户直接访问/flights这个地址时,Nginx要返回index.html,由Vue再接管路由渲染对应页面。
5.4 我在这个项目里的实际体会
项目收尾后复盘,有几个点想分享给后来者。
**先画ER图再建表,这句话是真的有用。**我第一版模型设计时偷懒,直接在代码里敲字段,结果航班表和订单表关联做了一半才发现漏了"乘客和订单多对多"这个关键关系,回炉重改了一次。后来老老实实花半小时把ER图画完,所有表关系一目了然,建表几乎一次通过。做任何管理系统,ER图的时间绝对不能省。
**接口文档先行的收益远比想象中大。**我当时用简单的方式列了个接口清单:路径、方法、请求参数、返回字段,发给协作的前端同学。虽然只是个Markdown文档,但联调时两边不存在"我以为你返回了这个字段"的误会,至少少吵了三回架。
**权限设计一开始就要做,别指望后面补。**刚开始我把所有接口都设成无认证可访问,想着"反正本地调试方便"。结果加上JWT认证那天,前端所有请求都要跟着改,忙活了一个晚上。再重来一次的话,我会在项目初始化时就带上认证,哪怕先写死一个测试账号。
**版本管理习惯很重要。**这个项目全程用Git管理,每个功能模块一个commit,比如"feat: 航班列表接口"、"fix: 修复订单余票并发问题"。后面排查bug需要回退版本,或者演示完想找某个历史状态,Git能帮上大忙。养成这个习惯之后,做任何项目心里都有底。
对我个人来说,这个航司管理系统最大收获不在于用了多少框架,而在于完整走了一遍"业务梳理 -> 表设计 -> 后端API -> 前端页面 -> 联调测试 -> 部署上线的全流程。如果你也是第一次做前后端分离的项目,这套流程的每个环节都值得你亲手过一遍,踩过的坑都会变成下次开发的直觉。希望这篇记录能帮你少走几步弯路。