拓十年匠心定制 · 商业建站与技术教学双线并行 咨询热线:400-886-1026 service@lmnt.cn
ARTICLE DETAIL

资讯详情

深耕网站建设与运营推广的一线实战洞察。

Python Django+Vue实战:从零开发小区报修管理系统

Python Django+Vue实战:从零开发小区报修管理系统

开头:别再让报修靠吼

我是去年开始琢磨这个小区的故障报修系统的。当时我们小区的水电工手机常年占线,业主报修全靠微信群接龙,一条"三楼漏水"后面跟着十几条"我家也漏",最后谁处理了、处理完没有、业主满不满意,全凭一张嘴。做物业系统的朋友听了直摇头,说这就是典型的报修管理缺失——工单没有台账、状态不可追踪、服务无法评价。

后来我花了三周时间,用 Python + Vue 把整套流程跑通了:后端基于 Django(也对比了 Flask),前端用 Vue 3 + Element Plus,开发全程在 Pycharm 里完成。从业主提交报修、物业派单、维修工接单处理,到业主确认完工并评价,整个闭环我在系统里完整走了好几遍,也顺手把部署方案理清了。

这篇内容适合两类人:一是零基础想学 Web 开发的,想看看 Django + Vue 这种前后端分离的项目到底怎么从零长出来;二是真的有小区物业、后勤管理需求,想用最小成本搭一个能用的报修平台的。你不需要一开始就懂所有概念,跟着我走过一遍完整流程,你就知道这类"表单 + 状态流转"的管理系统该怎么设计、怎么写、怎么避坑。

1. 拆需求:业主、物业、维修工三条线怎么走

1.1 一个工单从提交到完结的生命周期

我在写第一行代码之前,先把整个报修流程在纸上画了一遍。这件事看着简单——不就是报个修嘛——但真拆开,里面的角色和状态转换比想象中多。

先说角色。整个系统里最少有三类人:

  • 业主(普通用户):发起报修、查看处理进度、确认完工、评价。
  • 物业管理员:电话确认、给维修工派单、处理延期或投诉。
  • 维修工:接单、上门、填写处理结果。

围绕这三类人,一个工单的生命周期可以拆成七个状态:待审核 → 待派单 → 已派单 → 处理中 → 待确认 → 已完成 → 已取消。这里多说一句,为什么不是更简单的"已提交 → 处理中 → 已完成"三个状态?因为没有物业审核环节的话,业主随手提交一个"我家灯泡坏了",维修工就得跑一趟,结果发现是灯罩松了这种根本不需要上门的事。加上审核和派单环节,物业就能在电话里把问题前置过滤一遍,维修工的有效工时能提高一截。

状态流转规则也要提前定死:

  • 业主提交后,工单进入待审核,此时业主可取消。
  • 物业审核通过,工单进入待派单。
  • 物业指定维修工,工单进入已派单,维修工收到通知。
  • 维修工确认接单并开始处理,状态变为处理中。
  • 维修工填写处理结果,状态变为待确认。
  • 业主确认无误,状态变为已完成,此时才能评价。
  • 任何环节物业都可以强制取消,但要填取消原因。

这套状态机的设计思路其实跟订单系统是同一个套路,唯一不同的是报修单有"维修结果"和"评价"这两个业务属性,后面建模的时候要一起考虑进去。

1.2 角色权限怎么控制

角色对应的权限,我在后端用的是 Django 自带的 Group + Permission 机制,前端则用路由守卫配合角色字段控制页面入口。最核心的一条原则是:前端控制的是"看不看得到",后端控制的是"能不能操作",任何权限校验以后端为准。

实际开发里我给每个角色单独建了组,然后在接口层做装饰器校验。简单示意一下:

from django.contrib.auth.models import Group def is_in_group(group_name): def decorator(func): @wraps(func) def wrapper(request, *args, **kwargs): if not request.user.groups.filter(name=group_name).exists(): return JsonResponse({"code": 403, "msg": "无权限操作"}, status=403) return func(request, *args, **kwargs) return wrapper return decorator

这里有个细节:Django 的request.user在未登录时是AnonymousUser,是没有groups属性的,所以装饰器里必须判空,不然匿名用户访问接口会直接 500。我在第一版就踩过这个坑,后来改成了先判断is_authenticated。

