毕业设计选课题,最怕的就是“看着简单、做着乱”。我见过不少同学选了个管理系统,结果做成了纯 CRUD 的堆砌,答辩时被老师一句“你这个系统解决了什么业务问题”就问住了。我当初定下《基于Python的船舶维保管理系统》这个题目时,就看准了一点:这类项目重业务闭环、轻算法难度,数据模型建得稳、功能流程走得通、代码风格干净,答辩基本不用慌。
船舶维保管理和普通的图书管理、课程管理不一样,它涉及“船—设备—维保计划—执行记录—统计预警”一条完整链路。船员要按周期做检修保养,管理人员要盯着到期未办的项目,系统要能自动算下一次维保时间,还要能分清谁负责、做到哪一步了。这些没人管的时候全是纸面台账,账目一多就乱。把这条链路做成系统,恰恰是毕业设计最合适的切入点。
这篇文章我按自己的实际开发过程来写,从需求拆解、数据模型、核心逻辑到实操细节,再到我踩过的坑,适合正在做 Python 方向毕设、或者想用 Django 快速完成一个管理系统的读者。如果你已经选了类似题目但没思路,按着下面的路径走,能少走很多弯路。
1. 项目整体设计与模块划分
1.1 需求梳理:船舶维保场景的核心痛点
船舶维保和普通设备保养最大的区别在于“强制性”和“周期性”。船上的主机、辅机、压载系统、消防设备、导航雷达,每一样都有法定或规定的保养周期。日保、周保、月保、季度保养、年度检修,周期错开、项目繁多,如果靠 Excel 或纸质表去记,很容易漏项,而漏掉一项保养就可能影响船舶安全检查记录。
因此系统第一优先级不是好看,而是“不漏”:每条船舶、每台设备都要能建立独立的维保计划,每次保养执行都要有时可查、有人可溯。第二优先级才是效率和展示:支持快速录入、快速查询、到期提醒、统计报表。
常规的“某个学生管理系统的惯性思路”放在这里就不太够用了。如果只是给一张维保记录表做增删改查,等于丢了业务核心。我最后把功能拆成了五个模块:船舶档案管理、设备台账管理、维保计划管理、维保工单执行、统计看板与提醒。五个模块串起来,一笔维保数据的生命周期才是完整的。
1.2 技术选型:为什么用 Django 而不是 Flask 或 SSM
这个项目我一开始就在 Django 和 Flask 之间犹豫。Flask 轻巧、上手快,但毕业设计往往需要后台管理、登录认证、数据关系处理和文件上传等一堆“基础公共能力”,用 Flask 要自己拼很多第三方库,开发节奏会被琐碎事情拖慢。Django 自带 Admin 后台、ORM、认证系统、CSRF 防护和模板引擎,等于把这些基础设施提前备好了。
如果你本身对 Java 更熟,用 SSM 做这个题也完全可行;但题目既然是 Python 方向,我推荐直接上 Django。Django 的 ORM 对初学者非常友好,做关联查询不需要手动拼复杂的 JOIN,定义模型后迁移数据库也非常直观。我实际用到的 Django 组件主要有:models(数据模型)、views(业务视图)、admin(后台数据维护)、auth(用户登录与权限)、messages(页面提示信息)。
前端方面我用的是 Bootstrap 5 + jQuery,没有上 Vue 或 React。毕业设计里前端框架不是重点,稳定、兼容性好、能快速和 Django 模板集成才是重点。图表统计我用的是 ECharts,通过后端 JSON 接口输出数据,前端直接渲染,效果专业且开发量小。
1.3 功能模块拆解与数据流向
整个系统的数据流可以总结为:船舶档案建立后,往船舶下挂设备台账;设备台账再关联维保计划;维保计划到期后生成/确认执行工单;工单完成后写入维保记录;最后统计模块按船舶、设备、完成率、临期数量做展示。这个顺序不能乱,一旦颠倒,后面所有统计都会失真。
- 船舶档案模块:维护船名、IMO 编号、建造日期、载重吨、船舶类型、当前状态。
- 设备台账模块:每条船舶下挂多台设备,字段包含设备名称、型号、安装位置、投用日期。
- 维保计划模块:计划绑定到具体设备,设置周期天数、执行部门/负责人、最近下次维保日期。
- 工单执行模块:从计划发起工单,记录实际工时、完成时间、执行人、维保内容和备注。
- 统计看板:展示到期提醒数量、各船维保完成率、周期类型分布、最近 6 个月执行趋势。
我在代码层面把五个模块拆成三个 Django App:ships、plans、records。ships 管船舶和设备,plans 管计划与到期计算,records 管工单和记录。App 之间通过外键关联,层次清楚,答辩时讲模块划分也容易说。
2. 核心数据模型与关键业务逻辑
2.1 数据库表设计
数据模型是整个系统的地基。我的做法是先画一个简单的表格草稿,理清楚每张表的主外键关系,再写 Django Model。最终核心表一共有五张:Ship(船舶)、Device(设备)、MaintenancePlan(维保计划)、WorkOrder(工单)、MaintenanceRecord(维保记录)。
设备单独建表的理由很明显:一艘船可能有几十台设备,如果只把设备名称写在船舶表里一个字段中,后续做“按设备的维保统计”就完全没法实现。设备和维保计划是一对多的关系,一台设备可以有多条不同周期的计划,例如主机的日保计划和季度保养计划是两条独立记录。
下面是核心模型的核心字段写法的参考,我用的是 Django 的 models:
# apps/ships/models.py from django.db import models class Ship(models.Model): name = models.CharField('船名', max_length=100, unique=True) imo = models.CharField('IMO编号', max_length=30, blank=True) build_date = models.DateField('建造日期', null=True, blank=True) deadweight = models.DecimalField('载重吨', max_digits=10, decimal_places=2, default=0) status_choices = ((1, '营运中'), (2, '修理中'), (3, '停航')) status = models.SmallIntegerField('状态', choices=status_choices, default=1) create_time = models.DateTimeField('创建时间', auto_now_add=True) def __str__(self): return self.name class Device(models.Model): ship = models.ForeignKey(Ship, verbose_name='所属船舶', on_delete=models.CASCADE, related_name='devices') name = models.CharField('设备名称', max_length=100) model_no = models.CharField('型号', max_length=100, blank=True) install_pos = models.CharField('安装位置', max_length=100, blank=True) install_date = models.DateField('投用日期', null=True, blank=True) status_choices = ((1, '正常'), (2, '待维修'), (3, '停用')) status = models.SmallIntegerField('状态', choices=status_choices, default=1)注意这里我用了on_delete=models.CASCADE,含义是船舶删除后其下设备一起删除,这个逻辑要写清楚,答辩时老师很爱问。related_name='devices'的作用是方便从船舶对象反向取设备列表:ship.devices.all(),这一点也需要理解。
维保计划表是业务核心,字段需要覆盖周期类型、负责人和下一次维保日期。我单独提一下周期天数这个字段,用整数天数比用“月/季度/年度”更通用,因为实际保养间隔不一定完全按自然月,比如某设备规定 120 天保养一次,用天数直接算最稳妥。
# apps/plans/models.py class MaintenancePlan(models.Model): device = models.ForeignKey(Device, verbose_name='设备', on_delete=models.CASCADE, related_name='plans') plan_name = models.CharField('计划名称', max_length=100) cycle_days = models.IntegerField('周期天数', default=30) duty_person = models.CharField('负责人', max_length=50, blank=True) start_date = models.DateField('计划起始日') last_done_date = models.DateField('最后完成日', null=True, blank=True) remind_days = models.IntegerField('提前提醒天数', default=3) status_choices = ((1, '启用'), (0, '停用')) status = models.SmallIntegerField('状态', choices=status_choices, default=1)2.2 维保周期与到期计算逻辑
有了周期天数,下一步就是算“下一次应该什么时候维保”。我的算法很简单:优先取last_done_date(最后完成日),如果还没有完成记录,就取计划起始日start_date,加上cycle_days就是计划维保日;然后根据remind_days判断是否进入提醒窗口。
from datetime import timedelta from django.utils import timezone def get_next_plan_date(plan): base_date = plan.last_done_date or plan.start_date return base_date + timedelta(days=plan.cycle_days) def plan_is_due(plan): next_date = get_next_plan_date(plan) remind_date = next_date - timedelta(days=plan.remind_days) today = timezone.localdate() return today >= remind_date and plan.status == 1这里有个容易出错的地方:如果计划一直不做,last_done_date一直不变,那next_date是固定的,系统会每天提醒。这其实是业务上期望的行为——没完成就持续预警。但要注意界面上要把“未完成天数”也显示出来,让用户一眼看到已经超期多久。我计算超期天数是这样的:(today - next_date).days,如果为 0 表示今天到期,负数表示还没到,正数表示超期。这个值在计划列表和提醒列表里都很有用。
2.3 维保状态流转与权限控制
工单执行是整个流程的“最后一公里”。我建模了 WorkOrder 表,字段包括关联的 MaintenancePlan、执行人、开始时间、完成时间、状态、备注。状态我设置了三个:待执行、执行中、已完成。从计划生成工单后,默认状态是待执行,点击“开始处理”变为执行中,填写完成信息后变为已完成,同时回写MaintenancePlan.last_done_date。
这个回写动作必须在视图层做好事务控制,否则可能工单标记完成了,计划却没有更新,下次重启页面,提醒又跳出来。我用 Django 的事务装饰器来保证两步一起生效:
from django.db import transaction @transaction.atomic def complete_work_order(request, order_id): order = WorkOrder.objects.select_for_update().get(pk=order_id) order.status = '已完成' order.finish_time = timezone.now() order.save() plan = order.plan plan.last_done_date = order.finish_time.date() plan.save(update_fields=['last_done_date'])权限控制方面,我没有自己造轮子。用户用 Django 的User模型,再用一个 UserProfile 扩展角色字段,角色分为管理员、轮机长、值班员。页面权限用装饰器控制,比如工单创建和单据审核只允许管理员和轮机长操作,值班员只能查看和填写执行记录。
from django.contrib.auth.decorators import login_required, user_passes_test def is_manager(user): return user.is_authenticated and getattr(user, 'profile', None).role in ['admin', 'engineer'] @login_required @user_passes_test(is_manager) def create_work_order(request, plan_id): ...这套写法在答辩时很加分,因为老师会看到你不仅做了功能,还做了角色边界。
3. 实操拆解:从骨架到可答辩的完整实现
3.1 项目初始化与工程结构
我从零开始建项目时,目录结构和 App 划分建议这样:项目根目录叫ship_maint,Django 配置文件目录用config,三个业务 App 分别是ships、plans、records。为什么用config而不是默认的ship_maint?因为项目根目录也叫ship_maint,两个同名目录嵌套会让刚接触工程结构的同学混淆。
初始化命令我整理如下,直接在命令终端执行:
mkdir ship_maint cd ship_maint python -m venv venv source venv/bin/activate # Windows 环境用 venv\Scripts\activate pip install django pymysql django-admin startproject config . python manage.py startapp ships python manage.py startapp plans python manage.py startapp records安装pymysql是因为后期打算把数据库从 SQLite 切到 MySQL。SQLite 在演示阶段非常方便,但答辩现场如果有条件用 MySQL,数据表和中文支持更友好,老师看到连接配置也不会觉得简陋。
变成 MySQL 后,需要在config/__init__.py里加一行:
import pymysql pymysql.install_as_MySQLdb()然后在settings.py里把数据库配置改成 MySQL 连接参数。如果是本地开发,可以先一直用 SQLite,答辩前再切 MySQL,省去前期折腾。
注册 App 后,在settings.py的INSTALLED_APPS里加入ships、plans、records,再执行:
python manage.py makemigrations python manage.py migrate python manage.py createsuperuser登录 Admin 后台,现在就可以手工维护船舶和设备数据了。这一步跑通后,项目的骨架已经立住。
3.2 核心视图与路由实现:到期计划
视图层我主要写了三类:列表页、状态操作接口、统计接口。以“到期计划列表”为例,页面需要展示正在启用的计划,并且带一个筛选条件是“今日已进入提醒窗口”。我按自己的业务逻辑这么写:
# apps/plans/views.py from django.shortcuts import render, get_object_or_404, redirect from django.contrib import messages from .models import MaintenancePlan def due_plan_list(request): plans = MaintenancePlan.objects.select_related('device__ship').filter(status=1) search = request.GET.get('q', '').strip() if search: plans = plans.filter(device__ship__name__icontains=search) due_plans = [] for plan in plans: next_date = get_next_plan_date(plan) remind_date = next_date - timedelta(days=plan.remind_days) today = timezone.localdate() if today >= remind_date: due_plans.append({ 'plan': plan, 'next_date': next_date, 'type': '已超期' if today > next_date else '待执行', 'overdue_days': (today - next_date).days, }) due_plans.sort(key=lambda item: item['next_date']) return render(request, 'plans/due_list.html', {'due_plans': due_plans})这里用了select_related('device__ship'),Java 的同学可以理解成做了一次联合查询,避免在循环里逐条发起查询。写毕业设计时这个细节很关键,如果每一条计划都在模板里取plan.device.ship.name,N 条计划就是 N+1 次数据库查询,数据一多速度立刻变慢。答辩老师如果问到“你怎么优化查询”,直接讲这个select_related就是真实经验。
路由写法很常规,一个计划列表、一个状态流转接口、一个提醒数据接口,分别对应如下 URL:
# config/urls.py from django.urls import path, include urlpatterns = [ path('admin/', admin.site.urls), path('plans/', include('plans.urls')), path('records/', include('records.urls')), path('ships/', include('ships.urls')), ]3.3 前端页面与交互优化
前端页面我全部用 Django 模板 + Bootstrap 实现。列表页的关键不只是把数据输出出来,而是让“临期、超期”的状态一眼可辨。我用表格加徽章的方式,超期的行加红色背景,临期的行加浅黄色背景。这个判断直接复用overdue_days这个值,在模板里做条件渲染:
<tr class="{% if item.overdue_days > 0 %}table-danger{% elif item.overdue_days == 0 %}table-warning{% endif %}"> <td>{{ item.plan.device.ship.name }}</td> <td>{{ item.plan.device.name }}</td> <td>{{ item.plan.plan_name }}</td> <td>{{ item.next_date|date:"Y-m-d" }}</td> <td> {% if item.overdue_days > 0 %} <span class="badge bg-danger">超期 {{ item.overdue_days }} 天</span> {% elif item.overdue_days == 0 %} <span class="badge bg-warning text-dark">今日到期</span> {% else %} <span class="badge bg-info">临期</span> {% endif %} </td> </tr>删除操作我用了 SweetAlert2 做确认弹窗,体验比浏览器原生 confirm 好很多。需要特别注意的是删除工单时涉及外键关联,如果直接删除计划,下面的工单记录会变成孤儿数据。我的处理方案是“逻辑删除”:给计划表加一个is_deleted字段,前端点删除实际上把状态改成停用。这样数据链还在,统计报表也不会断。
3.4 统计报表的接口设计
统计看板我用三个图表:各船舶维保完成率柱状图、周期类型分布饼图、最近六个月执行趋势折线图。后端只需要提供三个 JSON 接口,前端用 ECharts 渲染。完成率接口的核心查询是分组聚合:
# apps/records/views.py from django.db.models import Count, Q from django.http import JsonResponse from apps.ships.models import Ship def completion_stat(request): result = [] ships = Ship.objects.annotate( total=Count('devices__plans__workorder'), done=Count('devices__plans__workorder', filter=Q(devices__plans__workorder__status='已完成')) ) for ship in ships: rate = round(ship.done / ship.total * 100, 1) if ship.total else 0 result.append({'name': ship.name, 'total': ship.total, 'done': ship.done, 'rate': rate}) return JsonResponse({'data': result})这种写法用了 Django 聚合中的条件过滤 Count,会比先取出数据再在 Python 里循环计算效率高很多。答辩时如果老师问统计怎么实现的,你能说出“数据库聚合,避免在应用层做二次循环”,这句话很加分。
3.5 坑点:联表和分页的性能问题
计划列表的数据会越来越多,直接在视图里把全部计划取出来再展示是不可行的。我给“所有计划”和“所有工单”页面都加了 Django 的Paginator。要注意的是,分页和搜索条件要同时保留,也就是翻页的时候 URL 里的?q=xxx必须带上。我用了request.GET.copy()放进分页对象的解决方案,否则点第二页时搜索条件就丢了。
4. 常见问题与排查实录
4.1 数据库中文乱码与连接配置问题
本地用 SQLite 时中文几乎不会乱码,但切到 MySQL 就很容易遇到Incorrect string value报错。根因是数据库表默认排序规则不是utf8mb4。我建库时直接指定字符集,命令是:
CREATE DATABASE ship_maint_db DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci;在 Django 的 settings.py 里除数据库名之外,还要设置OPTIONS:
'OPTIONS': {'charset': 'utf8mb4'},另外pymysql.install_as_MySQLdb()一定要在数据库配置加载前执行,我放在config/__init__.py里,避免了 DSN 加载时报找不到 MySQLdb 模块的问题。
4.2 定时提醒为什么不触发
很多同学会在系统里想做“每天定时提醒”,于是想到 Celery 或 Linux 的 cron。我在开发中也尝试过,最后发现对毕业设计来说其实是过度设计。如果你非要在本机演示自动提醒,需要额外开 Redis 和 worker,环境复杂度增加,答辩前临时演示很容易翻车。
我的替代方案是把“提醒”做成“进入系统即可见的预警面板”,同时在计划列表页显示临期和超期计划。这不依赖定时任务,逻辑可靠,演示效果也清楚。如果老师一定要看到“定时”,你可以讲清楚实现方案:在服务器上配置一个每日执行的脚本,调用 Django 管理命令发送提醒,但演示环境为了稳定性选择了页面预警。管理命令的核心就是遍历所有计划,找到今日进入提醒窗口的记录,然后发邮件或系统通知。
4.3 状态流转后数据不同步的问题
工单标记“已完成”后,计划列表里状态还是旧的,这个问题我开发时遇到过好几次。原因基本都是没有在complete_work_order里更新MaintenancePlan.last_done_date,或者是更新了但没有刷新页面缓存。Django 模板默认不缓存,只要重新请求列表就会看到最新值;如果还是不对,第一排查对象就是视图里是否真的执行了plan.save()。
另一个隐蔽问题是时区设置。如果你的settings.py里USE_TZ = True,而服务器时间是 UTC,那么timezone.localdate()得到的日期可能和北京时间相差一天。我的做法是统一使用系统本地时间,在配置里写:
TIME_ZONE = 'Asia/Shanghai' USE_TZ = True同时视图里所有“今天”的计算都用timezone.localdate(),不要用date.today(),两者的区别在时区开启后体验非常明显。日期错一天在答辩现场被演示出来会非常尴尬。
4.4 CSRF 与静态文件加载问题
如果前端用 AJAX 提交工单状态,会碰到 Django 的 CSRF 校验。手动拼表单时需要在页面中获取csrftoken这个 Cookie,jQuery 写法如下:
function getCookie(name) { let cookieValue = null; if (document.cookie && document.cookie !== '') { document.cookie.split(';').forEach(item => { let cookie = item.trim(); if (cookie.substring(0, name.length + 1) === (name + '=')) { cookieValue = decodeURIComponent(cookie.substring(name.length + 1)); } }); } return cookieValue; }静态文件 404 也很常见,我先确认settings.py中DEBUG = True、STATIC_URL = '/static/',再把 app 里的static目录放在应用根目录下,模板中用{% load static %}加{% static 'xxx.css' %}引用。collectstatic只在部署环境才需要,本地开发不做也不会出错。
4.5 故障排查速查表
| 问题现象 | 常见原因 | 解决办法 |
|---|---|---|
| 插入中文报错 | 数据库非 utf8mb4 字符集 | 重建库并指定 utf8mb4 |
| 列表查询很慢 | 循环里访问外键字段 | 用 select_related 预取关联数据 |
| 日期显示差一天 | USE_TZ 与本地时区不一致 | 统一用 Asia/Shanghai 和 localdate |
| 删除计划后工单丢失 | 外键级联删除 | 改为逻辑删除或禁止删除有记录的计划 |
| AJAX 提交失败 | CSRF token 没有写入请求头 | 用 getCookie 读取 csrftoken 并添加到 header |
| 静态文件 404 | STATIC_URL 配置错误 | 检查 settings 和模板的 static 标签 |
从我个人的实际开发顺序来看,这个项目最不能跳过的步骤就是数据模型设计。模型错了,后面所有视图、统计、权限都是白做。先花两天把业务逻辑画明白,再开始写代码,比反反复复改表结构高效得多。
最后分享一个答辩的小技巧:正式演示前,先造一批包含“临期计划、正常计划、已完成计划、超期计划”的测试数据。用真实的船名和设备名填充,比如“远洋1号—主机—750小时保养计划”,演示时老师一眼就能看懂页面表达的是什么业务,而不是看到满满一堆“测试1、测试2”。数据不真实,再好用的功能也显得不够专业。希望这篇能帮你在做毕设的路上少踩几个坑。