
毕业设计选课题最怕的就是“看着简单、做着乱”。我见过不少同学选了个管理系统结果做成了纯 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 Appships、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_length100, uniqueTrue) imo models.CharField(IMO编号, max_length30, blankTrue) build_date models.DateField(建造日期, nullTrue, blankTrue) deadweight models.DecimalField(载重吨, max_digits10, decimal_places2, default0) status_choices ((1, 营运中), (2, 修理中), (3, 停航)) status models.SmallIntegerField(状态, choicesstatus_choices, default1) create_time models.DateTimeField(创建时间, auto_now_addTrue) def __str__(self): return self.name class Device(models.Model): ship models.ForeignKey(Ship, verbose_name所属船舶, on_deletemodels.CASCADE, related_namedevices) name models.CharField(设备名称, max_length100) model_no models.CharField(型号, max_length100, blankTrue) install_pos models.CharField(安装位置, max_length100, blankTrue) install_date models.DateField(投用日期, nullTrue, blankTrue) status_choices ((1, 正常), (2, 待维修), (3, 停用)) status models.SmallIntegerField(状态, choicesstatus_choices, default1)注意这里我用了on_deletemodels.CASCADE含义是船舶删除后其下设备一起删除这个逻辑要写清楚答辩时老师很爱问。related_namedevices的作用是方便从船舶对象反向取设备列表ship.devices.all()这一点也需要理解。维保计划表是业务核心字段需要覆盖周期类型、负责人和下一次维保日期。我单独提一下周期天数这个字段用整数天数比用“月/季度/年度”更通用因为实际保养间隔不一定完全按自然月比如某设备规定 120 天保养一次用天数直接算最稳妥。# apps/plans/models.py class MaintenancePlan(models.Model): device models.ForeignKey(Device, verbose_name设备, on_deletemodels.CASCADE, related_nameplans) plan_name models.CharField(计划名称, max_length100) cycle_days models.IntegerField(周期天数, default30) duty_person models.CharField(负责人, max_length50, blankTrue) start_date models.DateField(计划起始日) last_done_date models.DateField(最后完成日, nullTrue, blankTrue) remind_days models.IntegerField(提前提醒天数, default3) status_choices ((1, 启用), (0, 停用)) status models.SmallIntegerField(状态, choicesstatus_choices, default1)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(daysplan.cycle_days) def plan_is_due(plan): next_date get_next_plan_date(plan) remind_date next_date - timedelta(daysplan.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(pkorder_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_maintDjango 配置文件目录用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(status1) search request.GET.get(q, ).strip() if search: plans plans.filter(device__ship__name__icontainssearch) due_plans [] for plan in plans: next_date get_next_plan_date(plan) remind_date next_date - timedelta(daysplan.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(keylambda item: item[next_date]) return render(request, plans/due_list.html, {due_plans: due_plans})这里用了select_related(device__ship)Java 的同学可以理解成做了一次联合查询避免在循环里逐条发起查询。写毕业设计时这个细节很关键如果每一条计划都在模板里取plan.device.ship.nameN 条计划就是 N1 次数据库查询数据一多速度立刻变慢。答辩老师如果问到“你怎么优化查询”直接讲这个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 classbadge bg-danger超期 {{ item.overdue_days }} 天/span {% elif item.overdue_days 0 %} span classbadge bg-warning text-dark今日到期/span {% else %} span classbadge 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( totalCount(devices__plans__workorder), doneCount(devices__plans__workorder, filterQ(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 里的?qxxx必须带上。我用了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 里除数据库名之外还要设置OPTIONSOPTIONS: {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这个 CookiejQuery 写法如下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静态文件 404STATIC_URL 配置错误检查 settings 和模板的 static 标签从我个人的实际开发顺序来看这个项目最不能跳过的步骤就是数据模型设计。模型错了后面所有视图、统计、权限都是白做。先花两天把业务逻辑画明白再开始写代码比反反复复改表结构高效得多。最后分享一个答辩的小技巧正式演示前先造一批包含“临期计划、正常计划、已完成计划、超期计划”的测试数据。用真实的船名和设备名填充比如“远洋1号—主机—750小时保养计划”演示时老师一眼就能看懂页面表达的是什么业务而不是看到满满一堆“测试1、测试2”。数据不真实再好用的功能也显得不够专业。希望这篇能帮你在做毕设的路上少踩几个坑。