1.3 MVP 阶段到底要做哪些功能

很多人一上来就想着把 App、短信通知、大屏监控统统做了,结果一个月过去了连个能跑通的版本都没有。我做这个系统时强制给自己划了 MVP 边界:

必做:

  • 登录注册(手机号 + 密码即可)
  • 报修单的创建、列表、详情、状态流转
  • 图片上传(报修时拍一张现场照片)
  • 三个角色的权限控制
  • 简单的后台管理页面

二期再做:

  • 消息推送(短信、公众号模板消息)
  • 维修工抢单模式
  • 超时未处理自动提醒
  • 报修数据可视化看板

事实证明这个决策非常正确。MVP 边界内的东西,我前后端加在一起写了大约 6000 多行代码,三周时间正好能扎实地走完;二期那些功能,后来用 Flask 写了个数据可视化小面板单独补齐了,没有影响主流程的稳定性。

2. 技术选型:Django还是Flask,前端为什么非Vue不可

2.1 Django与Flask的取舍

标题里同时出现了 Django 和 Flask,正好也是我被问得最多的问题:这两个框架到底选哪个?我的回答分三层。

第一层:两者的定位完全不同。Django 是"全家桶"式的重量级框架,自带 ORM、Admin 后台、认证系统、迁移工具,项目骨架一生成,该有的基础设施都有了,适合业务逻辑复杂、需要长期迭代的项目。Flask 是"微框架",核心只是一个路由 + 模板引擎,数据库、表单验证、登录这些统统要自己配或者装第三方扩展,灵活但依赖开发者自己整合。

第二层:我的选择逻辑。报修系统虽然看起来是个小项目,但核心的"用户认证、工单状态流转、权限控制"都是非常通用的需求,用 Django 的django.contrib.auth加上 DRF(Django REST Framework,后面简称 DRF)的视图集,开发效率明显更高,代码量能省下一大截。而如果用 Flask,光是 ORM 选型、认证方案、序列化这几块就要反复对比权衡,对新手很不友好。所以我主项目用 Django,这是深思熟虑的结果,不是随便选的。

第三层:Flask 还有用吗?有,而且很有用。我在二期给报修系统做数据统计看板时,就是用 Flask 写了一个轻量接口,专门给前端提供报修量趋势、维修耗时分布、满意度统计这些聚合数据。原因也很简单:这部分逻辑复用了一个独立的 MySQL 库,不想动 Django 主项目,Flask 几十行就能起一个独立服务,做完就丢,非常轻巧。

2.2 Vue在这个项目里解决了什么问题

前端我选了 Vue,具体是 Vue 3 + Vite + Element Plus + Pinia 这套组合。为什么不用 jQuery 套模板?因为这类管理系统的页面交互远比想象中多:工单列表要筛选、状态要实时更新、表单要校验、弹窗要按角色控制,用原生 JS 写这些,代码量至少要翻两倍,维护起来更是噩梦。

Vue 的核心价值是"数据驱动视图"。简单说,你只需要维护一个状态:currentTab是'pending'就显示待审核的列表,是'processing'就显示处理中的列表。界面会根据数据的变化自动更新,不需要手动去操作 DOM。

具体到报修系统,前端我分成了几个核心页面:

  • 登录/注册页:手机号 + 密码,登录成功后存 token。
  • 业主端:我要报修(表单 + 图片上传)、我的报修(列表 + 详情 + 确认 + 评价)。
  • 物业端:报修审核(通过/驳回)、派单(选维修工)。
  • 维修工端:我的工单(处理中列表)、工单详情(填写处理结果)。
  • 通用组件:状态标签、工单卡片、图片预览。

这套页面用 Vue 写起来非常顺手,v-for循环渲染工单卡片,v-if按角色切换按钮,v-model绑定表单数据,几乎不需要手动写事件监听。

2.3 Pycharm环境配置与开发效率

开发工具我用的是 Pycharm,而且是专业版。很多人纠结社区版能不能用,我的看法是:写 Django 项目,社区版完全够用,Django 和 Python 的支持都是内置的;但如果你要用数据库可视化工具、前端代码调试、远程部署这些功能,专业版会省事很多。

