做铁路博物馆这类题材的系统我一开始其实有点犯难。原因很简单博物馆数字化在市面上已经有大量成熟方案但铁路博物馆的展品结构、展览逻辑、观众动线都跟美术馆、科技馆不太一样。如果只是把“增删改查”套进去那跟做一个普通图文网站没区别。这个系统真正要解决的问题是如何把铁路主题特有的“动线感”机车从入库、检修、展示到退役的完整故事线和“时间纵深”从蒸汽时代到高铁时代的技术演进在Web端呈现出来同时让馆方日常运营变得省力。这篇内容我会从需求拆解、技术选型、数据库设计、后端API、前端实现到实测调优把一套可落地的方案完整捋一遍。适合正在做类似全栈毕设项目的同学也适合博物馆行业里想做轻量数字化改造但又不想上重型CMS的从业者参考。1. 铁路博物馆的业务需求为什么不能照搬普通美术馆系统任何系统开发的第一步都不是写代码而是把需求翻译成关系结构。博物馆行业的项目尤其如此因为它的业务对象不是商品而是“不可再生”的展品操作逻辑里充满了特殊约束。1.1 铁路藏品的三维分类逻辑铁路博物馆的藏品特点是“大物多、小物杂”。大物是机车、车辆、桥梁构件、道岔小物是票证、徽章、信号灯、线路图、调度日志。它们在管理维度上完全不是一回事大物件类一台蒸汽机车需要记录型号、生产年份、轨距、动轮数、锅炉压力、服役线路、退役原因、整修状态。这些参数直接决定展览时能不能通电展示还是只能静态摆放。文献票证类纸质藏品需要保存条件字段例如温湿度要求、光照上限。数字化时重点是高清扫描件的挂接而不是三维模型。线路与场景类比如一段废弃的窄轨铁路复原场景它可能是“展品场景”的组合需要把多个子展品关联到一个展线上。所以我首版数据库设计时没有用单一的“藏品类型”字符串字段而是拆出category大类机车/车辆/线路设备/信号通信/票证文献/其他和exhibit_type展示形式实物/模型/场景/数字化复现再配合一个attributesJSON 字段存放各异的扩展参数。这种设计在 Django 里非常合适因为 PostgreSQL 或 MySQL 5.7 都原生支持 JSON 类型查询时用JSONContainment也能做索引内过滤。1.2 观众端和管理端的关键功能清单把需求整理成功能清单的时候我习惯先列动词再列名词——观众能做什么馆员能做什么管理员能做什么一张表理清楚角色核心动作关键功能说明游客/观众浏览、搜索、预约、互动展品列表按年代/类别筛选展览日历参观预约展品详情页含语音讲解聚合馆员维护展品、排展、审核预约展品管理含图片/视频上传、展览排期、预约审核、公告发布超级管理员用户管理、数据看板员工账号分配、角色权限、客流与热门展品统计大屏一个容易忽略的点是参观预约。铁路博物馆很多展品在户外展区雨天会暂停开放。所以预约模块必须支持室内/室外分区判断并且馆员端能快速修改“暂不开放”状态观众端在预约日收到状态变更提醒。这套逻辑在毕设答辩时非常加分因为它体现了“业务细节思考”。1.3 非功能需求老馆员的Excel工作流跟某高校的同学交流时他提到一个很真实的痛点馆里很多展品档案还是Excel表格字段名混乱有的是“名称”有的是“品名”有的干脆用合并单元格。系统上线最大的阻力不是技术而是老馆员不愿意重新录入几百条数据。因此这个系统我从一开始就额外规划了两个模块Excel模板导入后端接收上传的 xlsx 文件按模板字段映射创建展品记录返回成功/失败明细失败原因如“年代格式不对”。双轨审核新导入的数据默认状态为“待审核”馆员在后台核对后才会在前台展示。这两个模块不属于标题里的“展览”关键词但它们直接决定系统能不能在实际场景跑起来。做项目别只盯着标题里的高频词管线上的辅助环节同样是完整性的体现。2. 技术选型Vue3与Django这套组合到底图什么很多人在选题时会纠结“前端是不是必须用Vue3”或者“后端用Flask行不行”。我的看法是选型不是追求最新而是追求开发效率、生态完整度、答辩叙事的一致性。2.1 为什么不选React不选Java Spring Boot先说前端。Vue3和React在能力上没有本质差距但Vue3的组合式API对中小型团队非常友好。这个系统有大量“列表抽屉详情弹窗表单”的交互Vue3的script setup语法能让每个页面的逻辑代码比Options API减少约30%的模板噪音。配合Vite的秒级热更新我在调试展品筛选器时几乎不用等编译。另外Element Plus作为Vue3的官方级组件库表格、分页、上传、日期选择器都是现成的比React生态里到处找组件要省心得多。后端选Django的原因很直接ORM省掉一半SQL。展品关联展厅、展览关联展品、预约关联场次这些关系在Django里用ForeignKey、ManyToManyField就能描述迁移又自动生成。用Java写SSM或Spring Boot光是建Entity和Mapper就要多出许多体力活。自带Admin后台可以当运营工具。虽然最终系统是前后端分离的自研界面但开发阶段Django Admin是极好的数据调试台甚至可以在交付后给馆员做“应急入口”。权限系统现成django.contrib.auth组和权限的模型可以直接复用不用自己设计角色表。2.2 前后端分离的接口协议与整体架构整体架构没有花哨的微服务就是经典的单体Django后端 Vue3 SPA前端 (Vue3 Vite Pinia Element Plus) | axios 请求 (JWT Bearer Token) ↓ Django REST Framework (DRF) | 序列化 视图集 JWT认证 ↓ MySQL 8.0 (业务数据) Redis (预约排队/缓存)Django侧我创建了两个Appmuseum核心业务展品、展览、展厅、预约、公告和user_auth用户注册登录、角色权限。当前端代码里写死业务App名会后期混乱所以项目中settings.INSTALLED_APPS要清晰分组INSTALLED_APPS [ django.contrib.admin, django.contrib.auth, ... rest_framework, rest_framework_simplejwt, corsheaders, import_export, # 数据导入导出专用 user_auth, museum, ]2.3 依赖版本与项目初始化版本锁定很关键。我第一次跑通的时候Django是4.2.x、DRF是3.14.xSimpleJWT用5.2.x。这几个版本组合的兼容性已经过实战验证建议按下面环境走不要一上来就追新# 后端环境 python -m venv venv source venv/bin/activate pip install django4.2.20 pip install djangorestframework3.14.0 pip install djangorestframework-simplejwt5.3.0 pip install django-cors-headers4.3.1 pip install pillow10.3.0 # 图片处理 pip install openpyxl3.1.2 # Excel导入导出 pip install redis5.0.4 # 前端环境 npm create vitelatest railway-museum-frontend -- --template vue-ts npm install element-plus pinia vue-router4 axios npm install echarts # 数据分析大屏中间踩过一个坑Django 4.2 对 MySQL 8.0 的utf8mb4支持很顺但本地SQLite跑的时候有些JSON查询语法不完全等价。开发期可以用SQLite上线前记得整体跑一遍迁移到MySQL的兼容测试。3. 数据库模型设计这套系统的“胜负手”博物馆系统的核心不是页面炫酷而是模型能否准确表达“展品—展览—预约—用户”之间的状态流转。模型设计错了后面每个接口都别扭。3.1 核心表结构拆解下面是我敲定的核心模型字段做了精简实际项目里更多from django.db import models from django.contrib.auth.models import AbstractUser class User(AbstractUser): ROLE_CHOICES ( (visitor, 游客), (curator, 馆员), (admin, 管理员), ) role models.CharField(max_length20, choicesROLE_CHOICES, defaultvisitor) phone models.CharField(max_length20, blankTrue) avatar models.URLField(blankTrue) class Category(models.Model): 藏品大类机车/车辆/线路设备/信号通信/票证文献/其他 name models.CharField(max_length50, uniqueTrue) order models.IntegerField(default0) # 用于前端排序 icon models.CharField(max_length100, blankTrue) class Exhibit(models.Model): 展品基础信息 扩展属性 code models.CharField(max_length50, uniqueTrue) # 铭牌号/档案编号 name models.CharField(max_length200) category models.ForeignKey(Category, on_deletemodels.PROTECT) exhibit_type models.CharField(max_length20) # 实物/模型/场景/数字化复现 era models.CharField(max_length100) # 年代允许模糊表述 origin models.CharField(max_length200, blankTrue) # 来源/捐赠单位 status models.CharField( max_length20, choices[(on_exhibit, 展出中), (stored, 库房), (restoring, 修复中), (pending, 待审核)], defaultpending ) attributes models.JSONField(defaultdict) # 车号/轨距/锅炉压力等扩展参数 cover_image models.ImageField(upload_toexhibits/cover/%Y%m/, blankTrue) description models.TextField(blankTrue) audio_url models.URLField(blankTrue) # 语音讲解 video_url models.URLField(blankTrue) views_count models.IntegerField(default0) # 热点统计用 created_at models.DateTimeField(auto_now_addTrue) class Exhibition(models.Model): 展览一个展览包含多个展品支持专题展/常设展 title models.CharField(max_length200) subtitle models.CharField(max_length300, blankTrue) category models.CharField(max_length50) # 临时展/常设展 start_date models.DateField() end_date models.DateField() hall models.CharField(max_length100) # 展厅名 cover_image models.ImageField(upload_toexhibitions/%Y%m/, blankTrue) description models.TextField(blankTrue) exhibits models.ManyToManyField(Exhibit, related_nameexhibitions, blankTrue) class Reservation(models.Model): 参观预约支持日期场次分区 user models.ForeignKey(User, on_deletemodels.CASCADE, related_namereservations) exhibition models.ForeignKey(Exhibition, on_deletemodels.SET_NULL, nullTrue, blankTrue) visit_date models.DateField() time_slot models.CharField(max_length20) # 09:00-11:00 等 zone models.CharField(max_length20, defaultindoor) # indoor/outdoor visitor_count models.IntegerField(default1) status models.CharField( max_length20, choices[(pending, 待审核), (confirmed, 已确认), (rejected, 已拒绝), (canceled, 已取消)], defaultpending ) remark models.TextField(blankTrue) created_at models.DateTimeField(auto_now_addTrue)3.2 “attributes JSON字段”的深层意义有人可能会问为什么不把“轨距”“锅炉压力”直接建列因为铁路藏品类型跨度太大硬建列会导致大量空字段而且每加一种展品类型就要改表结构。JSON字段的好处是前端表单动态渲染根据exhibit_type读取动态组件机车表单 vs 票证表单提交时整个JSON存进去。查询仍可用MySQL支持json_extractDjango里可以用attributes__track_gauge做过滤。避免频繁ALTER TABLE加入新型展品时只改表单配置不用改库。但代价是可读性和数据完整性下降所以系统里必须配套一个“校验Schema”每个exhibit_type对应一个validation_rules.json文件后端在serializer.is_valid()阶段做规则校验比如机车必须包含build_year且为正整数票证必须包含material和condition字段。3.3 预约模块的防重设计预约是并发场景如果两个人同时抢最后一个名额会出现“超卖”。我在Reservation表里没有设计总名额数字段而是用“场次日期分区”作为唯一并发控制单位class Meta: constraints [ models.UniqueConstraint( fields[visit_date, time_slot, zone], nameunique_reservation_slot, conditionmodels.Q(statusconfirmed), violation_error_message该场次预约已满或重复提交 ) ]同时在create方法里用事务包裹先select_for_update查计数再插入。细节是展览的每日名额要放在ExhibitionDailyQuota表里由馆员在后台配置而不是写死在代码里。否则博物馆遇到节假日加场次时开发要连夜改代码。4. 后端API与权限控制认证、序列化、搜索排序模型定好后后端最核心的事是写DRF视图集。这一节里我会把认证、动态字段校验、搜索排序一次讲清楚。4.1 基于ViewSet的展品接口实现DRF的ModelViewSet可以少写大量重复代码。展品接口核心逻辑如下from rest_framework import viewsets, filters from rest_framework.pagination import PageNumberPagination from django_filters.rest_framework import DjangoFilterBackend class ExhibitPagination(PageNumberPagination): page_size 12 page_size_query_param limit class ExhibitViewSet(viewsets.ReadOnlyModelViewSet): 游客端只能看状态为 on_exhibit 的展品。 馆员端通过管理接口才可以看到草稿/待审核。 serializer_class ExhibitSerializer pagination_class ExhibitPagination filter_backends [DjangoFilterBackend, filters.SearchFilter, filters.OrderingFilter] filterset_fields [category, exhibit_type, status, era] search_fields [name, code, description] ordering_fields [views_count, created_at] ordering [views_count] def get_queryset(self): qs Exhibit.objects.filter(statuson_exhibit) # 支持 attributes 中的年代范围筛选例如 era_min1920era_max1990 era_min self.request.query_params.get(era_min) era_max self.request.query_params.get(era_max) if era_min: qs qs.filter(era__gteera_min) if era_max: qs qs.filter(era__lteera_max) return qs.select_related(category).prefetch_related(exhibitions)这里有两个容易被忽略的点.select_related(category)和.prefetch_related(exhibitions)是避免N1查询的关键。博物馆首页要加载全部展品封面如果不做预取一个列表页可能产生几十次SQL查询被优化后稳定在3次以内。status字段为什么要参与过滤因为馆员编辑展品时若状态是“修复中”游客端立即不可见。这种“前台状态机后台状态机”分离是内容管理系统的通用原则。4.2 JWT认证与双角色权限控制系统里有“游客”和“馆员”两类操作者权限模型不能全写死在视图函数里。SimpleJWT只负责发Token角色校验靠DRF的权限类class IsCuratorOrAdmin(BasePermission): def has_permission(self, request, view): user request.user if not user or not user.is_authenticated: return False return user.role in [curator, admin] or user.is_staff class CuratorExhibitViewSet(viewsets.ModelViewSet): queryset Exhibit.objects.all() serializer_class ExhibitSerializer permission_classes [IsAuthenticated, IsCuratorOrAdmin] def perform_create(self, serializer): serializer.save(authorself.request.user)登录接口用SimpleJWT的标准配置在settings.py里设定过期时间from datetime import timedelta SIMPLE_JWT { ACCESS_TOKEN_LIFETIME: timedelta(hours2), REFRESH_TOKEN_LIFETIME: timedelta(days7), ROTATE_REFRESH_TOKENS: True, }毕设阶段很多人会把JWT密钥放在代码里建议至少通过.env文件加载。另外刷新Token一定要开启ROTATE_REFRESH_TOKENSTrue否则旧Token可以一直续期安全隐患很大。4.3 序列化器的嵌套与写读分离前端列表页只需要封面和名称详情页才需要完整描述和语音链接。所以序列化器要写读分离——列表用精简版详情用完整版class ExhibitListSerializer(serializers.ModelSerializer): category_name serializers.CharField(sourcecategory.name, read_onlyTrue) class Meta: model Exhibit fields [id, name, code, cover_image, category_name, era, status] class ExhibitDetailSerializer(ExhibitListSerializer): exhibitions ExhibitionBriefSerializer(manyTrue, read_onlyTrue) class Meta(ExhibitListSerializer.Meta): fields ExhibitListSerializer.Meta.fields [ description, audio_url, video_url, attributes, exhibit_type, views_count, exhibitions ]一个技巧views_count的自增不要用update每次把整行读出来再加一而是用F()表达式Exhibit.objects.filter(pkpk).update(views_countF(views_count) 1)这样可以避免并发下计数丢失也减少一次查询。5. 前端从页面到组件的实现链路前端部分的重点不是抄代码而是理解“路由结构、状态管理、筛选搜索、数据大屏”这四个模块的组织方式。5.1 路由守卫与角色可见性Vue3项目的路由表按角色分了两组const routes [ { path: /, component: HomePage }, { path: /exhibits, component: ExhibitList }, { path: /exhibits/:id, component: ExhibitDetail }, { path: /exhibitions, component: ExhibitionList }, { path: /reserve, component: Reservation, meta: { requiresAuth: true } }, { path: /admin, component: AdminLayout, meta: { requiresAuth: true, roles: [curator, admin] }, children: [ { path: exhibits, component: AdminExhibitList }, { path: exhibits/new, component: AdminExhibitForm }, { path: reservations, component: AdminReservationList }, { path: dashboard, component: AdminDashboard }, ] } ]登录状态用Pinia管理路由守卫里做两件事sessionStorage里没有Token就跳登录页。userStore.role不在meta.roles列表中时跳转到一个独立的403页面而不是继续渲染。强烈建议不要只在路由里做控制后端API的permission_classes才是真正的安全边界。前端隐藏按钮只是体验优化如果接口不设限懂技术的人直接用Postman就能拉到你所有未上线的展品数据。5.2 展品列表的条件筛选与卡片布局列表页交互是“分类Tab 年代滑杆 搜索框”组合。年代筛选从后端接口参数era_min和era_max同步前端结构script setup langts const filterParams reactive({ category: , exhibit_type: , era_min: 1900, era_max: 2025, keyword: , page: 1, limit: 12, }) async function fetchExhibits() { const { data } await axios.get(/api/exhibits/, { params: filterParams }) exhibits.value data.results totalCount.value data.count } /script避坑提示筛选条件变化时必须把page重置为1否则会出现“在第7页筛选后列表空”的鬼现象。这是前端开发里最常见的小bug加一个watch(filterParams, resetPageAndFetch)即可。卡片设计上铁路主题适合用“铭牌风”——每张卡片模拟铁路路徽序号年代角标。技术上就是对Element Plus的el-card做样式覆盖不复杂但整体质感会提升很多。5.3 数据大屏和ECharts的关键指标管理员端的Dashboard是系统的加分项。我做了四块图表热门展品Top10柱状图数据来源Exhibit.views_count客流预约趋势折线图按visit_date聚合馆藏占比环形图按Category计数展览档期甘特图用横向条形图展示各展览时间区间实现上注意一点大屏数据接口要单独写不要复用列表接口。比如预约趋势接口直接返回聚合好的数组class DashboardView(APIView): permission_classes [IsAuthenticated, IsCuratorOrAdmin] def get(self, request): # 近30天预约趋势 trend (Reservation.objects .filter(visit_date__gtedate.today() - timedelta(days30)) .values(visit_date) .annotate(countCount(id)) .order_by(visit_date)) return Response({ reservation_trend: trend, category_ratio: ..., top_exhibits: ..., exhibition_schedule: ..., })前端拿到数据直接喂给EChartsresize事件记得用监听否则侧边栏折叠时图表会撑破容器。5.4 后台表单的图片上传与动态字段馆员添加展品时图片是老大难。Element Plus的el-upload默认把文件转为Base64如果图片5MB提交的表单会非常臃肿甚至超时。正确姿势是用“直传后端”模式class ExhibitImageUploadView(APIView): permission_classes [IsAuthenticated, IsCuratorOrAdmin] def post(self, request): file request.FILES.get(file) if not file: return Response({error: 未获取到文件}, status400) if file.size 10 * 1024 * 1024: return Response({error: 图片不能超过10MB}, status400) # 压缩处理 img Image.open(file) img.thumbnail((1600, 1600)) name fexhibit_{int(time.time())}.jpg img.save(fmedia/exhibits/uploads/{name}) return Response({url: f/media/exhibits/uploads/{name}})前端上传组件的action直接指向这个接口图片上传完成后返回带上URL的图片列表表单提交时存的是URL字符串这样既快又不会卡请求体。另一个避坑点MEDIA_ROOT和MEDIA_URL必须在settings.py里明确配置并且在urls.py里通过static()辅助函数暴露开发环境下的媒体目录否则后台永远显示图片404。6. 实测阶段踩过的坑与调优记录这一部分是我实际开发中花时间最多的地方也最值得记录下来。很多坑不是看文档能发现的需要自己跑一遍才知道。6.1 跨域配置与代理链路的坑前后端分离开发的第一个拦路虎就是跨域。Django配置django-cors-headers后仍然报跨域错误原因有80%是中间件位置不对MIDDLEWARE [ corsheaders.middleware.CorsMiddleware, # 必须在最前面不能放到后面 django.middleware.security.SecurityMiddleware, ... ] CORS_ALLOWED_ORIGINS [ http://localhost:5173, # Vite开发服务器 ]第二个坑是“开发环境走Vite代理生产环境走Nginx反代”两套配置不能混用。开发环境在vite.config.ts里配置server: { proxy: { /api: { target: http://localhost:8000, changeOrigin: true, }, /media: { target: http://localhost:8000, changeOrigin: true, } } }生产环境直接在Nginx中把/api和/media都转发给Django应用前端就不需要再配代理了。6.2 图片上传Base64与文件流的取舍上面已经提到Base64的弊端这里补充一个实测数据一张约3MB的图片用Base64编码后体积膨胀约33%到4MB。在普通校园网环境下上传要3~5秒体验非常差。改成后端直传后同样大小图片因为做了thumbnail压缩到1MB以内上传速度体感快了一倍。另外Django的ImageField不等于自动处理图片它只负责保存文件路径。真正做缩放要配合Pillow。我写了一个公共函数上传时统一压缩from PIL import Image def compress_image(image, max_size(1600, 1600)): img Image.open(image) img.thumbnail(max_size, Image.Resampling.LANCZOS) if img.mode RGBA: img img.convert(RGB) output BytesIO() img.save(output, formatJPEG, quality85) output.seek(0) return ContentFile(output.read())6.3 大数据量下列表页的性能调优当展品数据超过500条时直接Exhibit.objects.all()不做分页前端会非常卡。DRF自带的PageNumberPagination只是接口层分页真正的问题在QuerySet本身。我做的优化有三个首页展示只拉前8条热门展品用objects.filter(statuson_exhibit).order_by(-views_count)[:8]。列表页强制走分页每页不超过24条并在返回数据里带上count供前端渲染总数。详情页的“相关推荐”不要用Exhibition.objects.filter(exhibits__in...)这种复杂关联会导致索引失效。用category一样查前6条即可简单查询快得多。另外cover_image如果要展示缩略图Nginx层配置一下image_filter或者用前端?x-oss-processimage/resize这类扩展避免大图直出。6.4 部署阶段静态文件与媒体文件的目录隔离很多同学开发完部署时就懵了——前端打包的静态文件和Django的collectstatic该放哪。我的目录结构是这样/path/to/project/ ├── frontend/ # Vue3 项目 │ └── dist/ # npm run build 产物 ├── backend/ │ ├── staticfiles/ # Django 自己收集的静态资源 │ └── media/ # 运行时上传的图片Nginx配置核心片段# 前端静态页面 location / { root /path/to/project/frontend/dist; try_files $uri $uri/ /index.html; # 解决Vue Router history模式刷新404 } # 后端API location /api/ { proxy_pass http://127.0.0.1:8000; proxy_set_header Host $host; proxy_set_header X-Real-IP $remote_addr; } # 用户上传图片 location /media/ { alias /path/to/project/backend/media/; }Django侧还要把DEBUG改为FalseALLOWED_HOSTS填上域名或IP然后执行python manage.py collectstatic配合whitenoise处理静态文件。这一环节容易忽略的是try_files配置少了它点击页面刷新时会出现404或空白页。6.5 上线前必检查的清单最后給一张自检表是我每次交付前都会过的分享出来可以当参考检查项具体判断方法同一场馆预约并发用两个账号同时提交同一场次确认只有一个能成功展品删除保护删除被展览引用的展品时后端应返回友好错误而非500文件上传类型后端校验扩展名和格式不能只信前端accept属性JWT过期处理前端拦截401时跳转登录页并清理Pinia状态移动端适配展品列表、预约表单至少保证375px宽度不溢出数据恢复演练导出数据库备份文件确认能正常导入恢复这个系统的另外两个扩展思路一是给重点机车展品做基于视频的3D轨道环绕展示静态图换成多视角可拖拽的媒体组件二是接入语音合成接口把展品文字描述自动转为解说音频馆员不用人工录制。这两块在答辩时间充裕时都可以加到项目计划里属于“可演示的增量亮点”。如果是从零开始复现这套项目建议顺序是“模型设计 → 后端API → Vue页面 → 部署”不要反过来先调页面否则前端等到后端字段变成交付前三天最容易返工。