简介:这是一份基于Python与Django的博物馆藏品数字化管理系统完整项目实例文档,面向具备Python和Django基础、有志于文博信息化与文化遗产数字化管理的开发者、系统设计师及项目经理,解决藏品从入藏、编目、保管、流转到修复的全生命周期管理问题。文档以藏品为核心,系统展开项目背景、模型架构、功能模块、数据库设计、前后端实现与部署应用,涵盖藏品档案、分类库位、数字资源、出入库与修复、权限控制及审计日志等完整业务闭环。包体仅1个docx文件,大小114KB,内容包含数据库表结构说明、RESTful API设计规范、Django模型/序列化器/视图集代码解析以及Vue.js前端调用示例,便于按章节精读与本地复现。目前已有123人学习下载,适合作为从业务需求到软件系统落地的教学案例,可帮助读者快速理解Django复杂业务建模与前后端分离开发的关键技术。
1. 从纸质台账到数字化系统:这套 Django 藏品管理项目到底做了什么
某博物馆的库房管理员在季度盘点时发现,三件藏品的纸质登记卡片和电子表格各写各的——两件显示在库,实物却在展厅,另一件状态完全对不上账。这种账实不符在依赖纸质总账、卡片档案和分散表格的传统管理方式里太常见了。这套基于 Python 和 Django 的博物馆藏品数字化管理系统,就是把藏品登记、分类字典、库位管理、图片数字资源、出入库流转、修复记录、权限与审计日志串成一条以藏品唯一登记编号为主线的业务闭环,MySQL 存结构化数据,Django 提供 RESTful API,前端用 Vue 消费接口。它最直接的价值,是让任何一件藏品从入藏、编目、保管、流转到修复的每一次变化都有记录可查、有权限可管、有日志可追。适合正在做文博信息化、文化遗产数字化项目的人,也适合拿它当教学案例或毕业设计原型来拆解。这套资源不只是一堆代码,更是一套把业务需求翻译成软件系统的完整范例。
2. 领域模型与 MySQL 表结构:先把藏品生命周期变成可查询的数据
拿到这套资源,第一步别急着跑代码,先把数据模型读透。这类管理系统最怕后期发现字段不够、关系拆错,返工成本远高于写代码本身。项目把藏品实体作为业务中心,分类、库位、图片、出入库、修复、审计表的关联都围绕藏品主档案展开。建模决策直接决定后续查询、统计、审批流程的复杂度,我在拆这个项目时,花了最多时间的就是模型这一层。
2.1 藏品主档案与分类字典:字段怎么定才不至于返工
藏品主档案表看起来字段多,但每个都有明确用途,没有为了凑数硬加的列。登记编号是全馆唯一标识,与二维码或条码绑定,扫码设备可以直接定位档案;名称、年代、材质、尺寸、重量这些是检索和学术研究的基础字段;收藏级别、完残情况、来源信息、责任保管人则是保管和安全责任划分的依据。整套字段设计本质上是在回答一个问题:不同部门围绕同一件藏品需要看到什么。库房看库位和状态,修复看病害和材料,研究看时代和纹饰,管理者看分级和风险分布,一张主表要同时喂饱这些角色。
分类字段是一个容易被新手做成自由文本的地方,而这个项目把它做成了外键字典。原因很直接:同一种材质可能被录入成"青铜""铜器""铜质",同一种类别在不同人嘴里叫法完全不同,自由文本的检索和统计会彻底失控。分类表用 parent 自关联支持树形结构,未来拆二级分类或者做学术类目扩展都不用改表结构。
# collection/models.py from django.db import models class Category(models.Model): """藏品分类字典,parent 自关联支持二级分类""" name = models.CharField('类别名称', max_length=64, unique=True) code = models.CharField('类别编码', max_length=32, unique=True) parent = models.ForeignKey( 'self', null=True, blank=True, on_delete=models.SET_NULL, verbose_name='上级类别' ) sort_order = models.IntegerField('排序', default=0) class Meta: db_table = 'collection_category' ordering = ['sort_order', 'id']这里的关键选择是 parent 字段用 SET_NULL 而不是 CASCADE。分类的上级被删除时,子分类保留,code 和 name 都是 unique,避免字典数据重复。sort_order 控制展示顺序,在新增藏品的类别下拉框里特别有用。接下来是藏品主档案表,也就是全系统的核心实体,我用代码把关键字段列出来。
class Collection(models.Model): register_no = models.CharField('登记编号', max_length=32, unique=True) name = models.CharField('藏品名称', max_length=128) category = models.ForeignKey( Category, on_delete=models.PROTECT, verbose_name='藏品类别' ) dynasty = models.CharField('年代/文化时期', max_length=64, blank=True) material = models.CharField('材质工艺', max_length=64, blank=True) size_desc = models.CharField('尺寸描述', max_length=128, blank=True) weight = models.DecimalField( '重量(kg)', max_digits=10, decimal_places=3, null=True, blank=True ) level = models.CharField( '收藏级别', max_length=16, choices=[('一级', '一级'), ('二级', '二级'), ('三级', '三级'), ('一般', '一般')], default='一般' ) condition = models.CharField('完残情况', max_length=64, blank=True) source = models.TextField('来源信息', blank=True) keeper = models.CharField('责任保管人', max_length=32) status = models.CharField( '当前状态', max_length=16, default='在库', choices=[('在库', '在库'), ('展出中', '展出中'), ('修复中', '修复中'), ('借展中', '借展中'), ('盘点中', '盘点中'), ('注销', '注销')] ) created_at = models.DateTimeField(auto_now_add=True) updated_at = models.DateTimeField(auto_now=True) class Meta: db_table = 'collection_item' indexes = [ models.Index(fields=['status', 'category']), models.Index(fields=['name']), ]category 外键用了 PROTECT:只要该类别下还有藏品,就不允许删字典项,防止把一个类目删了之后所有关联藏品变成"无类别"。这对博物馆场景是正确取舍,宁可在界面上提示"有藏品关联,不能删除",也不能静默把档案挂空。weight 用 DecimalField 而不是 FloatField,因为浮点数的精度误差在入库登记这类场景是不能接受的。状态字段用 choices 限定取值集合,比存任意字符串更稳,配合后文的状态机服务,非法状态迁移在模型层就被堵住。
2.2 库位、图片与出入库:围绕生命周期的关联表设计
库位管理在项目里的建模方式是典型的四级分层:库房、区域、柜架、层位。每件藏品挂在最后一个层位节点上,这样既能精确到格,也能按库房维度做聚合盘点。full_path 属性把四级路径拼成完整字符串,前端列表页直接展示,不必每次去拼多个字段。
class StorageLocation(models.Model): storage_room = models.CharField('库房', max_length=64) area = models.CharField('区域', max_length=64, blank=True) cabinet = models.CharField('柜架', max_length=64, blank=True) shelf = models.CharField('层位', max_length=64, blank=True) class Meta: db_table = 'collection_location' unique_together = ('storage_room', 'area', 'cabinet', 'shelf') @property def full_path(self): return f'{self.storage_room}/{self.area}/{self.cabinet}/{self.shelf}'unique_together 保证同一套四级定位只能存在一个库位节点,不会出现两个 ID 指向同一个物理位置。实际使用时,库房管理员一般是先建好一批库位节点,再把藏品挂上去,而不是每加一件藏品就新建一个库位。图片表的设计也值得留意,它把图片类型、拍摄人、版权信息都结构化记录了。文博机构的影像资料最怕脱离藏品档案单独存放,文件夹里一堆图,过两年不知道哪张是主图、哪张是修复前的。下面的模型把这个问题在数据结构层面解决了。
class CollectionImage(models.Model): collection = models.ForeignKey( Collection, on_delete=models.CASCADE, related_name='images' ) image_type = models.CharField( '图片类型', max_length=16, choices=[('main', '主图'), ('detail', '细节图'), ('decor', '纹饰图'), ('compare', '修复对比图')] ) image = models.ImageField('图片文件', upload_to='collection_images/%Y/%m/') title = models.CharField('图片标题', max_length=128, blank=True) photographer = models.CharField('拍摄人', max_length=32, blank=True) copyright_info = models.CharField('版权信息', max_length=128, blank=True) created_at = models.DateTimeField(auto_now_add=True) class Meta: db_table = 'collection_image'related_name='images' 让前端可以通过 collection.images 直接拿到全部图片,序列化时嵌套输出非常方便。upload_to 按年/月组织目录,图片量大之后磁盘管理不会一团乱麻。注意 ImageField 依赖 Pillow 库,部署时 requirements 里必须有这一项。
出入库记录表是状态流转的载体。它把业务类型、用途、经办人、审批人、接收单位、预计归还时间、实际归还时间都存成结构化字段,归还时间一旦填写,系统在逻辑上就认为这件藏品应该回到在库状态。审批状态默认"待审批",审批通过后藏品状态才允许变更。
class InOutRecord(models.Model): collection = models.ForeignKey( Collection, on_delete=models.PROTECT, related_name='inout_records' ) biz_type = models.CharField( '业务类型', max_length=16, choices=[('in', '入库'), ('out', '出库'), ('exhibit', '借展出库'), ('return', '归还入库')] ) purpose = models.TextField('用途说明') operator = models.CharField('经办人', max_length=32) approver = models.CharField('审批人', max_length=32, blank=True) receiver_unit = models.CharField('接收单位', max_length=128, blank=True) expect_return_time = models.DateField('预计归还时间', null=True, blank=True) status = models.CharField( '审批状态', max_length=16, default='待审批', choices=[('待审批', '待审批'), ('已通过', '已通过'), ('已驳回', '已驳回')] ) actual_return_time = models.DateField('实际归还时间', null=True, blank=True) created_at = models.DateTimeField(auto_now_add=True) class Meta: db_table = 'collection_inout' ordering = ['-created_at']2.3 MySQL 建库与初始数据:字符集、外键和字典数据一次到位
数据库层面,建库语句必须把字符集钉死。文物名称里生僻字很常见,MySQL 的 utf8 字符集最多只能存三字节字符,遇到四字节字符会直接报错或者落库变成乱码,所以在建库这一步就要用 utf8mb4,这也是项目里明确写的配置。
CREATE DATABASE museum_db DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci;分类主表在 SQL 层面的写法与 Django ORM 对应,外键关联上级分类:
CREATE TABLE collection_category ( id INT AUTO_INCREMENT PRIMARY KEY, name VARCHAR(64) NOT NULL UNIQUE, code VARCHAR(32) NOT NULL UNIQUE, parent_id INT NULL, sort_order INT DEFAULT 0, CONSTRAINT fk_category_parent FOREIGN KEY (parent_id) REFERENCES collection_category(id) ON DELETE SET NULL ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;初始字典数据是一张很实用的表,项目里给了示例数据。常见做法是直接把基础的分类、级别、材质、状态字典写进迁移脚本或者初始 SQL 里,这样系统一部署就有一个可选的下拉框,而不是空字典让用户不知道怎么填。
INSERT INTO collection_category (name, code, sort_order) VALUES ('金属器', 'metal', 1), ('陶瓷器', 'ceramic', 2), ('书画', 'painting', 3), ('玉石器', 'jade', 4), ('漆器', 'lacquer', 5);2.4 状态机与事务锁:在代码里守住状态边界
藏品状态不能随便改,这是这套系统设计上最值得学习的地方。状态迁移不是简单地执行一条 save,而是走一个显式的状态机服务。它用一张迁移表定义合法迁移路径:在库可以转到展出中、修复中、借展中,但借展中的藏品不能直接变成修复中。非法迁移直接抛异常,逻辑上保证业务数据不会出现"上个月还在展出,这个月突然变成注销"这种荒谬记录。
# collection/services.py from django.db import transaction from django.core.exceptions import ValidationError class StorageStatusService: VALID_TRANSITIONS = { '在库': ['展出中', '修复中', '借展中', '盘点中'], '展出中': ['在库', '修复中'], '修复中': ['在库', '展出中'], '借展中': ['在库'], '盘点中': ['在库'], } @classmethod @transaction.atomic def apply_transition(cls, collection, new_status, operator): allowed = cls.VALID_TRANSITIONS.get(collection.status, []) if new_status not in allowed: raise ValidationError( f'不允许从 {collection.status} 直接变更为 {new_status}' ) # 行锁:防止两个请求同时改同一件藏品 locked = Collection.objects.select_for_update().get(pk=collection.pk) old_status = locked.status locked.status = new_status locked.save() AuditLog.objects.create( model_name='Collection', object_id=locked.id, action='transition', operator=operator, old_value=old_status, new_value=new_status, )select_for_update 是这里最关键的细节。两个管理员同时操作同一件藏品,不锁行的话就会出现后提交覆盖先提交的翻车现场。加上行锁后,第二个事务必须等第一个提交后才能读,状态变更变成串行。transaction.atomic 保证状态更新和审计日志写入要么都成功、要么都失败,不会出现状态变了日志却没记上的半截账。
3. Django REST API 的实现链路:序列化、检索、权限与审计怎么落
模型定完,后端的主要工作就是三件事:把模型暴露成 RESTful API、把多条件检索和权限控制做好、把每个写操作记进审计日志。项目采用前后端分离架构,Django 只出 JSON,路由、视图、序列化器三层配合。初学者最容易翻车的地方是把业务逻辑全堆在视图函数里,这套资源把状态迁移、编号校验收敛到序列化器和 service 层,视图集保持干净,这是值得照搬的结构。
3.1 序列化器设计:嵌套字段与写操作校验
序列化器的作用不只是把模型转 JSON,它还承担输入校验和字段暴露控制。藏品详情接口需要同时返回分类名称、库位完整路径和图片列表,纯 ModelSerializer 做不到,需要嵌套字段配合只读字段。
# collection/serializers.py from rest_framework import serializers from .models import Collection, CollectionImage class CollectionImageSerializer(serializers.ModelSerializer): url = serializers.SerializerMethodField() class Meta: model = CollectionImage fields = ['id', 'image_type', 'title', 'photographer', 'url'] def get_url(self, obj): request = self.context.get('request') url = obj.image.url return request.build_absolute_uri(url) if request else urlSerializerMethodField 用来拼图片绝对地址。如果只返回相对路径,前端得自己拼 host,后期换域名或者走 CDN 都要改前端代码,把地址拼装在序列化器里,前端只管用。
class CollectionSerializer(serializers.ModelSerializer): category_name = serializers.CharField(source='category.name', read_only=True) location_path = serializers.CharField(source='location.full_path', read_only=True) images = CollectionImageSerializer(many=True, read_only=True) class Meta: model = Collection fields = [ 'id', 'register_no', 'name', 'category', 'category_name', 'dynasty', 'material', 'size_desc', 'weight', 'level', 'condition', 'source', 'keeper', 'status', 'location', 'location_path', 'images', 'created_at', 'updated_at' ] read_only_fields = ['status', 'created_at', 'updated_at']status 被放进 read_only_fields,这是个容易被忽略但很重要的设计。普通用户通过 PUT 接口不能直接改状态,状态变更必须走到状态机服务,否则任何人调一下接口就能把藏品改成任意状态,权限控制直接形同虚设。编号校验放在 validate_register_no 方法里,DRF 会在反序列化时自动调用,不需要在视图里手动判断。
def validate_register_no(self, value): # 登记编号统一格式:至少 6 位,建议用入藏年份+序号 if not value or len(value) < 6: raise serializers.ValidationError('登记编号格式不正确') if not value.isalnum(): raise serializers.ValidationError('登记编号只能包含字母和数字') return value3.2 多条件检索与分页:把搜索算法收敛到视图集
藏品查询是使用频率最高的模块,检索条件包括名称、编号、类别、级别、状态、材质、库房、责任人。DRF 自带的 SearchFilter 做跨字段模糊搜索够用,但组合条件筛选用自定义 get_queryset 更直观,也方便后续加时间范围、来源单位等扩展条件。
# collection/views.py from django.db.models import Q from rest_framework import viewsets from rest_framework.pagination import PageNumberPagination from .models import Collection from .serializers import CollectionSerializer class CollectionPagination(PageNumberPagination): page_size = 20 page_size_query_param = 'page_size' max_page_size = 200 class CollectionViewSet(viewsets.ModelViewSet): queryset = Collection.objects.select_related('category', 'location') \ .prefetch_related('images') serializer_class = CollectionSerializer pagination_class = CollectionPagination def get_queryset(self): qs = super().get_queryset() params = self.request.query_params if params.get('search'): search = params['search'].strip() qs = qs.filter( Q(name__icontains=search) | Q(register_no__icontains=search) | Q(material__icontains=search) ) if params.get('category'): qs = qs.filter(category_id=params['category']) if params.get('level'): qs = qs.filter(level=params['level']) if params.get('status'): qs = qs.filter(status=params['status']) return qs这里的 Q 对象把三个字段的模糊查询用 OR 拼接,参数走 ORM 参数化查询,不会拼出 SQL 注入。category 和 level 用等值匹配,因为字典表已经收拢了取值。queryset 里提前用 select_related 把 category 和 location 两张关联表一次性 JOIN 出来,避免列表页每行多查两次数据库。配合 max_page_size=200 的限制,防止有人一次拉全表数据打爆接口。
3.3 角色权限与审计日志:每个写操作都有迹可循
权限模块把用户分成管理员、藏品管理员、库房管理员、修复人员、研究人员、只读浏览人员。权限控制分两层:接口级权限和对象级权限。接口级用 DRF 的 BasePermission 实现,对象级可以配合 is_staff 或自定义角色字段判断。
# collection/permissions.py from rest_framework.permissions import BasePermission, SAFE_METHODS class IsAdminOrReadOnly(BasePermission): """非管理员只能读,所有写操作需要管理员身份""" def has_permission(self, request, view): if request.method in SAFE_METHODS: return True return request.user and request.user.is_staff class IsKeeperOrApprover(BasePermission): """出库申请需要库房管理员或审批人身份""" def has_object_permission(self, request, view, obj): if request.method in SAFE_METHODS: return True role = getattr(request.user, 'role', '') return role in ('admin', 'keeper')SAFE_METHODS 包含 GET、HEAD、OPTIONS,普通研究人员可以随便检索浏览,但新增、修改、删除、审批全部要身份校验。审计日志是这类系统的验收必查项,责任追溯靠它。日志模型记录模型名、对象 ID、操作类型、操作人、旧值、新值和时间,任何一次数据变化都能回溯到具体的人和时间点。
# collection/audit.py from django.db import models class AuditLog(models.Model): model_name = models.CharField('模型名', max_length=64) object_id = models.IntegerField('对象ID') action = models.CharField( '操作类型', max_length=16, choices=[('create', '新增'), ('update', '修改'), ('delete', '注销'), ('transition', '状态流转')] ) operator = models.CharField('操作人', max_length=32) old_value = models.TextField('旧值', blank=True) new_value = models.TextField('新值', blank=True) created_at = models.DateTimeField('操作时间', auto_now_add=True) class Meta: db_table = 'collection_audit_log' ordering = ['-created_at']我一般会在写操作发生的地方同步写日志,而不是依赖信号量。信号量在批量操作和事务回滚时容易漏记或多记,显式记录在 service 层更可控。前端界面上管理员可以直接按藏品 ID 搜索全部操作记录,这就是"责任到人"的落地方式。
3.4 路由注册与跨域配置:让前后端在开发环境先跑通
Django 路由用 DRF 的 DefaultRouter 注册,两个视图集自动生成全套 RESTful 路由。列表、详情、新增、更新、删除的 URL 都由 router 推导,不用手写 CRUD 路由。
# museum/urls.py from django.contrib import admin from django.urls import path, include from rest_framework.routers import DefaultRouter from collection.views import CollectionViewSet, InOutViewSet router = DefaultRouter() router.register('collections', CollectionViewSet, basename='collection') router.register('inout', InOutViewSet, basename='inout') urlpatterns = [ path('admin/', admin.site.urls), path('api/', include(router.urls)), path('api/auth/', include('rest_framework.urls')), ]前端开发服务器默认跑在 5173 端口,后端跑 8000 端口,跨域问题不解决页面一个请求都发不出去。项目里用的 django-cors-headers,配置里有个顺序坑:CorsMiddleware 必须放在尽量靠前的位置,否则被其他中间件拦在前面,CORS 响应头加不上去。
# museum/settings.py INSTALLED_APPS = [ 'django.contrib.admin', 'django.contrib.auth', # ... 'rest_framework', 'corsheaders', ] MIDDLEWARE = [ 'corsheaders.middleware.CorsMiddleware', 'django.middleware.common.CommonMiddleware', # ... ] CORS_ALLOWED_ORIGINS = [ 'http://localhost:5173', 'http://127.0.0.1:5173', ]开发环境把两个地址都加进白名单,注意 5173 是 Vite 默认端口,有些人改成 8080 后忘了同步这里,接口就一直在 CORS 报错。生产环境不要图省事开 CORS_ALLOW_ALL_ORIGINS=True,浏览器端让 Nginx 反向代理同源访问,能不开跨域就不开,少一个攻击面。
4. Vue 前端与接口联调:从请求封装到出入库审批的完整交互
后端接口就绪后,前端要做的不是把每个请求都写一遍 axios,而是先做一个统一的请求中心,把 Token 注入、错误码处理、401 跳转这些横切逻辑收敛到一处。这套资源的前端部分覆盖了 API 请求中心、主界面导航、数据看板、查询分页列表、新增编辑表单、图片上传、出入库审批和修复任务页面,几乎把博物馆日常业务的前端交互都做到了。
4.1 Axios 请求中心:Token 注入、错误码统一处理
请求中心的核心是拦截器。请求拦截器统一加 Authorization 头,响应拦截器统一处理错误状态码。前端代码里不会到处散落 localStorage 读取和错误弹窗逻辑,维护起来清爽很多。
// src/api/request.js import axios from 'axios' import { ElMessage } from 'element-plus' const request = axios.create({ baseURL: '/api', timeout: 10000 }) request.interceptors.request.use((config) => { const token = localStorage.getItem('token') if (token) { config.headers.Authorization = `Token ${token}` } return config }) request.interceptors.response.use( (response) => response.data, (error) => { const status = error.response?.status if (status === 401) { localStorage.removeItem('token') window.location.href = '/login' } else if (status >= 500) { ElMessage.error('服务端异常,请稍后重试') } else { ElMessage.error(error.response?.data?.detail || '请求失败') } return Promise.reject(error) } ) export default request这里有个细节:响应拦截器直接返回 response.data,调用方拿到的就是 JSON 数据本身,而不是 axios 包装后的完整响应对象。这样业务代码里不需要每个接口都写 .data.data,少一层嵌套就少一类出错机会。baseURL 用 /api 而不是写死 localhost,开发环境靠 Vite 代理转发,生产环境靠 Nginx 反代,前端代码本身不需要区分环境。
4.2 藏品列表页:分页、筛选与状态标签的渲染
藏品列表是前端业务量最大的页面。查询条件、分页、状态标签、操作按钮都集中在这里。先把接口封装成独立的 API 模块,页面组件只调用函数,不直接碰 axios。
// src/api/collection.js import request from '@/api/request' export function listCollections(params) { return request.get('/collections/', { params }) } export function getCollection(id) { return request.get(`/collections/${id}/`) } export function createCollection(data) { return request.post('/collections/', data) } export function updateCollection(id, data) { return request.put(`/collections/${id}/`, data) } export function deleteCollection(id) { return request.delete(`/collections/${id}/`) }页面组件负责把 query 参数绑定到筛选控件,每次查询重置页码到第一页。状态列用标签组件区分颜色:在库显示绿色,展出中显示蓝色,修复中显示橙色,借展中显示红色。颜色映射在前端做,后端不关心展示样式,职责边界清楚。
<!-- src/views/CollectionList.vue 核心片段 --> <template> <div class="collection-list"> <el-form inline> <el-input v-model="query.search" placeholder="名称/编号/材质" clearable style="width: 220px" /> <el-select v-model="query.status" placeholder="状态" clearable> <el-option v-for="s in statusOptions" :key="s" :label="s" :value="s" /> </el-select> <el-button type="primary" @click="handleSearch">查询</el-button> </el-form> <el-table :data="list" v-loading="loading"> <el-table-column prop="register_no" label="登记编号" width="140" /> <el-table-column prop="name" label="名称" min-width="180" /> <el-table-column prop="category_name" label="类别" width="120" /> <el-table-column prop="level" label="级别" width="80" /> <el-table-column label="状态" width="100"> <template #default="{ row }"> <el-tag :type="statusType(row.status)">{{ row.status }}</el-tag> </template> </el-table-column> <el-table-column label="操作" width="200"> <template #default="{ row }"> <el-button size="small" @click="openDetail(row.id)">详情</el-button> <el-button size="small" type="primary" @click="openInOutDialog(row)" >出入库</el-button> </template> </el-table-column> </el-table> <el-pagination v-model:current-page="query.page" :total="total" :page-size="query.page_size" layout="total, prev, pager, next" @current-change="loadList" /> </div> </template>数据流是单向的:控件变化触发 handleSearch 重置页码,然后调 loadList 拉数据,表格和分页组件只消费这个响应。分页组件切换页码时 page 变化,绑定的 @current-change 再次触发 loadList,不需要额外写逻辑。列表接口返回的 count 字段赋值给 total,分页器自动算出总页数。
4.3 新增与编辑表单:字典选项联动与校验
新增和编辑共用同一个表单组件是常见做法,用一个 id prop 区分:有 id 走 PUT 更新,没有 id 走 POST 新增。分类下拉框从字典接口拉取,材质、级别、完残情况都用选项枚举,前端不提供自由输入框,从交互层面配合后端把数据口径收拢。
// src/views/CollectionForm.vue 核心片段 const props = defineProps({ id: { type: Number, default: null } }) const formRef = ref(null) const form = reactive({ register_no: '', name: '', category: null, dynasty: '', material: '', level: '一般', condition: '', source: '', keeper: '' }) async function submit() { await formRef.value.validate() if (props.id) { await updateCollection(props.id, form) } else { await createCollection(form) } ElMessage.success('保存成功') emit('saved') }Element Plus 的 form 校验规则可以写必填项和长度限制,但业务校验以后端为准。前端校验只是省一次请求往返,真正决定数据合法性的还是 DRF 序列化器里的 validate 方法。这个观念要立住:前端校验是体验优化,后端校验才是安全边界。
4.4 图片上传与出入库审批:文件流和状态流转的前端落地
图片上传用 FormData 承载文件,这里有一个很隐蔽的坑:不要手动给 axios 设置 Content-Type 为 multipart/form-data,axios 在检测到 FormData 时会自动带上正确的 boundary,手动设置反而会把请求头搞坏,后端解析不到文件。
// src/api/upload.js import request from '@/api/request' export function uploadImage(collectionId, file, imageType) { const fd = new FormData() fd.append('collection', collectionId) fd.append('image', file) fd.append('image_type', imageType) return request.post('/images/', fd) }上传前前端可以先做一层类型和大小的拦截:只允许 jpg、png、webp,单文件不超过 10MB,超出直接提示。上传进度可以用 axios 的 onUploadProgress 参数,做成进度条,馆藏高清图动辄几 MB,没有进度条会让操作者以为页面卡死了。
出入库审批的前端交互分两段:库房管理员提交申请,填用途、接收单位、预计归还时间;审批人列表里看到待审批的记录,点通过或驳回。审批通过后,前端需要重新拉取藏品详情,因为状态已经变了,列表页的状态标签要同步刷新。这里我一般会在审批成功的回调里同时刷新列表和详情两个数据源,避免出现界面上还是旧状态的错觉。
5. 部署与联调避坑:五个实战里常见翻车点排查
这套系统的代码逻辑不算难,真正让初学者崩溃的都是部署配置和边界条件。我在按这个项目复现时踩过或者见别人踩过的坑不少,挑五个最常见的,按现象、原因、解决的顺序写清楚,每条都是可以直接对照排查的实战记录。
5.1 MySQL 中文乱码与生僻字报错
现象:页面和后台管理里中文全部变成问号或者乱码,写入藏品名称时直接报 Incorrect string value: '\xF0\x9F...'。
原因:数据库默认字符集不是 utf8mb4。MySQL 的 utf8 字符集最多三字节,生僻字和部分特殊符号占四字节,落库就报错;已经用错误字符集建的表,即使改连接串也没用。
解决:建库时显式指定字符集,已存在的库要先转换。两种方式任选,我建议新建项目直接在 CREATE DATABASE 语句里写死,同时把 Django 数据库连接的 charset 也钉住。
ALTER DATABASE museum_db DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci; ALTER TABLE collection_item CONVERT TO CHARACTER SET utf8mb4;# settings.py 数据库连接配置 DATABASES = { 'default': { 'ENGINE': 'django.db.backends.mysql', 'NAME': 'museum_db', 'USER': 'museum_admin', 'PASSWORD': 'your_password', 'HOST': '127.0.0.1', 'PORT': '3306', 'OPTIONS': { 'charset': 'utf8mb4', }, } }5.2 图片上传成功但访问 404
现象:上传接口返回 200,图片记录也建好了,但拿到返回的 URL 在浏览器打开直接 404。
原因:开发环境没配置 MEDIA_URL 的路由,或者配置了但 urls.py 里没挂上 static 处理;生产环境是 Nginx 的 location /media/ 没有指向实际磁盘目录。
解决:开发环境在 urls.py 里追加一行,生产环境检查 Nginx 配置。我见过有人在两个环境都栽在这上面,排查时先看响应的 URL 长什么样,再确认文件实际落盘位置。
# museum/urls.py from django.conf import settings from django.conf.urls.static import static urlpatterns += static(settings.MEDIA_URL, document_root=settings.MEDIA_ROOT)# Nginx 站点配置 location /media/ { alias /srv/museum/media/; }5.3 前端接口报 CORS 错误
现象:浏览器控制台报 Access-Control-Allow-Origin 缺失,前端页面一个接口都调不通。
原因:前后端分离架构下,前端 5173 端口调后端 8000 端口属于跨域请求。django-cors-headers 没装,或者装了但中间件顺序不对,响应头加不上去。
解决:先确认 INSTALLED_APPS 和 MIDDLEWARE 里都加了 corsheaders,再把前端开发地址写进白名单。如果只是开发环境要快速跑通,临时加 CORS_ALLOW_ALL_ORIGINS=True 也行,但上线前必须改掉。我见过不止一次有人带着这个配置直接上生产,等于把后端接口暴露给任意网站跨域调用。
CORS_ALLOWED_ORIGINS = [ 'http://localhost:5173', 'http://127.0.0.1:5173', ]5.4 并发出入库导致状态互相覆盖
现象:两台管理端同时给同一件藏品做出库和盘点操作,后提交的覆盖了先提交的状态,审计日志里出现两条矛盾的状态流转记录。
原因:读取藏品对象、修改状态、save 写回,这三步不是原子操作。两个事务同时读到"在库",各自改成不同状态,后提交的把先提交的覆盖,数据库里只剩最后一个结果。
解决:状态变更必须走带 select_for_update 的 service 方法,配合事务包装。我之前在 2.4 节写的 StorageStatusService 就是这个方案的完整落地。再强调一次,不要在视图里直接改 collection.status 然后 save,那是并发问题的根源。
with transaction.atomic(): c = Collection.objects.select_for_update().get(pk=collection_id) # 校验状态迁移合法性后修改保存5.5 列表接口慢:ORM 的 N+1 查询
现象:藏品数据只有几百条,列表接口响应却要 2 秒以上,打开页面转圈。
原因:ModelViewSet 的默认 queryset 在序列化时,每条藏品都要单独查一次分类表和库位表。100 条数据就是 201 条 SQL,N+1 查询是 Django 性能问题的头号元凶。
解决:queryset 里预先用 select_related 把单值外键 JOIN 出来,多值关联用 prefetch_related。这一步从接口刚开发时就该做,等项目跑起来数据量上来再补,排查成本翻倍。
queryset = Collection.objects.select_related('category', 'location') \ .prefetch_related('images')不建议一上来就上 Redis 缓存。先把 N+1 查干净,把常用筛选字段加上数据库索引,这两个做完了,几百条数据的列表接口响应通常能压到 100 毫秒以内。缓存在这种量级是锦上添花,不是救命稻草。
6. 上线前的端到端验证:一条业务链路检验整个系统
项目从开发到上线,中间隔着部署、配置、验证三件事。很多人在本地跑通了,一上服务器就各种问题,根因在于部署链路没有被完整验证过。我建议把部署和验收固定成一套标准动作,每次项目交付都强制走一遍。
6.1 生产部署:Gunicorn 与 Nginx 的分层配置
生产环境用 Gunicorn 跑 Django 应用,Nginx 处理静态资源和反向代理。这套组合是 Python 项目最常见的部署形态,关键配置如下。
# 安装依赖、迁移、收集静态文件 pip install -r requirements.txt python manage.py migrate python manage.py collectstatic --noinput# Gunicorn 启动,4 个 worker 对这类业务量足够 gunicorn museum.wsgi:application \ --bind 127.0.0.1:8000 \ --workers 4 \ --timeout 60 \ --access-logfile /srv/museum/logs/access.log \ --error-logfile /srv/museum/logs/error.logworkers 数量一般按 CPU 核数的 2 倍加 1 估算,博物馆内部系统并发不高,4 个 worker 足够。timeout 设置 60 秒,防止图片上传或者报表导出这类耗时请求被 Gunicorn 提前杀掉。
6.2 用一条业务链路做验收
部署完成后,我会用一条从建档到归还的完整业务链路做验收,而不是打开首页看一眼就完事。这条链路覆盖了系统的大部分核心逻辑,每一步都有明确的预期结果。
| 步骤 | 操作 | 预期结果 |
|---|---|---|
| 1 | 管理员登录,新增分类字典项 | 下拉框出现新类别 |
| 2 | 新增一件藏品,登记编号故意重复 | 接口报唯一性校验错误 |
| 3 | 用合法编号建档并上传主图 | 图片 URL 可访问 |
| 4 | 提交出库申请,填写用途和接收单位 | 记录状态为待审批 |
| 5 | 审批人通过申请 | 藏品状态变为借展中或展出中 |
| 6 | 登记归还入库 | 藏品状态回到在库 |
| 7 | 查询该藏品的审计日志 | 五次操作记录完整可追溯 |
| 8 | 用只读账号尝试删除藏品 | 接口返回 403 |
同时要做的还有备份恢复演练。数据库备份和 media 目录备份是两件事,只备份数据库不备份图片,恢复之后档案全在但图片全丢,更麻烦。
#!/bin/bash # backup_museum.sh 每天凌晨执行 BACKUP_DIR="/srv/museum/backup/$(date +%Y%m%d)" mkdir -p "$BACKUP_DIR" mysqldump -u museum_admin -p'your_password' \ --single-transaction --routines museum_db \ > "$BACKUP_DIR/museum_db.sql" tar czf "$BACKUP_DIR/media.tar.gz" -C /srv/museum media # 保留最近 30 天 find /srv/museum/backup -type d -mtime +30 -exec rm -rf {} \;从那以后我每次接手这类交付项目,都会强制走一遍从建库到归还的完整链路,再做一次备份恢复演练。这两件事至少能拦下一半上线后的问题,比上线后半夜被叫起来排查强得多。希望这套系统的模型拆分、状态机设计和权限审计思路,能帮你在实际项目里少走几步弯路。
本文还有配套的精品资源,点击获取