我重点说一下环境层面容易踩的两个坑。

第一个坑是虚拟环境。Python 的依赖管理,特别是 Django 这种几十个包的项目,直接装在全局环境里会乱成一团。我在项目根目录下创建虚拟环境:

python -m venv venv # Windows: venv\Scripts\activate # macOS/Linux: source venv/bin/activate pip install django djangorestframework django-cors-headers pillow

在 Pycharm 里,解释器选择venv下的 Python,这样项目依赖和系统 Python 完全隔离。

第二个坑是Node 与 Vue 的环境。Vue 3 要求 Node.js 版本在 16 以上,我建议直接用最新的 LTS 版本。装 Vue 脚手架用 Vite 就够了:

npm create vite@latest repair_frontend -- --template vue cd repair_frontend npm install npm install vue-router@4 pinia axios element-plus

装完这两套环境,我开发时会在 Pycharm 的 Terminate 里同时起两个进程:一个python manage.py runserver跑后端 8000 端口,一个npm run dev跑前端 5173 端口。改完前后端代码,浏览器刷新即可看到效果。

3. 后端落地:环境准备、项目结构与数据库建模

3.1 从零创建Django项目与App

后端我按标准的 Django 项目结构来建。先创建项目主目录,再创建功能模块 app,这里我用的是repair_project(项目)和repairs(报修模块)、accounts(用户模块)这两个 app。

django-admin startproject repair_project cd repair_project python manage.py startapp repairs python manage.py startapp accounts python manage.py migrate

在settings.py里把新建的 app 注册进去,同时配置 DRF 和跨域。这里有个重要配置,AUTH_USER_MODEL要指向自定义用户模型,不然后续想给用户加"楼栋号""手机号"这些字段会非常麻烦:

INSTALLED_APPS = [ 'django.contrib.admin', 'django.contrib.auth', 'django.contrib.contenttypes', 'django.contrib.sessions', 'django.contrib.messages', 'django.contrib.staticfiles', 'rest_framework', 'rest_framework_simplejwt', 'corsheaders', 'accounts', 'repairs', ] AUTH_USER_MODEL = 'accounts.User' REST_FRAMEWORK = { 'DEFAULT_AUTHENTICATION_CLASSES': [ 'rest_framework_simplejwt.authentication.JWTAuthentication', ], 'DEFAULT_PERMISSION_CLASSES': [ 'rest_framework.permissions.IsAuthenticated', ], }

3.2 数据模型设计与状态字段的心得

数据库建模是整个项目里最值得花时间的部分。我在accounts/models.py里自定义了用户模型,继承AbstractUser,额外加上手机号和楼栋号:

class User(AbstractUser): phone = models.CharField(max_length=11, unique=True) building = models.CharField(max_length=50, blank=True) unit = models.CharField(max_length=50, blank=True) room = models.CharField(max_length=50, blank=True)

报修单模型放在repairs/models.py,核心字段如下:

class RepairOrder(models.Model): STATUS_CHOICES = [ ('pending', '待审核'), ('waiting_dispatch', '待派单'), ('dispatched', '已派单'), ('processing', '处理中'), ('waiting_confirm', '待确认'), ('completed', '已完成'), ('cancelled', '已取消'), ] CATEGORY_CHOICES = [ ('water', '水管漏水'), ('electric', '电路故障'), ('appliance', '家电维修'), ('door', '门窗维修'), ('other', '其他'), ] title = models.CharField(max_length=100) description = models.TextField() category = models.CharField(max_length=20, choices=CATEGORY_CHOICES) status = models.CharField(max_length=20, choices=STATUS_CHOICES, default='pending') reporter = models.ForeignKey(settings.AUTH_USER_MODEL, on_delete=models.CASCADE, related_name='reported_orders') assignee = models.ForeignKey(settings.AUTH_USER_MODEL, on_delete=models.SET_NULL, null=True, blank=True, related_name='assigned_orders') address = models.CharField(max_length=200) photos = models.JSONField(default=list) cancel_reason = models.TextField(blank=True) result_note = models.TextField(blank=True) created_at = models.DateTimeField(auto_now_add=True) updated_at = models.DateTimeField(auto_now=True) assigned_at = models.DateTimeField(null=True, blank=True) completed_at = models.DateTimeField(null=True, blank=True)

