市面上讲管理系统开发的博文很多,但大多数要么只谈后端,要么是纯前端界面演示,能真正把 Vue3 + Django 这一前后端分离组合从一个完整项目落地的角度讲清楚的并不多。恰好最近一直在做博物馆展览类系统的相关工作,借着铁路博物馆展览系统这个题目,把从需求拆解到前后端实现、再到部署上线的完整链路整理出来。如果你正准备做类似的展馆类管理/展览系统,或者正在用 Vue3 + Django 组合做毕业设计、项目实战,这篇文章应该能帮你少走不少弯路。
1. 项目认知与需求拆解
1.1 博物馆展览系统的真实需求
先聊聊这类系统到底在解决什么问题。铁路博物馆和一般的企业管理系统不一样,它既有传统信息管理系统的共性——比如后台需要管理展品、维护用户数据,又有很强的行业特殊性——展品本身具有历史价值,需要按照主题、时期、车型、铁路线路等维度分类展示。除此之外,参观者有预约、导航、浏览展品详情的需求,管理人员有统计访客数据、上下架展览内容、处理预约订单的需求。
这些需求归纳起来,就是两条主线:一条是面向普通用户的“展览服务线”,涵盖博物馆介绍、展品展示、在线预约、展览活动浏览等功能;另一条是面向管理人员的“运营管理线”,涵盖展品库管理、展览策划上线、预约审核、用户反馈处理、数据统计等功能。两条线之间存在数据关联,比如用户预约某一个展览场次,后台就需要对应记录并处理。很多初学者做类似系统时容易犯一个错误,就是只顾着做前台的漂亮页面,后台管理做得非常粗糙,或者反过来只做了一堆管理界面,用户端毫无体验可言。实际上这类系统前端和后端的比重差不多,前端侧要兼顾展示效果和信息架构,后端侧要把数据模型设计扎实,这是整个项目的核心难点。
1.2 为什么是 Vue3 + Django
选型这件事,直接决定了后续开发效率。这个项目标题里已经把技术栈定死了:前端 Vue3,后端 Django。抛开被指定这一点不谈,这个组合本身在国产化信息系统、高校项目、中小型展览场馆的信息化建设中都非常常见,原因其实很实际。
Vue3 相比 Vue2,最大的提升在于组合式 API(Composition API)和响应式系统的重构。对于博物馆展览系统这种包含大量列表页、详情页、筛选交互的界面场景,组合式 API 能把同一业务功能的代码聚合在一起,逻辑复用性明显更好。响应式系统的重构也带来了性能提升,展品列表、图片相册这类高频渲染的场景非常受用。再加上 Vite 作为开发服务器,冷启动速度和热更新体验吊打 Webpack 时代,这些都是实打实的效率提升。
Django 这边,自带 Admin 后台、ORM 数据映射、Migrate 数据库迁移机制,以及完善的认证系统。对于“管理端”需求占比较重的展览系统来说,Django 的"全家桶"特性太合适了——不需要额外整合 Spring Security 那一套复杂的东西,一个 Django 项目配合 DRF(Django REST Framework)就能把认证、序列化、路由、权限全部覆盖。通常一个 Rails 或 Spring 项目要配置半天的基础设施,在 Django 里敲几个命令就齐了。数据库默认用 SQLite 开发省事、切到 MySQL 或 PostgreSQL 配置也就几行变化,这为后续生产环境部署留下了很灵活的余地。
这套组合的核心价值在于:前端负责交互体验和视觉呈现,后端专注业务逻辑和数据安全,二次开发成本低,系统维护边界也清晰。实际项目中只要把跨域问题处理好,开发时的配合体验是相当顺畅的。
1.3 核心功能模块规划
博物馆展览系统的功能模块大致可以拆成这几个核心块:
- 用户端:博物馆概况展示、展品分类浏览、展览活动日历、在线预约参观、个人预约记录、展品搜索。
- 管理端:管理员登录、展品管理(增删改查、图片上传)、展览策划管理、场次与时段设置、预约审核与管理、用户管理、数据看板统计。
- 公共模块:用户注册登录、Token 鉴权、统一响应格式、文件上传管理。
模块划分的原则是“先纵向分层、再横向切块”。纵向指按用户端/管理端区分,横向指按业务域拆模块,比如展品域、展览域、预约域、用户域。每个业务域内部包含前端页面、状态管理、API 调用、后端模型、接口序列化器、视图函数,形成完整的闭环。做好这个规划,后面写代码不会乱。
2. 数据库与后端架构设计
2.1 数据模型设计
数据模型是这种展览系统的地基。设计的时候我习惯先从业务对象入手,识别出核心实体,再梳理它们之间的关系。铁路博物馆系统的核心实体有这几个:用户(普通参观者和管理员)、展品、展览、预约订单、展品类别、图片资源。
以展品来说,它的字段设计需要覆盖几个维度的信息:基础字段包括展品名称、编号(唯一)、年代、型号、产地、尺寸、材质、描述等;分类字段包括所属主题分类、所属时期、所属铁路线路;状态字段包括是否上架展示、是否借调外出等。这些字段的取值策略要提前想清楚,比如展品状态是直接存字符还是用数字枚举,推荐用数字加字典映射,虽然前端要多做一步翻译,但后续扩展状态时会省非常多事。
展览实体建议单独建表,不要直接挂在展品上。原因很简单:一个展览会包含多个展品,一个展品也可能在不同时期参加不同展览,这是明显的多对多关系。如果图省事在展品表里加一个展览外键,后期策划新展览时就得改一堆展品数据。正确做法是建立一张关联表,记录展览和展品之间的归属关系,同时可以扩展出“展品在展览中的序号”“是否重点推荐展品”这类额外信息。
预约订单表也要单独设计,包含预约人(外键关联用户)、预约日期、参观时段、同行人数、联系电话、订单状态(待审核/已通过/已拒绝/已取消)、创建时间。这里要注意的是,用户可能一次预约多人参观,所以订单表还要冗余记录一下人数信息。曾见过有人把人数写在备注字段里,这种做法在统计报表时会让你非常痛苦。
2.2 Django 项目结构搭建
Django 项目创建的时候,建议按业务域拆分成多个 app,而不是把全部功能塞到一个 app 里。这个项目的实践做法是拆成这几个:
- users:用户注册、登录、Token 鉴权、个人资料。
- exhibits:展品管理、分类管理、图片资源。
- exhibitions:展览策划、展览-展品关联。
- bookings:预约场次、预约订单、审核流程。
- dashboard:数据统计(也可以合并到 bookings,但独立出来更好维护)。
为什么要拆分?从代码组织角度讲,每个 app 维护自己的 models、serializers、views、urls,职责边界清晰;从团队协作角度讲,多个人同时开发时减少代码冲突;从后期维护角度讲,某一业务模块改动时不会误伤其他模块。虽然单文件也能写出功能完整的应用,但说实话,规模稍大一点(比如超过二三十个接口),单文件就会变成千行级的“屎山”,调试和排查问题的成本直线上升。
数据库配置这块,本地开发直接用 SQLite 就行。一个大型展览系统的数据量通常不会太大,SQLite 完全能支撑开发测试阶段。但部署到生产环境前,还是建议切换到 MySQL 或者 PostgreSQL。切换方式不复杂:改一下 DATABASES 配置,执行 python manage.py migrate,再把数据通过 fixtures 导出导入。不过要注意,SQLite 和 MySQL 在字段类型、索引策略上有些差异,如果项目里用了 Django ORM 的某些特定函数,可能需要微调查询逻辑。
2.3 接口设计与 RESTful 规范
后端接口设计直接采用 RESTful 风格。以展品为例,核心接口如下:
- GET /api/exhibits/ —— 展品列表(支持分页、分类筛选、关键词搜索)
- POST /api/exhibits/ —— 新增展品(管理端)
- GET /api/exhibits/{id}/ —— 展品详情
- PUT /api/exhibits/{id}/ —— 修改展品
- DELETE /api/exhibits/{id}/ —— 删除展品
接口返回统一使用 JSON 格式,并且封装统一的响应结构,比如 { code: 20000, message: "success", data: {...} }。这套结构的价值体现在前端处理时不需要每个接口单独做错误判断,统一拦截即可。code 用业务码区分(20000 成功、40001 参数错误、40002 未登录、40003 无权限),message 给用户友好的提示,data 装载真正的业务数据。
分页策略也要提前约定。DRF 默认有 PageNumberPagination,前端传 page 和 page_size 参数,后端返回当前页数据的同时带上总数 total,方便前端做分页组件。这里要注意一个细节:total 的获取在数据量大时会影响接口性能,可以在查询集上先调用 .count() 而不是把数据全部取出来再 len(),对于百万级数据来说是质的差别。
搜索功能方面,可以在 DRF 的 filter_backends 里配置 SearchFilter 和 OrderingFilter。SearchFilter 支持多字段模糊搜索,比如展品名称、编号、描述。OrderingFilter 支持按创建时间、更新时间、参观热度排序。这些现成的 DRF 功能能省下大量手写查询逻辑的时间。
3. 后端核心模块实现
3.1 用户认证与权限控制
Django 自带的认证系统默认基于用户名和密码,而现代前后端分离项目通常希望使用 JWT(JSON Web Token)来做无状态认证。实现方式可以选择 DRF 配合 rest-framework-simplejwt 库,也可以用 django-rest-framework 原生的 TokenAuthentication。个人更推荐 SimpleJWT,因为 JWT 天然适合移动端、小程序、Web 多端共用接口的场景,同时支持 Token 过期刷新,安全性更好。
认证流程是这样的:用户登录时提交用户名密码,后端验证成功后签发两个 Token——access_token(短期有效,比如 24 小时)和 refresh_token(长期有效,用于刷新)。前端把 access_token 存到内存或者 localStorage,每次请求在请求头带上 Authorization: Bearer ,后端通过 SimpleJWT 中间件自动解析验证。Token 过期后前端用 refresh_token 自动换取新的 access_token,用户无需重新登录,体验比较顺畅。
权限控制这块,DRF 提供了 IsAuthenticated、IsAdminUser 等权限类。实际项目中,展品的增删改查接口需要登录且是管理员才能操作,普通参观者只允许查看列表和详情。可以直接在视图类里配置 permission_classes = [IsAdminUser],也可以在具体接口方法上用 @permission_classes 单独控制。更细粒度的权限(比如某个管理员只能管理某一分类的展品)可以通过自定义 permission 类实现,在 has_object_permission 方法里做逻辑判断。
这里要特别提醒:不要在前端做权限判断就算完了,后端接口层的权限校验是必须要做的。很多人会习惯性地把按钮隐藏就当权限控制了,但接口实际上仍然可以被直接调用。我有个习惯,写完接口第一件事就是用 Postman 或者 curl 不携带 Token 打一下,确认返回 401,再带着普通用户 Token 打一下,确认返回 403,最后才用管理员 Token 验证正常流程。这能挡掉一大半被接口爆破的风险。
3.2 展品与展览模块
展品模块是整个系统的信息核心。模型层我用一个 Exhibit 模型承载展品主数据,一个 ExhibitImage 模型承载多图。为什么不把图片直接写死成几个字段?因为一个展品的图片数量不固定,可能一两张,也可能十几张,用单独表存的好处是扩展性高,回到前端就表现为详情页图片轮播的数量可以自由变化。
展览模块的设计稍微复杂一点。Exhibition 模型记录展览的名称、主题、简介、封面图、开始日期、结束日期、开放状态。ExhibitionItem 关联表记录展览和展品的对应关系,额外增加 sort_order 排序字段和 is_featured 推荐位标志。当展览的开放状态是开启时,用户端才能在展览列表中看到这个展览;后台策划人员调整展品顺序时,只需要修改关联表的 sort_order,不会动到展品主表。
接口层面,展览列表推荐使用嵌套序列化器输出展品摘要信息。比如 GET /api/exhibitions/{id}/ 时,返回数据里直接带上展品的基本列表,省去前端拿到展览 ID 后再二次请求展品接口,减少一次往返。DRF 的 SerializerMethodField 或者嵌套 Serializer 都可以实现。性能方面注意用 select_related 和 prefetch_related 避免 N+1 查询。
具体的实现代码(简化版)可以这样写:
# exhibitions/models.py class Exhibition(models.Model): title = models.CharField(max_length=100) description = models.TextField() cover = models.ImageField(upload_to='exhibition_covers/') start_date = models.DateField() end_date = models.DateField() is_active = models.BooleanField(default=True) class ExhibitionItem(models.Model): exhibition = models.ForeignKey(Exhibition, related_name='items', on_delete=models.CASCADE) exhibit = models.ForeignKey(Exhibit, related_name='exhibition_slots', on_delete=models.CASCADE) sort_order = models.IntegerField(default=0) is_featured = models.BooleanField(default=False) class Meta: ordering = ['sort_order']这样设计之后,无论新增展品还是调整展览内容,都只需要操作对应记录,数据收敛性非常好。
3.3 预约与票务逻辑
预约系统是这个项目里最容易出 Bug 的地方,因为涉及到日期和数量的判断。需求通常是这样的:用户选择某天某时段,提交预约申请,后台管理员审核通过后才算预约成功。有些场馆做到了自动确认,但大多数中低流量场馆还是人工审核更稳妥。
预约的核心逻辑要处理几点:同一用户同一天不能重复预约同一个场次;每个场次有人数上限;预约人数不能为零或负数;历史日期的场次不能预约。
实现时,Booking 模型里存储预约日期、时段、人数、用户、状态、创建时间。校验逻辑放在 Serializer 的 validate 方法里,确保数据进入数据库之前就被拦截:
def validate(self, attrs): user = self.context['request'].user visit_date = attrs.get('visit_date') session = attrs.get('session') visitors = attrs.get('visitors', 1) if visit_date < timezone.now().date(): raise serializers.ValidationError('不能预约过去的日期') if Booking.objects.filter(user=user, visit_date=visit_date, session=session, status__in=['pending', 'approved']).exists(): raise serializers.ValidationError('您已预约过该场次') if visitors < 1 or visitors > 10: raise serializers.ValidationError('单次预约人数必须在1到10人之间') return attrs人数上限的校验可以放在视图层处理。先把当天的所有已通过预约人数汇总,加上本次预约的人数,判断是否超过场馆容纳上限。这里要注意竞态条件:如果两个用户同时提交预约,可能都通过了校验。解决方案有几种:最简单的是在数据库读时加锁(select_for_update),或者用事务串行化。Django 里可以用 transaction.atomic() 配合 select_for_update 实现,确保同一时间只有一个请求在处理该时段的预约。虽然对小型展馆来说并发量不大,但设计和实现时具备这个意识,后面展示系统扩展到其他场馆时不用推翻重来。
3.4 后台管理功能
Django 自带的 Admin 后台在某些场景可以直接用,但实际面对博物馆的工作人员——他们通常是文科背景,对复杂表单的容忍度很低——最好还是用 Vue3 单独开发一个管理前端。管理端的功能覆盖几个方面:一个综合看板展示核心指标(今日访客预约数、本月参观人次、最热展品TOP5、展览上架数量);展品列表支持搜索、筛选、分页、批量上下架;预约管理需要支持查看详情、审核通过/拒绝、记录审核人;展览策划要支持拖拽排序调整展品顺序;用户管理包括查看用户列表、封禁账号。
这部分用到的技术和用户端相似,但代码组织上可以做独立路由和独立布局。管理端和用户端共用同一个 Vue 项目,通过路由的 meta 字段区分是否需要管理员权限。全局路由守卫在 beforeEach 中判断用户角色,非管理员访问管理端路由时直接重定向到登录页或首页。
我做这类系统的一个经验是,管理端的表格页面尽量把所有筛选条件和操作按钮放得显眼一些。博物馆工作人员通常没有技术背景,你设计的界面如果让他们找不到“审核”按钮在哪里,后续运维就会变成你不断远程协助的局面。以及,列表页的排序和筛选要记住状态——有些员工会在筛选条件下翻页,刷新页面后条件丢失,体验很差。可以把筛选条件缓存到 sessionStorage 或 Pinia store 中,翻页和刷新时自动找回。
4. 前端 Vue3 项目实战
4.1 工程化搭建与目录规划
前端项目用 Vite 初始化,命令是 npm create vite@latest museum-frontend -- --template vue。初始化后安装主要依赖:vue-router(路由)、pinia(状态管理)、axios(HTTP 请求)、element-plus(UI 组件库)、sass(样式预处理)。
目录规划上,我习惯按照功能模块组织而不是按文件类型组织:
src/ ├── api/ # 接口请求模块,按业务域拆分 │ ├── auth.js │ ├── exhibit.js │ ├── exhibition.js │ └── booking.js ├── assets/ # 静态资源 ├── components/ # 全局通用组件 │ ├── ExhibitCard.vue │ ├── Pagination.vue │ └── ImageUploader.vue ├── router/ # 路由配置 ├── stores/ # Pinia 状态管理 ├── views/ # 页面组件 │ ├── user/ # 用户端 │ └── admin/ # 管理端 ├── utils/ # 工具函数 │ ├── request.js # axios 实例封装 │ └── auth.js # Token 处理 └── App.vue为什么要拆成 user 和 admin 两个目录?因为这两个角色的页面视觉风格、布局结构、交互模式差异很大。用户端偏向信息展示型,大面积图文混排;管理端偏向操作密集型,表格和表单为主。分开管理后,样式文件的组织也更清晰,不会互相污染。
Vite 开发体验确实比 Webpack 好很多,但有一个需要注意的地方:Vite 的开发模式用 ESM,生产构建用 Rollup,两者在打包行为上有些微差别。比较常见的就是开发环境图片路径没问题,打包后图片 404。解决方法是所有静态资源统一用 import 引入或放在 public 目录下,不要在前端代码里写死相对路径。
4.2 路由与状态管理设计
路由设计分两层。用户端的路由包括首页、博物馆概况、展品列表、展品详情、展览日历、在线预约、个人中心、登录注册。管理端的路由包括管理后台首页、展品管理、展览管理、预约审核、用户管理、数据统计。每个路由配置里都用 meta 字段标记 title 和 requiresAuth、requiresAdmin 属性。
路由守卫的设置很关键。完整实现:
router.beforeEach((to, from, next) => { const authStore = useAuthStore() if (to.meta.requiresAuth && !authStore.isLoggedIn) { next({ path: '/login', query: { redirect: to.fullPath } }) return } if (to.meta.requiresAdmin && !authStore.isAdmin) { next({ path: '/', message: '无权限访问' }) return } next() })这里踩过的坑是:页面刷新后 Pinia 里的用户状态会被清空,导致路由守卫误判为未登录。解决方案是在 Pinia 的 store 初始化时从 localStorage 读取 Token 和用户信息,并且在全局路由守卫里增加“如果状态为空但本地有 Token,则先调用 getUserInfo 拉取用户信息”的处理。虽然多了一次请求,但刷新后用户体验是完整的。
状态管理这块,用 Pinia 管理三个核心 store:authStore 管用户登录态和用户信息;exhibitStore 管展品列表的缓存——注意这里最好按搜索条件做缓存 key,否则缓存容易失控;bookingStore 管预约流程的临时状态。
我见过不少人把后端返回的数据全部塞进 store,导致 store 膨胀,且与后端数据不同步。这种做法在简单项目里可以勉强运转,但这是严重的技术债。正确思路是 store 只保存后端数据的镜像,任何修改操作走接口,接口返回后更新 store。避免直接操作 state 里的深层字段,特别是从接口拿到的对象,操作失误会导致响应丢失。
4.3 核心页面开发细节
用户端的展品列表页是重点。这个页面包含分类侧边栏、搜索框、排序方式、展品卡片列表、分页组件。展品卡片上展示缩略图、名称、时代、类别,点击进入详情页。Vue3 组合式 API 的实现思路是把“获取展品列表”的逻辑抽成 composable,比如 useExhibitList 函数:
// composables/useExhibitList.js export function useExhibitList() { const exhibits = ref([]) const total = ref(0) const loading = ref(false) const page = ref(1) const pageSize = ref(12) const filters = reactive({ category: '', keyword: '', sortBy: 'latest' }) const fetchList = async () => { loading.value = true try { const res = await exhibitApi.getList({ page: page.value, page_size: pageSize.value, category: filters.category, search: filters.keyword, ordering: filters.sortBy }) exhibits.value = res.data.items total.value = res.data.total } finally { loading.value = false } } watch([filters, page], fetchList, { immediate: true }) return { exhibits, total, loading, page, pageSize, filters, fetchList } }这样页面组件只需要调用 useExhibitList 即可,逻辑复用性很强。后续如果有移动端同类型页面,直接复用这个 composable 就行。
展品详情页需要做成"图文 + 档案"的结构。头部是大图轮播,下面是展品基本信息表格,再往下是详细介绍文本。如果展品有历史影像或 3D 模型,可以在这里嵌入视频组件或第三方 3D 查看组件。铁路博物馆的展品特性是强调时间和空间维度,所以详情页里可以用时间轴组件展示展品年代节点,比如某个机车型号的研发年份、使用时间、退役年份等。
展览日历页是另一个值得细化的页面。用户需要查看未来一个月的展览排期,并选择某一天的某个时段预约。这里面临一个常见问题:日历组件默认只展示日期,但展览信息往往绑定的是某段时间范围。实现方式可以分两步:先拉取当月所有已开放的展览列表,再根据每个展览的开始结束日期,在日历上标记对应日期区间;用户点击某个有展览的日期后,下方展示该日可预约的场次时段。
管理端的展品管理页面同样要有好的交互设计。表格列需要支持自定义显示,行内操作放“编辑”“上下架”“删除”。编辑用对话框弹出表单,表单里有图片上传组件(支持多图、拖拽排序、预览)。图片上传组件封装比较费时间,但用 Element Plus 的 el-upload 配合 OSS 直传或后端本地存储都能实现。我务实建议用后端本地存储加 Nginx 静态文件服务,对中小型系统来说最简单可靠,不需要额外引入云存储依赖。
5. 前后端联调与体验优化
5.1 Axios 封装与跨域处理
前后端分离项目的联调阶段,首要处理的是跨域问题。开发时前端跑在 5173 端口,后端跑在 8000 端口,两个端口不同就产生了跨域。Django 后端通过 django-cors-headers 解决:
INSTALLED_APPS = [ ... 'corsheaders', ] MIDDLEWARE = [ 'corsheaders.middleware.CorsMiddleware', ... ] CORS_ALLOWED_ORIGINS = [ 'http://localhost:5173', 'http://127.0.0.1:5173', ]生产环境下更推荐的做法是用 Nginx 做反向代理,统一入口域名,这样不存在跨域问题,django-cors-headers 的配置甚至可以去掉。但开发期保留 CORS 配置能极大提升调试效率,临时演示的时候也方便。
前端 axios 封装时需要注意几个点:设置 baseURL,开发环境用 Vite 的代理配置:
// vite.config.js export default defineConfig({ server: { proxy: { '/api': { target: 'http://127.0.0.1:8000', changeOrigin: true } } } })采用 /api 前缀后,前端请求接口只需要写相对路径,后端接口从 /api/... 开始配置。这样避免了每个请求写完整 URL 的冗余。
Axios 拦截器需要做两件事:请求拦截器自动在请求头带上 Token;响应拦截器统一处理错误码。特别是 401 的场景——Token 过期后,响应拦截器自动调用 refresh 接口尝试刷新 Token,刷新成功则重新发送原请求,刷新失败则跳转登录页。这个机制是保证用户体验的关键,不然用户正在浏览展品,突然跳登录页,非常突兀。
5.2 富文本与图片上传
展品的详细介绍和展览的策展说明都需要富文本。选择富文本编辑器时,建议用 wangeditor5 或者 tiptap。这两个编辑器对 Vue3 的支持都很好,功能覆盖加粗、标题、引用、图片插入、表格等。富文本产生的 HTML 内容写入后端时需要注意两点:
一是 XSS 防护。Django 模板默认会转义 HTML,但如果前端把 HTML 直接渲染到页面上,还是有风险。前端渲染时可以用 DOMPurify 清洗一遍,这个库专门做 HTML 消毒。管理端录入时也可以在提交前用后端白名单校验,只允许 p、br、img、strong、h2-h4、ul、ol、li、a 这些常用标签,其他标签全部过滤掉。
二是图片存储。富文本中插入的图片如果和展品图库混在一起,时间长了管理端会非常混乱。建议上传时增加分类参数,用 upload_type 区分是展品图库还是富文本内容图片,后端按类型存储到不同目录。数据库层面可以用一个统一的 FileAsset 模型来记录文件路径、类型、上传人、上传时间,展品图和内容图都挂在这个模型的关联下。
图片上传后要注意压缩处理。现在的手机拍出来的照片动不动几 MB,直接上传会影响加载速度。后端用 Pillow 库,在上传时自动把图片压缩到合适的尺寸。比如展品列表页缩略图统一压缩到宽度 800px,封面图 1200px,同时转成 WebP 格式。实测这样处理后,同样质量下图体积缩小约 60% 到 75%,页面加载速度的提升是非常明显的。
5.3 响应式适配与移动端兼容
博物馆参观者在使用预约功能时,绝大多数用的是手机,所以前端响应式适配绝不是可有可无的加分项,而是刚需。实现思路是:UI 框架选 Element Plus,配合 Tailwind CSS 的响应式断点体系,整体上采用桌面端完整布局、移动端单列布局的策略。
移动端导航栏的处理是最影响体验的部分。桌面端可以是顶部横向导航,移动端就要变成抽屉式侧边栏或底部 Tab 栏。展品列表页在移动端要把筛选条件收起,做成顶部标签切换或侧滑筛选面板。预约表单在移动端要减少字段冗余,把日期选择和场次选择设计成横滑卡片的形式。
测试移动端效果时,不要只看浏览器开发者工具的设备模拟。真实的移动设备屏幕还有物理像素比、浏览器底部安全区等变量,最稳妥的方式是准备一台真机,或者用云真机服务,把预约、搜索、图片轮播这些核心流程完整走一遍。我踩过的坑是桌面端弹窗在移动端偏左偏右,或者弹出键盘时固定定位元素跳动。现在的习惯是每实现一个页面,立刻调整到移动端宽度检查一遍,不留到最后统一处理。
6. 常见问题与排查技巧
6.1 跨域问题排查实录
新人最容易卡住的问题就是跨域。常见报错是 Access to XMLHttpRequest at 'http://localhost:8000/api/...' from origin 'http://localhost:5173' has been blocked by CORS policy。
排查思路按顺序来:先确认浏览器请求是不是 OPTIONS 预检请求——Django 的 corsheaders 如果配置正确,预检请求会正常返回。如果预检都没通过,优先看 MIDDLEWARE 顺序,CorsMiddleware 必须放在最前面,因为它要处理所有请求的响应头。中间件顺序错了,后面怎么配置都不生效。
其次确认 CORS_ALLOWED_ORIGINS 是否精确匹配。注意 http://localhost:5173 和 http://127.0.0.1:5173 是两个不同来源,同时也要小心结尾不能带斜杠。debug 的时候可以直接把 CORS_ALLOW_ALL_ORIGINS 设为 True 排查,确认是配置问题而不是逻辑问题后再收敛回白名单模式。
最后要养成查看实际响应头的习惯。浏览器 Network 面板里看一下响应头有没有 Access-Control-Allow-Origin 字段。有这个字段且值匹配,才能说明跨域配置生效。很多人配置完代码后发现还是跨域,其实就是后端服务没重启或者改了代码没生效。
6.2 图片上传与静态文件问题
Django 开发环境处理图片比较简单,urls.py 里加上 static() 配置就行。但生产环境如果不配 Nginx 静态文件服务,Django 处理文件请求的效率会非常低。推荐配置是:后端只负责接口和文件存储位置,Nginx 负责 /media/ 路径的访问映射。这样浏览器请求图片时根本不进 Django 应用,直接由 Nginx 返回文件,性能压力完全分散开。
实际上传时还容易遇到几个怪问题。一个是上传后图片在本地磁盘存在,但通过 URL 访问 404。这种情况八成是 MEDIA_ROOT 和 MEDIA_URL 的配置不一致,或者 Nginx 的 alias 路径写错了。另一个问题是图片更新后浏览器缓存不刷新——因为图片 URL 没变。解决方案是在图片 URL 后面拼接版本参数,例如 {expic.url}?v={expic.updated_at.timestamp},这样每次更新都会生成新的 URL,浏览器强制拉新。
6.3 预约并发与数据一致性问题
预约并发问题在开发环境很难复现,但上线后如果遇到热点展览,可能出现“预约成功但实际人数超了”的数据问题。原因是两个用户同时发起预约,都通过了人数校验,然后先后写入数据库。
解决办法是使用 Django 的数据库锁。在事务中先 select_for_update 锁定当日场次的统计行,再执行插入操作。这样两个请求不会同时通过校验。具体实现:
from django.db import transaction with transaction.atomic(): counter = SessionCounter.objects.select_for_update().get( visit_date=visit_date, session=session ) if counter.booked_count + visitors > session.capacity: raise BookingLimitExceeded counter.booked_count += visitors counter.save() booking = Booking.objects.create(...)另外一个常见问题是无意中创建了重复的预约记录。除了业务校验外,数据库层也可以加 UniqueConstraint 约束,对 user、visit_date、session 字段做联合唯一,双保险。这样即使服务层有疏漏,数据库也会兜底拦截。
6.4 Django 版本与依赖兼容问题
Django 版本升级带来的兼容问题,值得单独提一下。这个项目如果使用了 Django 4.x 或更高版本,需要注意几个变化:USE_L10N 已被移除,时区处理统一由 USE_TZ 控制;Django 3.1 之后 models.JSONField 已经内置,不需要依赖第三方库;django-cors-headers 需要更新到 3.13 以上才能兼容 Django 4.x。
如果遇到第三方库不兼容的情况,首要解决思路不是换库,而是先确认 Python 虚拟环境里的包版本组合。我通常会导出一份 requirements.txt 并且锁定版本号,避免团队协作时环境不一致导致的“项目在我电脑上可以跑”这种经典问题。在项目 README 里写明 Python 版本、Node 版本、依赖安装命令,也是节省沟通成本的好习惯。
如果是老项目从 Vue2/Webpack 迁移到 Vue3/Vite,迁移过程最容易出问题的就是 Element UI 升级为 Element Plus 之后组件 API 的差异。比如 el-dialog 的 visible 属性改为 model-value,el-table 的 slot-scope 改成 #default="scope"。这种改动没有技术难度,纯体力活,但容易遗漏。没别的办法,只能逐个页面过一遍。好在项目和组件数量最多几十个页面,集中一个周末就能完成。
7. 部署上线与经验总结
7.1 Django 后端部署要点
生产环境部署时,后端部分有几个环节要处理好。第一个是 DEBUG 必须设为 False,并且 ALLOWED_HOSTS 配置成实际的域名或 IP,比如 ["www.example.com", "example.com"]。如果漏了这个配置,线上访问直接 500,而且真出问题时报错信息不具体,排查起来很头痛。
第二个是数据库切换到 MySQL 或者 PostgreSQL。切换后记得重新执行 migrate,并且做一次完整的数据导入。注意 MySQL 需要用 utf8mb4 字符集才能支持生僻字和 emoji,Django 连接配置里 OPTIONS 需要指定 charset:
DATABASES = { 'default': { 'ENGINE': 'django.db.backends.mysql', 'NAME': 'museum_db', 'USER': 'museum_user', 'PASSWORD': 'your_password', 'HOST': '127.0.0.1', 'PORT': '3306', 'OPTIONS': { 'charset': 'utf8mb4', }, } }第三个是 Gunicorn 启动服务。一般配置方式是创建 gunicorn_config.py,设置 workers = 2 到 4(根据服务器 CPU 核数),绑定 127.0.0.1:8000,配合 systemd 或 supervisor 守护进程。这样 Django 应用自己成为一个服务,然后由 Nginx 反向代理对外提供访问。
7.2 前端打包与 Nginx 配置
前端打包就是 npm run build,产物在 dist 目录。保证接口请求走的是相对路径 /api,然后前端部署和反向代理都通过 Nginx 统一转发。
一个典型的 Nginx 配置如下:
server { listen 80; server_name www.example.com; # 前端静态文件 location / { root /var/www/museum-frontend/dist; try_files $uri $uri/ /index.html; # 支持 Vue Router history 模式 } # 后端接口反向代理 location /api/ { proxy_pass http://127.0.0.1:8000; proxy_set_header Host $host; proxy_set_header X-Real-IP $remote_addr; proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; } # 上传文件访问 location /media/ { alias /var/www/museum-backend/media/; } # Django 静态文件(如果有的话) location /static/ { alias /var/www/museum-backend/static/; } }这里有几个容易出错的细节。第一,try_files 配置了 /index.html 之后,Vue Router 的 history 模式在刷新子路由页面时才不会 404。第二,proxy_pass 后面的 URL 如果带了 /api 前缀,要注意斜杠的问题——两者是否有路径重叠会直接影响转发结果。第三,上传文件访问路径一定要确认 alias 目录权限可读,Nginx 的 worker 用户(一般是 www-data 或 nginx)需要具备读取权限。
前端部署时还有一个问题:刷新后 404 或者接口 404。接口 404 时去后端日志看,如果请求打到了 Django 但路径不对,大概率是 Nginx 转发路径拼接的问题;如果请求根本没到 Django,说明 Nginx 的 location 匹配规则有问题。先用 curl 直接打后端接口确认后端能通,再用 curl 打 Nginx 的地址对比,基本两个 curl 就能定位到问题出在哪一层。
7.3 我踩过的几个坑
最后分享几个实际操作中遇到过的坑,这些在教科书上基本看不到,但每一个都实实在在地消耗过时间。
第一个是 Django 的 DateTimeField 时区问题。开发环境用的是本地时区,部署到服务器后忘了设置 TIME_ZONE,服务器时区默认是 UTC,导致用户预约的日期差了几个小时,前端的日历显示和后端存储的日期对不上。解决方法是 Django 配置文件里设置:
TIME_ZONE = 'Asia/Shanghai' USE_TZ = True这里特别要注意 USE_TZ 开启时,ORM 读出的 DateTimeField 都是 UTC 时区的 datetime,前端拿到后如果不做时区转换,显示就会偏差。常见的处理方式是在 Django 序列化器里把时间转成字符串输出,或者前端统一用 dayjs 做本地时区转换。
第二个是和图片文件权限相关的问题。服务器上用 root 用户启动了 collectstatic 或者上传测试文件,之后会发现 Nginx 无法访问 media 目录——因为 Nginx 的用户没有读权限。排查这种问题的时候不要盲目怀疑代码,先 ls -l 看看目录和文件的权限。好习惯是部署时用专门的部署用户运行 Django 服务,设置好目录所有者,尽量不用 root 跑应用。
第三个是 Vue Router 切换路由后页面不滚动到顶部。用户从展品详情页滚动浏览完,点击返回列表页时,页面还停留在原来的滚动位置,观感很差。解决方案是在路由切换后在全局 afterEach 钩子里执行 window.scrollTo(0, 0),或者配置 scrollBehavior 返回 { top: 0 }。这个细节虽然小,但对用户体验影响很大。
第四个是(这是一个经常被忽略但在多表单页面惨痛过的教训)——浏览器自动填充。Chrome 等浏览器会自动填充用户名密码,但有时会把密码填充到搜索框或者其它表单输入框中,干扰表单数据。特别是管理端的展品添加表单,里面明明没有密码字段,浏览器却自动填充了一串字符。解决方案是给表单加上 autocomplete="off" 属性,或者给输入框设置不同的 autocomplete 值,比如"off"、"new-password"等,视场景而定。
还有一个容易被忽视的细节,是接口返回的数据格式里如果有 null 值,前端渲染时不做防御,直接访问 undefined 的属性会报错。比如展品的 manufacture_date 字段为空时,详情页里本来要渲染型号参数表格,某一行是 undefined。处理办法是前端统一封装一个 safeGet 工具或者用 lodash 的 get 方法,模板里也可以用 v-if 做空值判断。这个习惯养成之后,页面报错的概率会大幅下降。
7.4 系统的未来扩展方向
博物馆展览系统这类项目,做完基础功能只是第一步,实际上有很大的扩展空间。后续可以考虑的方向包括:展厅室内导航与展品定位;语音导览或扫码导览,用户看到某个展品时,扫码直接收听语音介绍;虚拟展厅或 360 全景展示;在线文创商城;观众留言与互动社区;数据统计分析升级,比如热度预测、用户画像等。
这些扩展方向在现有架构上基本都能兼容。数据模型层面,只需要补充对应的模型和接口;前端层面,新增路由和页面即可。技术的可扩展性来自最初架构设计时对边界的清晰划分,这也是为什么我一直强调前端按模块组织、后端按业务域拆分的重要性。
如果想把系统真正落地使用,还可以做一版配套的小程序或者 H5 轻应用。Django 后端接口不需要变,搬过去就能用。小程序端复用现有接口能节省大量的开发时间,只需要在认证方式上额外适配小程序登录,即 wx.login 换取 openid 再绑定到系统用户体系即可。
我自己在实操这个系统的过程中,最大的感悟是:做这类“传统行业+互联网”的展览系统,一定要真正理解业务方的使用场景和习惯。技术难点其实不多,但需求理解和用户体验设计上的坑很多。如果一开始就能把展品、展览、预约的模型关系理清楚,把权限边界分明白,后面大部分联调问题都可以避免。希望这篇记录能帮到你,哪怕只是少踩一两个我踩过的坑,这个整理就很值了。