这里几个字段的设计思路我要重点说一下:

photos用 JSONField 而不是单独建图片表。因为在报修这个场景里,图片只属于一个工单,数量也有限(一般 1 到 5 张),单独建表还要维护外键关系,反而麻烦。JSONField 直接存["/media/photo_1.jpg", "/media/photo_2.jpg"],查询起来一次就能取到,简单高效。

assignee用SET_NULL而不是CASCADE。如果维修工被删了,工单还在,不能连工单一起删掉。同理,reporter用CASCADE是因为业主账号注销时,他的报修记录没有保留价值。

状态字段用字符串而不是数字。比如'pending'比1可读性强得多,调试时一眼能看出当前状态,在前后端传输时也更直观。

3.3 序列化器与视图集的搭配

DRF 的序列化器是前后端数据交换的桥梁。报修单的序列化器我最开始写得太粗暴,直接用fields = '__all__',结果创建工单时用户可以伪造assignee字段给自己派单。后来我改成按操作场景拆分序列化器:

  • RepairOrderCreateSerializer:只允许传title、description、category、address、photos。
  • RepairOrderDetailSerializer:展示所有字段,嵌套用户信息。
  • RepairOrderAssignSerializer:只允许传assignee_id,供物业派单使用。
  • RepairOrderResultSerializer:只允许传result_note,供维修工填写结果。
class RepairOrderCreateSerializer(serializers.ModelSerializer): class Meta: model = RepairOrder fields = ['title', 'description', 'category', 'address', 'photos'] def create(self, validated_data): validated_data['reporter'] = self.context['request'].user return super().create(validated_data)

视图部分我用了 DRF 的ViewSet,配合@action定义自定义动作。这样工单的每个状态流转都变成一个可读性很高的接口:

class RepairOrderViewSet(viewsets.ModelViewSet): queryset = RepairOrder.objects.all().order_by('-created_at') serializer_class = RepairOrderDetailSerializer permission_classes = [IsAuthenticated] def get_queryset(self): user = self.request.user if user.groups.filter(name='物业管理员').exists(): return RepairOrder.objects.all() if user.groups.filter(name='维修工').exists(): return RepairOrder.objects.filter(assignee=user) return RepairOrder.objects.filter(reporter=user) @action(detail=True, methods=['post']) def audit(self, request, pk=None): order = self.get_object() if request.data.get('action') == 'approve': order.status = 'waiting_dispatch' order.save() return Response({'msg': '已通过审核'}) else: order.status = 'cancelled' order.cancel_reason = request.data.get('reason') order.save() return Response({'msg': '已驳回'}) @action(detail=True, methods=['post']) def assign(self, request, pk=None): order = self.get_object() assignee_id = request.data.get('assignee_id') order.assignee_id = assignee_id order.status = 'dispatched' order.assigned_at = timezone.now() order.save() return Response({'msg': '派单成功'})

这里有个权限的细节:get_queryset里我已经把每个角色的可见范围限制了,但自定义动作assign只被物业管理员调用,所以还需要额外加一层权限校验,不能只依赖get_queryset的结果。我在assign方法里加了if not request.user.groups.filter(name='物业管理员').exists(): return Response({'msg': '无权限'}, status=403)。

4. 核心接口实战:登录认证与工单全流程

4.1 JWT登录注册与基于角色的信息获取

认证方案我用了 SimpleJWT,它和 Django 自带的认证系统集成得很好,不用自己维护 session 状态,前端只需要在每次请求的Authorization请求头里带上 token 即可。

注册接口我用 DRF 的APIView自己写的:

class RegisterView(APIView): permission_classes = [AllowAny] def post(self, request): username = request.data.get('username') password = request.data.get('password') phone = request.data.get('phone') building = request.data.get('building') unit = request.data.get('unit') room = request.data.get('room') role = request.data.get('role', 'owner') if User.objects.filter(username=username).exists(): return Response({'msg': '用户名已存在'}, status=400) if User.objects.filter(phone=phone).exists(): return Response({'msg': '手机号已注册'}, status=400) user = User.objects.create_user( username=username, password=password, phone=phone, building=building, unit=unit, room=room ) # 根据注册选择的角色加入对应分组 group_map = {'owner': '业主', 'property': '物业管理员', 'worker': '维修工'} group, _ = Group.objects.get_or_create(name=group_map[role]) user.groups.add(group) return Response({'msg': '注册成功'})

登录接口直接使用 SimpleJWT 自带的TokenObtainPairView,然后前端登录成功后马上调一个/api/users/me/接口拿到用户名、角色、楼栋信息,存到 Pinia 里。

后端对应me接口:

class MeView(APIView): def get(self, request): user = request.user groups = [group.name for group in user.groups.all()] role = 'unknown' if '物业管理员' in groups: role = 'property' elif '维修工' in groups: role = 'worker' else: role = 'owner' return Response({ 'id': user.id, 'username': user.username, 'phone': user.phone, 'building': user.building, 'unit': user.unit, 'room': user.room, 'role': role })

这里我想提醒大家一个容易忽略的点:前端判断角色用的是/users/me/返回的 role 字段,但真正决定接口能访问什么,后端靠的是 token 里解析出来的用户身份 + 数据库里的分组信息。前端的 role 只是用来决定渲染哪些按钮和页面,如果只靠前端隐藏按钮来保护接口,别人拿 Postman 一样能调接口。

4.2 报修单创建接口与图片上传

图片上传是一个独立的接口,前端先把图片传到服务器,拿到 URL 后,再把 URL 跟着表单数据一起提交给报修单创建接口。这样设计的理由很简单:报修单创建是一个事务性操作,如果在创建时才上传图片,一旦表单校验失败,图片已经传了又产生垃圾数据。先传图,后提交,用户就算提交失败也不至于丢图。

图片上传接口:

class UploadImageView(APIView): parser_classes = [MultiPartParser, FormParser] def post(self, request): file = request.FILES.get('file') if not file: return Response({'msg': '没有文件'}, status=400) if file.size > 10 * 1024 * 1024: return Response({'msg': '图片大小不能超过10M'}, status=400) allowed_types = ['image/jpeg', 'image/png', 'image/webp'] if file.content_type not in allowed_types: return Response({'msg': '不支持的图片格式'}, status=400) filename = f"repair_{timezone.now().strftime('%Y%m%d%H%M%S')}_{random.randint(1000, 9999)}.{file.name.split('.')[-1]}" file_path = os.path.join(settings.MEDIA_ROOT, 'repairs', filename) os.makedirs(os.path.dirname(file_path), exist_ok=True) with open(file_path, 'wb+') as f: for chunk in file.chunks(): f.write(chunk) url = f"{settings.MEDIA_URL}repairs/{filename}" return Response({'url': url})

4.3 工单流转的核心链路与日志记录

我强烈建议给工单加一个RepairLog模型,记录每一次状态变更。这不仅是排查问题的便利工具,更重要的是当业主投诉"我家的报修等了三天没人处理"时,你能拿出完整的时间线证明每个环节卡在了哪里,这对物业来说是非常好的自证工具。

日志模型长这样:

class RepairLog(models.Model): order = models.ForeignKey(RepairOrder, on_delete=models.CASCADE, related_name='logs') user = models.ForeignKey(settings.AUTH_USER_MODEL, on_delete=models.SET_NULL, null=True) action = models.CharField(max_length=50) detail = models.TextField(blank=True) created_at = models.DateTimeField(auto_now_add=True)

每次状态变更时就追加一条日志。我在RepairOrderViewSet里封装了一个_log_order_action(order, request, action, detail='')方法,所有自定义动作里调用,避免到处复制粘贴。

5. 前端页面:Vue3 + Element Plus 把这些接口用起来

5.1 路由设计:按角色分流

前端路由我用的是 Vue Router,总共规划了这些路径:

const routes = [ { path: '/login', component: Login, meta: { public: true } }, { path: '/register', component: Register, meta: { public: true } }, { path: '/', component: Layout, redirect: '/my-repairs', children: [ { path: 'my-repairs', component: MyRepairs, meta: { roles: ['owner'] } }, { path: 'new-repair', component: NewRepair, meta: { roles: ['owner'] } }, { path: 'audit-list', component: AuditList, meta: { roles: ['property'] } }, { path: 'dispatch', component: DispatchList, meta: { roles: ['property'] } }, { path: 'worker-orders', component: WorkerOrders, meta: { roles: ['worker'] } }, { path: 'order-detail/:id', component: OrderDetail }, { path: 'stats', component: StatsDashboard, meta: { roles: ['property'] } }, ]}, ]

这里我给/order-detail/:id特意没限制角色,因为业主、物业、维修工都要看同一个工单详情页面,只是页面里根据当前用户的角色渲染不同的操作按钮而已。

路由守卫控制进入页面的权限:

router.beforeEach((to, from, next) => { const token = localStorage.getItem('access_token') const user = useUserStore() if (to.meta.public) { if (token && to.path === '/login') next('/') else next() return } if (!token) { next('/login') return } if (to.meta.roles && !to.meta.roles.includes(user.role)) { next('/login') return } next() })

5.2 报修表单与图片上传组件

报修表单用的 Element Plus 的el-form,重点是校验规则。手机号、楼栋号、故障描述都要做必填校验,描述还限制最少 10 个字符,防止业主提交"坏了"这种没营养的描述。图片上传用的是el-upload,我让它只接受图片格式,限制大小 10M,一次最多传 5 张,每传一张就调后端的/api/upload/接口得到 URL,最后汇总到表单的photos数组里。

这里是组件的关键片段:

<el-form ref="formRef" :model="form" :rules="rules" label-width="90px"> <el-form-item label="标题" prop="title"> <el-input v-model="form.title" placeholder="例如:厨房水管漏水" /> </el-form-item> <el-form-item label="故障类型" prop="category"> <el-select v-model="form.category"> <el-option label="水管漏水" value="water" /> <el-option label="电路故障" value="electric" /> <el-option label="家电维修" value="appliance" /> <el-option label="门窗维修" value="door" /> <el-option label="其他" value="other" /> </el-select> </el-form-item> <el-form-item label="详细地址" prop="address"> <el-input v-model="form.address" placeholder="自动带出,也可修改" /> </el-form-item> <el-form-item label="问题描述" prop="description"> <el-input type="textarea" v-model="form.description" :rows="4" /> </el-form-item> <el-form-item label="现场照片"> <el-upload list-type="picture-card" :http-request="handleUpload" :limit="5" :on-remove="handleRemove" > <span>点击上传</span> </el-upload> </el-form-item> </el-form>

注意el-upload的http-request属性,它是用来重写上传行为的。默认的action属性走的是标准 form 上传,但我们的接口需要带 token,需要自定义实现:

const handleUpload = async (options) => { const formData = new FormData() formData.append('file', options.file) const { data } = await axios.post('/api/upload/', formData, { headers: { 'Content-Type': 'multipart/form-data' } }) form.value.photos.push(data.url) }

5.3 工单列表与状态跟踪

工单列表的核心逻辑就是"根据用户角色拉取不同接口 + 按状态筛选"。物业端的审核列表长这样:

<el-tabs v-model="activeStatus"> <el-tab-pane label="待审核" name="pending"> <RepairCard v-for="order in filteredOrders('pending')" :key="order.id" :order="order" /> </el-tab-pane> <el-tab-pane label="待派单" name="waiting_dispatch"> <RepairCard v-for="order in filteredOrders('waiting_dispatch')" :key="order.id" :order="order" /> </el-tab-pane> <el-tab-pane label="处理中" name="processing"> <RepairCard v-for="order in filteredOrders('processing')" :key="order.id" :order="order" /> </el-tab-pane> </el-tabs>

RepairCard是一个通用卡片组件,展示报修标题、分类、楼栋号、状态标签、提交时间。点击卡片进入详情页。详情页再根据角色显示不同按钮:物业看到"审核通过""派单",维修工看到"开始处理""填写结果",业主看到"确认完工""评价"。

这里分享一个开发效率的经验:前后端接口字段一定提前对齐,特别是状态码和枚举值。我一开始前后端各写各的,后端返回pending,前端判断的是0,导致列表页面一片空白,花了大半天排查才发现是枚举值没对齐。后来我在前端单独建了一个constants.js文件,把后端的状态枚举值全部抄进去,统一从那里引用:

// constants.js export const REPAIR_STATUS = { pending: { label: '待审核', color: 'warning' }, waiting_dispatch: { label: '待派单', color: 'primary' }, dispatched: { label: '已派单', color: 'info' }, processing: { label: '处理中', color: 'danger' }, waiting_confirm: { label: '待确认', color: 'warning' }, completed: { label: '已完成', color: 'success' }, cancelled: { label: '已取消', color: 'info' } }

6. 前后端联调:跨域、时间格式、接口对不上的那些事

6.1 CORS跨域问题的标准解法

前端跑在 5173 端口,后端跑在 8000 端口,这就是典型的跨域场景。浏览器会拦截跨域请求,后端必须明确告诉浏览器"这个来源我可以接受"。

我在后端用了django-cors-headers,配置如下:

CORS_ALLOWED_ORIGINS = [ "http://localhost:5173", "http://127.0.0.1:5173", ] CORS_ALLOW_CREDENTIALS = True

注意这里不要图省事直接写CORS_ALLOW_ALL_ORIGINS = True,生产环境会埋下安全隐患。我自己开发时确实为了省事全放过,最后部署前改成白名单,结果漏掉了部署域名的前端地址,又是几分钟的排查。

6.2 时间格式和枚举值对齐

前后端联调最容易翻车的三件事,我掰着指头数一下:

一是时间格式。Django 序列化默认输出的时间是带Z和+00:00后缀的 ISO 格式,比如2024-06-01T08:30:00Z。前端如果直接用new Date()解析时区不同,显示的时间会差 8 个小时。我在前端做了一个统一的工具函数:

const formatTime = (isoStr) => { const date = new Date(isoStr) const offset = 8 * 60 const localDate = new Date(date.getTime() + offset * 60 * 1000) return localDate.toLocaleString('zh-CN', { hour12: false }) }

二是枚举字段。后端返回status是英文字符串,前端展示要转换成中文,并且要根据状态显示不同颜色,所以我做了REPAIR_STATUS映射表。后端返回的category同理,也有映射表。

三是主键与关联信息。报修单详情里的reporter和assignee字段,直接返回外键 ID 其实对前端不够友好——前端拿了一个assignee_id,还得再调接口查维修工叫什么名字、什么手机号。我建议序列化器里嵌套展示关键信息:

class RepairOrderDetailSerializer(serializers.ModelSerializer): reporter_name = serializers.CharField(source='reporter.username', read_only=True) reporter_phone = serializers.CharField(source='reporter.phone', read_only=True) assignee_name = serializers.CharField(source='assignee.username', read_only=True, default='') assignee_phone = serializers.CharField(source='assignee.phone', read_only=True, default='') class Meta: model = RepairOrder fields = ['id', 'title', ...]

6.3 调试工具与Mock数据经验

联调阶段我强烈推荐两个工具:Postman 和后端日志。Postman 用来单独测接口,可以不经过前端,快速确认问题到底出在前端还是后端。Postman 里要记得在 Authorization 里配置 Bearer token,否则所有接口都会返回 401。

后端的日志输出也很关键。我在settings.py里配置了简单的日志:

LOGGING = { 'version': 1, 'handlers': { 'console': { 'class': 'logging.StreamHandler', } }, 'loggers': { 'django.request': { 'handlers': ['console'], 'level': 'DEBUG', } } }

这样每次前端调接口,后端控制台都会打印请求路径、耗时、状态码和异常信息,排查问题效率能翻一倍。

我遇到过的最诡异的一次 bug:前端页面一直报 404,但我在浏览器地址栏直接输入接口 URL 又能访问。最后检查发现是前端的 Axios 基础路径配置错了——axios.defaults.baseURL = '/api',但因为前端跑在 5173 端口,这个/api被解析成了http://localhost:5173/api,根本没打到后端。正确配置应该是完整的http://localhost:8000/api,当时想打自己一顿的心都有了。

7. 部署与后续优化:把系统放上生产环境

7.1 生产服务器选的什么方案

开发环境跑通之后,还有一个现实的问题:系统要真正给物业用,就不能一直停在localhost上。我的部署方案是经典组合:Nginx + Gunicorn + MySQL。

部署流程大致是:

  1. 服务器装好 Python、Node,把项目代码克隆过去。
  2. 后端项目里创建虚拟环境,安装依赖,python manage.py collectstatic把静态文件收集到一个目录。
  3. Gunicorn 起 Django 服务:gunicorn repair_project.wsgi:application -w 4 -b 127.0.0.1:8001。
  4. 前端项目执行npm run build,生成dist静态文件目录,交给 Nginx 托管。
  5. Nginx 配置:访问根路径/返回前端页面,访问/api/前缀的请求反向代理到127.0.0.1:8001。

Nginx 的关键配置片段:

server { listen 80; server_name your-domain.com; # 前端静态文件 root /var/www/repair_frontend/dist; index index.html; # 前端路由需要,否则刷新页面会 404 location / { try_files $uri $uri/ /index.html; } # 后端接口代理 location /api/ { proxy_pass http://127.0.0.1:8001; proxy_set_header Host $host; proxy_set_header X-Real-IP $remote_addr; } # 媒体文件(报修图片) location /media/ { alias /var/www/repair_project/media/; } }

这里面有一个部署后才会暴露的坑:Django 的DEBUG = False时,不会自动提供静态文件服务,所以图片和静态资源必须交给 Nginx 处理。如果你忘了配置/media/的 alias,业主上传的图片全部裂掉,检查半天才反应过来。

7.2 用Flask扩展的数据看板

前面提到的数据统计看板,我用 Flask 单独写了。这段代码非常简单,从报修库里算几个聚合指标,输出 JSON 给前端图表组件:

from flask import Flask, jsonify import pymysql app = Flask(__name__) @app.route('/stats/repair-trend') def repair_trend(): conn = pymysql.connect(host='localhost', user='root', password='xxx', database='repair_db', charset='utf8mb4') cursor = conn.cursor() cursor.execute(""" SELECT DATE(created_at) AS d, COUNT(*) AS cnt FROM repairs_repairorder WHERE created_at >= DATE_SUB(NOW(), INTERVAL 30 DAY) GROUP BY DATE(created_at) ORDER BY d """) rows = cursor.fetchall() conn.close() return jsonify([ {'date': str(row[0]), 'count': row[1]} for row in rows ]) if __name__ == '__main__': app.run(port=5000)

前端统计页面用 ECharts 引入折线图和柱状图,直接展示近 30 天报修量、各类型占比、各楼栋报修排行。这样物业在开周会时能直接打开大屏汇报,领导和同事都对系统价值有直观感知。

7.3 后续值得扩展的方向

系统上线之后,明显能感知到几个新需求开始浮出水面:

一是消息推送。工单从"待审核"变成"已派单"时,业主和维修工都希望第一时间收到通知。目前最简单的方案是接入企业微信/飞书群机器人 Webhook,状态变更时往群里推一条消息,成本几乎为零。

二是超时预警。报修单长时间停留在某个状态,系统应该提醒物业去干预。我建议在 Django 里加一个定时任务(Celery beat 或简单点用 APScheduler),每五分钟扫一遍超时工单,给物业发提醒。

三是服务评分体系。目前已经有业主评价功能,后续可以按月统计每个维修工的平均分和完成量,作为绩效考核依据。这个需求物业非常看重,因为技术维修人员的服务状态很难量化,评价数据就是最好的抓手。

结尾的话

最后说说做这个项目最深的体会:一个系统能不能真正用起来,关键不在于功能多炫,而在于流程是否完整、状态是否有迹可循、数据是否能推动改进。我见过不少团队花大价钱做报修 App,结果用一个月就弃用了,就是因为工单流转设计得模棱两可,卡在中间状态没人负责。这类管理系统的本质,是把线下的口头约定固化成线上的状态机,让每个环节的负责人和耗时都清清楚楚。

如果你也想搭一个类似的系统,我的建议是从最小闭环开始,先把手动的工单流转跑顺,再考虑自动化、可视化这些锦上添花的事。过程中遇到的具体问题,欢迎随时交流——代码里的每个坑我都是踩过之后才知道深浅的,希望这篇内容能帮你少走几步弯路。

返回列表