news 2026/9/14 23:27:15

Django+Vue实战:超市进销存仓储系统从建模到部署全指南

作者头像

张小明

前端开发工程师

1.2k 24
文章封面图
Django+Vue实战:超市进销存仓储系统从建模到部署全指南

最近花了三周多时间,把一套基于 Django + Vue 的超市进销存仓储系统从数据库设计到前后端联调完整跑通了。这套系统除了常规的商品管理、库存盘点之外,重点做了供应商采购和前台收银两条链路:供应商把货送到仓库,仓库入库,前台销售出库,整个进销存的数据闭环被打通。完工后回头看,真正值钱的地方不是某个炫技功能,而是把“进销存”三个字拆成清晰的数据流,再落到 Django 模型和 Vue 页面里。如果你也想做类似的超市、便利店或者中小仓储系统,这篇文章应该能帮你少走不少弯路。

我默认你已经有 Django 和 Vue 的基础,但即使你只是学了语法、还没完整做过前后端分离项目,下面这些建模思路、接口设计、联调坑位也一样有参考价值。我会把供应商管理、前台收银、库存流水、后台管理这几个核心模块怎么搭、为什么这么搭、踩过哪些坑,都摊开来说。

1. 做超市进销存,先别急着写代码,把业务流画明白

1.1 进销存不是库存管理系统,它覆盖供应商到前台的三条线

很多人一听到“超市进销存”,第一反应就是做个商品列表,加一个库存数字,再做个简单的前台收银页面。实际做下来会发现,这种思路做到一半就会乱套。真正的进销存要管的是三条业务线:

  • 进:向供应商下采购单,供应商送货,仓库验收入库,库存增加。
  • 销:前台收银卖出商品,生成销售订单,库存同步减少。
  • 存:库存当前有多少、存在哪个仓库、每个批次是什么时候进的、保质期还剩多久。

这三条线不是独立存在的。采购单审核通过后要生成入库单,入库单要写库存流水,库存流水要更新商品库存;前台销售订单提交后要扣减库存,扣减记录也要落到库存流水里。也就是说,每一次库存数字的变动,都必须能找到对应的业务单据,否则月底对账是对不上的。

供应商在这个模型里是“进”的上游,前台收银是“销”的下游。没有供应商管理,采购单就是无源之水;没有前台收银,库存只进不出就成了死库存。所以标题里特别强调“供应商”和“前台”,其实是在提醒我们:进销存系统的核心是双向流动,而不是一个静态的库存表。

1.2 为什么选 Django + Vue 这套组合

技术选型上,我一开始也纠结过要不要用纯 Django 模板,毕竟 Django Admin 开箱即用,能省不少事。但真正做下去发现,超市前台的收银场景交互很频繁:商品扫码、购物车加减、实时计算金额、会员折扣、找零提示,这些如果用 Django 模板 + jQuery 来做,代码会越写越乱。Vue 的响应式状态管理做这类交互非常顺手,尤其是购物车和收银台这种需要即时更新界面的场景。

Django 负责的是稳定可靠的后端:ORM 管理数据库模型,Django Admin 给运营人员用,DRF 提供 API 给 Vue 调用,事务和锁机制保证库存扣减不超卖。所以选 Django + Vue 不是跟风,而是把各自擅长的部分用在合适的位置上。

用到的核心依赖大概是这样:

  • 后端:Django 4.x、Django REST framework、django-cors-headers、simplejwt、mysqlclient、simpleui
  • 前端:Vue 3、Vite、Vue Router、Pinia、Element Plus、Axios
  • 数据库:MySQL 8.0,用 InnoDB 引擎支持事务和行锁

1.3 功能模块先划边界,再拆任务

动手写代码前,我把系统分成了五个模块,每个模块对应一组数据表和页面。这个边界划分很重要,不然开发到一半很容易互相牵扯。

模块核心业务主要数据表使用角色
基础数据商品分类、商品档案、仓库信息Category / Product / Warehouse管理员
供应商管理供应商档案、采购入库Supplier / PurchaseOrder / PurchaseItem采购员
前台收银商品扫码、购物车、订单结算SaleOrder / SaleOrderItem收银员
库存管理库存台账、库存流水、盘点StockProduct / StockFlow仓库管理员
系统管理用户、权限、操作日志User / Group系统管理员

这样的拆分让我在写 Django app 的时候也有了清晰边界:goods管商品,supplier管采购,sale管前台订单,stock管库存,每个 app 只负责自己那一块的模型和接口,避免一个 models.py 里堆几百行代码。

2. Django 后端的数据建模:供应商、商品、库存、订单怎么串起来

2.1 商品分类与商品档案

商品是进销存系统的基础,所有采购、销售、库存操作最终都要落到具体的商品上。超市商品有个特点:条码是天然的业务主键,扫码枪扫出来就是条码字符串。所以Product模型里条码字段不仅唯一,而且建议加索引,因为前台收银的每一次操作都要按条码查商品。

from django.db import models class Category(models.Model): name = models.CharField(max_length=50, unique=True, verbose_name="分类名称") parent = models.ForeignKey("self", null=True, blank=True, on_delete=models.CASCADE, verbose_name="上级分类") sort_order = models.IntegerField(default=0, verbose_name="排序") class Meta: verbose_name = "商品分类" verbose_name_plural = verbose_name def __str__(self): return self.name class Product(models.Model): barcode = models.CharField(max_length=64, unique=True, db_index=True, verbose_name="条码") name = models.CharField(max_length=200, db_index=True, verbose_name="商品名称") category = models.ForeignKey(Category, on_delete=models.PROTECT, verbose_name="分类") unit = models.CharField(max_length=10, default="瓶", verbose_name="单位") purchase_price = models.DecimalField(max_digits=10, decimal_places=2, verbose_name="进价") sale_price = models.DecimalField(max_digits=10, decimal_places=2, verbose_name="售价") status = models.BooleanField(default=True, verbose_name="上架状态") created_at = models.DateTimeField(auto_now_add=True, verbose_name="创建时间") updated_at = models.DateTimeField(auto_now=True, verbose_name="更新时间") class Meta: verbose_name = "商品档案" verbose_name_plural = verbose_name def __str__(self): return f"{self.name}({self.barcode})"

这里有个容易纠结的问题:进价和售价直接存在商品表里,那如果供应商涨价了怎么办?实际业务中,商品的“当前进价”和“采购时的进价”确实可能不一样。所以规范的建模思路是:商品表里存“当前进价”,而采购单明细里存“本次采购价”。下单时以采购单明细里的价格为准,库存成本也可以按批次来算。这样报表里的毛利才是真实可靠的。

2.2 供应商档案与采购单

供应商表本身不复杂,但要注意不能把“供应商联系人”和“供应商”做成一张表就完事。一个超市可能有几百个供应商,一个供应商有多个联系人,每次采购对接的人可能还不同。第一版我做成了单表,后面加联系人和移动电话时才发现很别扭,只能再拆表。所以建议从一开始就把 Supplier 和 SupplierContact 分开,哪怕第一版联系人字段不多。

class Supplier(models.Model): name = models.CharField(max_length=200, unique=True, verbose_name="供应商名称") contact_person = models.CharField(max_length=50, blank=True, verbose_name="默认联系人") phone = models.CharField(max_length=20, blank=True, verbose_name="联系电话") address = models.CharField(max_length=300, blank=True, verbose_name="地址") status = models.BooleanField(default=True, verbose_name="合作状态") remark = models.TextField(blank=True, verbose_name="备注") created_at = models.DateTimeField(auto_now_add=True, verbose_name="创建时间") class Meta: verbose_name = "供应商" verbose_name_plural = verbose_name def __str__(self): return self.name class PurchaseOrder(models.Model): STATUS_CHOICES = [ ("pending", "待审核"), ("confirmed", "已确认"), ("received", "已入库"), ("cancelled", "已取消"), ] order_no = models.CharField(max_length=32, unique=True, verbose_name="采购单号") supplier = models.ForeignKey(Supplier, on_delete=models.PROTECT, verbose_name="供应商") status = models.CharField(max_length=20, choices=STATUS_CHOICES, default="pending", verbose_name="状态") total_amount = models.DecimalField(max_digits=12, decimal_places=2, default=0, verbose_name="采购总金额") created_by = models.ForeignKey("auth.User", on_delete=models.PROTECT, verbose_name="制单人") created_at = models.DateTimeField(auto_now_add=True, verbose_name="创建时间") received_at = models.DateTimeField(null=True, blank=True, verbose_name="入库时间") class PurchaseOrderItem(models.Model): order = models.ForeignKey(PurchaseOrder, related_name="items", on_delete=models.CASCADE, verbose_name="采购单") product = models.ForeignKey(Product, on_delete=models.PROTECT, verbose_name="商品") quantity = models.IntegerField(default=0, verbose_name="采购数量") price = models.DecimalField(max_digits=10, decimal_places=2, verbose_name="采购单价") amount = models.DecimalField(max_digits=12, decimal_places=2, verbose_name="明细金额")

采购单的状态流转也是容易忽略的地方。我采用了一个简单的状态机:待审核 -> 已确认 -> 已入库,或者取消。只有“已入库”这一步才真正影响库存,前面的状态都只是业务审批流程。这样设计的好处是,即使采购单确认了但货还没到,库存数字不会被提前污染。

2.3 库存台账与库存流水

库存不能只用一个整数字段放在商品表里,因为查流水、做盘点、对账都需要知道“这个数字是怎么变过来的”。所以我建了两张表:StockProduct存当前库存,StockFlow存每一次变动流水。

class Warehouse(models.Model): name = models.CharField(max_length=100, unique=True, verbose_name="仓库名称") address = models.CharField(max_length=300, blank=True, verbose_name="仓库地址") class StockProduct(models.Model): warehouse = models.ForeignKey(Warehouse, on_delete=models.PROTECT, verbose_name="仓库") product = models.ForeignKey(Product, on_delete=models.PROTECT, verbose_name="商品") quantity = models.IntegerField(default=0, verbose_name="当前库存") updated_at = models.DateTimeField(auto_now=True, verbose_name="更新时间") class Meta: unique_together = ("warehouse", "product") verbose_name = "库存台账" class StockFlow(models.Model): FLOW_TYPE_CHOICES = [ ("purchase_in", "采购入库"), ("sale_out", "销售出库"), ("adjust", "盘点调整"), ("return_in", "销售退货"), ] flow_type = models.CharField(max_length=20, choices=FLOW_TYPE_CHOICES, verbose_name="流水类型") product = models.ForeignKey(Product, on_delete=models.PROTECT, verbose_name="商品") warehouse = models.ForeignKey(Warehouse, on_delete=models.PROTECT, verbose_name="仓库") quantity_change = models.IntegerField(verbose_name="变动数量,正数为入,负数为出") before_quantity = models.IntegerField(default=0, verbose_name="变动前库存") after_quantity = models.IntegerField(default=0, verbose_name="变动后库存") biz_no = models.CharField(max_length=32, db_index=True, verbose_name="关联单据号") created_at = models.DateTimeField(auto_now_add=True, verbose_name="发生时间")

每一条流水都记录变动前后的库存,这个看起来不起眼,但对排查问题帮助非常大。比如某个商品月底对账发现少了 3 件,直接查 StockFlow 就能看出是采购入库没做,还是前台退货没处理,还是盘点调整错了。如果没有流水表,等于所有历史操作全部不可追溯,这是仓储系统的硬伤。

2.4 前台销售订单与订单明细

前台收银对应的是销售订单,和采购单的结构很像,但关注点不同:销售订单要记录收银员、支付方式、优惠金额、应收金额、实收金额、找零金额。从收银角度,这些字段缺一不可,不然钱对不上。

class SaleOrder(models.Model): PAY_TYPE_CHOICES = [ ("cash", "现金"), ("wechat", "微信"), ("alipay", "支付宝"), ("card", "银行卡"), ] order_no = models.CharField(max_length=32, unique=True, verbose_name="销售单号") cashier = models.ForeignKey("auth.User", on_delete=models.PROTECT, verbose_name="收银员") total_amount = models.DecimalField(max_digits=12, decimal_places=2, verbose_name="商品总额") discount_amount = models.DecimalField(max_digits=12, decimal_places=2, default=0, verbose_name="优惠金额") payable_amount = models.DecimalField(max_digits=12, decimal_places=2, verbose_name="应收金额") received_amount = models.DecimalField(max_digits=12, decimal_places=2, verbose_name="实收金额") change_amount = models.DecimalField(max_digits=12, decimal_places=2, default=0, verbose_name="找零金额") pay_type = models.CharField(max_length=20, choices=PAY_TYPE_CHOICES, verbose_name="支付方式") created_at = models.DateTimeField(auto_now_add=True, verbose_name="下单时间") class SaleOrderItem(models.Model): order = models.ForeignKey(SaleOrder, related_name="items", on_delete=models.CASCADE, verbose_name="销售单") product = models.ForeignKey(Product, on_delete=models.PROTECT, verbose_name="商品") quantity = models.IntegerField(verbose_name="数量") price = models.DecimalField(max_digits=10, decimal_places=2, verbose_name="成交单价") amount = models.DecimalField(max_digits=12, decimal_places=2, verbose_name="明细金额")

销售单号我习惯用“日期 + 随机数”生成,例如S20250607150000123,这样即使没有自增 id 也能从单号直接看出是哪天发生的业务,打印小票、后续投诉对账都很方便。

2.5 用事务和锁保证库存一致性

数据模型建好之后,最核心的问题来了:采购入库和前台销售出库,怎么保证库存不错乱?

我的做法是:所有变更库存的操作,必须放在同一个数据库事务里,并且用select_for_update()锁住对应的库存台账行。比如前台提交订单的接口,要完成三步:创建 SaleOrder、扣减 StockProduct、写入 StockFlow。这三步要么全部成功,要么全部失败,绝不能出现订单建了但库存没扣的情况。

from django.db import transaction from django.db.models import F @transaction.atomic def create_sale_order(cashier, product_items, received_amount, pay_type): # 先锁定库存行,防止并发超卖 stock_rows = ( StockProduct.objects.select_for_update() .filter(warehouse_id=1, product_id__in=[item["product_id"] for item in product_items]) ) stock_map = {row.product_id: row for row in stock_rows} total_amount = 0 order_items = [] for item in product_items: product = Product.objects.get(pk=item["product_id"]) stock = stock_map.get(product.id) if stock is None or stock.quantity < item["quantity"]: raise ValueError(f"商品 {product.name} 库存不足") amount = product.sale_price * item["quantity"] total_amount += amount # 扣减库存,并记录流水 before = stock.quantity stock.quantity -= item["quantity"] stock.save() StockFlow.objects.create( flow_type="sale_out", product_id=product.id, warehouse_id=stock.warehouse_id, quantity_change=-item["quantity"], before_quantity=before, after_quantity=stock.quantity, biz_no=order_no, ) order_items.append(...) # 创建 SaleOrder 和 SaleOrderItem

我是故意不用F()或者update(quantity=F("quantity")-1)一步更新的,因为在扣减前我需要拿到“变动前库存”来写流水。用select_for_update()锁住这些库存行之后,并发请求会排队执行,不会出现两个订单同时读到库存 5,然后各自扣成 4 的问题。这一点在超市高峰期很关键。

3. Django Admin 和 DRF 双通道:后台管理效率与前台接口并存

3.1 为什么保留 Django Admin,而不是全部自己写页面

很多前后端分离的项目会彻底抛弃 Django Admin,全部用 Vue 重写。但进销存系统里,管理员和运营人员需要的功能往往偏表单、偏查询、偏批量处理。比如财务要看采购单、仓库要做盘点、老板要看库存报表,这些场景用 Django Admin 配合 simpleui 做界面,效率比用 Vue 重新开发快得多。

我保留 Django Admin 的核心原则是:给运营人员用的复杂后台查询留在 Admin,给收银台、供应商维护、移动端这类需要定制交互的页面交给 Vue。这样做至少省了三十个页面的前端开发量。

3.2 Django Admin 的美化与快捷操作

Django 自带的 Admin 界面有些简陋,装一个django-simpleui就能切换成侧边栏风格,按钮、表格、筛选器都比默认好看不少。更重要的是在 admin.py 里把常用字段和操作暴露出来,让运营人员少点几次鼠标。

from django.contrib import admin from .models import PurchaseOrder, PurchaseOrderItem class PurchaseOrderItemInline(admin.TabularInline): model = PurchaseOrderItem extra = 1 @admin.register(PurchaseOrder) class PurchaseOrderAdmin(admin.ModelAdmin): list_display = ("order_no", "supplier", "status", "total_amount", "created_by", "created_at") list_filter = ("status", "supplier") search_fields = ("order_no", "supplier__name") inlines = [PurchaseOrderItemInline] actions = ["mark_as_received"] @admin.action(description="标记为已入库") def mark_as_received(self, request, queryset): for order in queryset.filter(status="confirmed"): # 调用共用入库函数,生成库存流水 receive_purchase_order(order.id) order.status = "received" order.received_at = timezone.now() order.save() self.message_user(request, "已处理入库")

Admin 里最常用的是“批量入库”这个 action。采购员确认一批货到了之后,勾选采购单,点击“标记为已入库”,后台就把采购单明细逐条加到库存里并写流水。如果不用 Admin action,这个操作就得在 Vue 管理后台重新开发一整套入库页面,性价比太低了。

3.3 DRF 接口:供应商、商品、下单和权限控制

Vue 前端需要的接口集中在:登录获取 token、商品列表/搜索、供应商 CRUD、前台提交订单、查询历史订单。用 DRF 的ModelViewSet可以很快把这些接口搭起来。

from rest_framework import viewsets, permissions from rest_framework.response import Response from .models import Supplier, Product from .serializers import SupplierSerializer, ProductSerializer class SupplierViewSet(viewsets.ModelViewSet): queryset = Supplier.objects.filter(status=True) serializer_class = SupplierSerializer permission_classes = [permissions.IsAuthenticated] class ProductViewSet(viewsets.ReadOnlyModelViewSet): queryset = Product.objects.filter(status=True) serializer_class = ProductSerializer permission_classes = [permissions.IsAuthenticated] def get_queryset(self): queryset = super().get_queryset() keyword = self.request.query_params.get("keyword", "") if keyword: queryset = queryset.filter(name__icontains=keyword) return queryset

权限控制这里要注意,不是所有登录用户都能访问所有接口。收银员只需要商品查询和下单,不应该有供应商管理的权限。用 DRF 自带的DjangoModelPermissions或者 simplejwt 配合 Django Group,可以在接口层做一次过滤。实际开发中,我给采购组开放了 Supplier 和 PurchaseOrder 的写权限,给收银组只开放 Product 读权限和 SaleOrder 写权限。

3.4 导出报表时 StreamingHttpResponse 的正确姿势

热搜词里反复出现StreamingHttpResponse的参数content_typecontent-disposition,这个点确实容易踩坑。老板经常要导出一份当前库存表或者销售流水,数据量一大,直接在 Django 里拼字符串返回很容易撑爆内存。用 StreamingHttpResponse 可以边生成边返回,体验好很多。

import csv from django.http import StreamingHttpResponse def export_stock_csv(request): def generate_rows(): yield ["商品条码", "商品名称", "供应商", "当前库存"] rows = ( StockProduct.objects.select_related("product") .select_related("product__category") .values_list("product__barcode", "product__name", "product__category__name", "quantity") ) for row in rows: yield list(row) response = StreamingHttpResponse(generate_rows(), content_type="text/csv; charset=utf-8") response["Content-Disposition"] = "attachment; filename=stock_export.csv" return response

content_type决定了浏览器按什么 MIME 类型处理响应,Content-Disposition里的attachment; filename=...则让浏览器弹出下载而不是直接在页面里打开。如果中文文件名乱码,可以把 filename 用filename*=UTF-8''%E6%88%91%E7%9A%84%E6%96%87%E4%BB%B6.csv这种 URL 编码方式处理,或者直接用纯英文文件名。

4. Vue 前端:前台收银和供应商管理两个关键页面如何落地

4.1 项目创建、依赖安装与目录规划

Vue 3 项目我直接用npm create vue@latest脚手架创建,组合式 API + Vite。Node 版本尽量用 18+,否则装依赖时容易遇到engines报错。安装 Element Plus 和 axios、pinia、vue-router 这几件套之后,目录结构按模块划分:

src/ api/ # axios 接口封装 product.js supplier.js sale.js auth.js store/ # Pinia cart.js user.js views/ pos/ # 前台收银 supplier/ # 供应商管理 product/ # 商品查询 order/ # 订单查询 router/index.js App.vue

这里有个容易被新手忽略的点:不要把 axios 请求一股脑写进组件里。我一开始偷懒在每个页面里直接axios.get,后面供应商页面要复用同一个接口时,就不得不复制粘贴。统一在src/api里维护后,改一个 baseURL 或者加个 token 拦截器,全局生效,省事很多。

4.2 路由设计:前台收银和后台管理分开

路由设计上,我分了两组布局:一组是收银台的简洁布局,要尽量全屏、方便扫码枪操作;另一组是后台管理布局,带侧边栏。这样进入系统后,收银员在一个大屏收银界面,供应商采购人员进的是另一个管理界面。

import { createRouter, createWebHistory } from "vue-router"; const router = createRouter({ history: createWebHistory(import.meta.env.BASE_URL), routes: [ { path: "/login", component: () => import("@/views/Login.vue"), }, { path: "/pos", component: () => import("@/views/pos/PosLayout.vue"), children: [ { path: "", component: () => import("@/views/pos/PosHome.vue") }, { path: "orders", component: () => import("@/views/pos/OrderList.vue") }, ], }, { path: "/admin", component: () => import("@/views/admin/AdminLayout.vue"), children: [ { path: "suppliers", component: () => import("@/views/supplier/SupplierList.vue") }, { path: "products", component: () => import("@/views/product/ProductList.vue") }, ], }, ], });

使用createWebHistory是 Vue 前端常见的 history 模式,URL 看起来干净,但后面部署到 nginx 时记得要配置 try_files,否则刷新会 404。这个坑后面专门说。

4.3 Pinia 管理购物车:商品扫描、加减数量、结算

前台收银的核心就是购物车状态。Pinia 比 Vuex 更适合这个场景,写法更简单,每个 store 就是一个defineStore。购物车我维护一个商品列表和一个数量映射,商品条码扫进来的时候,按条码查询商品并加入购物车。

// store/cart.js import { defineStore } from "pinia"; export const useCartStore = defineStore("cart", { state: () => ({ items: [], // [{ product, quantity }] }), getters: { totalAmount: (state) => state.items.reduce((sum, item) => sum + item.product.sale_price * item.quantity, 0), }, actions: { addItem(product) { const found = this.items.find((item) => item.product.id === product.id); if (found) { found.quantity += 1; } else { this.items.push({ product, quantity: 1 }); } }, removeItem(productId) { this.items = this.items.filter((item) => item.product.id !== productId); }, clear() { this.items = []; }, }, });

收银台页面的逻辑是这样的:一个搜索框,扫描枪扫码后自动触发回车事件,前端拿着条码调商品查询接口,查到商品后调用cartStore.addItem。右侧显示购物车列表和总金额,底部是“结算”按钮,点击后弹出支付方式选择框,输入实收金额后自动计算找零,确认后调下单接口。

这里有个使用体验上的细节:扫码枪本质是键盘输入设备,扫完码会自动发送一个回车键。所以搜索框的@keyup.enter事件要处理得够快,不能等用户手动点按钮。我一开始没做防抖,结果扫码过快的时候接口请求顺序错乱,后面给查询接口加了请求序号,只有最后一次请求的结果才会写入购物车。

4.4 供应商管理页:Element Plus 表格、弹窗与表单校验

供应商管理页面没有收银那么复杂,但它代表了后台管理类页面的通用范式:表格展示、新增/编辑弹窗、删除确认、分页加载。

<template> <div> <el-button type="primary" @click="openDialog()">新增供应商</el-button> <el-table :data="suppliers" v-loading="loading"> <el-table-column prop="name" label="供应商名称" /> <el-table-column prop="contact_person" label="联系人" /> <el-table-column prop="phone" label="电话" /> <el-table-column label="状态"> <template #default="{ row }"> <el-tag :type="row.status ? 'success' : 'danger'"> {{ row.status ? "合作中" : "已停用" }} </el-tag> </template> </el-table-column> <el-table-column label="操作"> <template #default="{ row }"> <el-button size="small" @click="openDialog(row)">编辑</el-button> <el-button size="small" type="danger" @click="removeSupplier(row.id)">删除</el-button> </template> </el-table-column> </el-table> <el-dialog v-model="dialogVisible" :title="form.id ? '编辑供应商' : '新增供应商'"> <el-form ref="formRef" :model="form" :rules="rules" label-width="90px"> <el-form-item label="名称" prop="name"> <el-input v-model="form.name" /> </el-form-item> <el-form-item label="联系人" prop="contact_person"> <el-input v-model="form.contact_person" /> </el-form-item> <el-form-item label="电话" prop="phone"> <el-input v-model="form.phone" /> </el-form-item> </el-form> <template #footer> <el-button @click="dialogVisible = false">取消</el-button> <el-button type="primary" @click="submitForm">确定</el-button> </template> </el-dialog> </div> </template>

表单校验规则里,供应商名称必填,电话用正则校验手机或座机格式。Element Plus 的el-form-itemprop,提交时调用formRef.validate()做整体校验,避免自己写一堆if (name === "")的判断。

4.5 前端调接口的通用封装

axios 封装是老生常谈,但还是要强调一下 baseURL 和 token 的统一处理。我在src/api/request.js里创建了一个 axios 实例,设置baseURL: "/api",然后通过请求拦截器从 Pinia 的 user store 里取 token 放进 Authorization 头,响应拦截器遇到 401 就自动跳回登录页。

import axios from "axios"; import { useUserStore } from "@/store/user"; import router from "@/router"; const request = axios.create({ baseURL: "/api", timeout: 10000, }); request.interceptors.request.use((config) => { const userStore = useUserStore(); if (userStore.token) { config.headers.Authorization = `Bearer ${userStore.token}`; } return config; }); request.interceptors.response.use( (response) => response.data, (error) => { if (error.response?.status === 401) { router.push("/login"); } return Promise.reject(error); } ); export default request;

这么封装之后,页面里的接口调用就变得很干净。比如供应商列表:

import request from "@/api/request"; export function fetchSuppliers(params) { return request.get("/suppliers/", { params }); }

5. 联调与部署阶段最容易踩的五个坑

5.1 跨域配置:别把 CORS_ALLOW_ALL_ORIGINS 直接设成 True

前后端分离联调,第一步就是跨域。Django 后端默认不允许别的域名访问,需要在settings.py里安装并配置django-cors-headers

INSTALLED_APPS = [ ... "corsheaders", ] MIDDLEWARE = [ "corsheaders.middleware.CorsMiddleware", ... ] CORS_ALLOWED_ORIGINS = [ "http://localhost:5173", "https://yourdomain.com", ]

很多人图省事直接把CORS_ALLOW_ALL_ORIGINS = True打开了,开发时没问题,一上线就等于把所有接口裸奔,任何网站都能用用户浏览器发起请求。除非你做的系统完全不需要登录态,否则我建议还是明确配置允许的来源列表。开发环境里如果 Vue 跑在 5173 端口,Django 跑在 8000 端口,就把http://localhost:5173加进去。

5.2 Token 认证与请求拦截:JWT 过期处理

登录接口我用的是djangorestframework-simplejwt,前端登录后拿到accessrefresh两个 token。access token 有效期设短一些(比如 2 小时),refresh token 设长一些(比如 7 天)。前端刷新页面后从 localStorage 取 token 恢复登录态。

但有个问题不能忽略:token 过期了怎么办?如果只在响应拦截器里遇到 401 就跳登录页,用户很可能正在收银的时候突然被踢下线。我后来加了一个刷新逻辑:401 时先尝试用 refresh token 换一个新的 access token,换成功了就重放原来的请求,换失败了才跳登录页。

5.3 Vue history 刷新 404 与打包后布局异常

Vue 项目打包部署到 nginx 之后,如果用的是createWebHistory,直接访问https://domain.com/admin/suppliers会得到 nginx 的 404,因为 nginx 默认会去找服务器上对应的物理路径。解决办法是在 nginx 配置里加一个try_files

location / { root /opt/home/dist; index index.html; try_files $uri $uri/ /index.html; }

这样前端路由的所有路径都会回退到 index.html,由 Vue Router 自己接管。如果你访问的是https://domain.com,但内容是白屏或者样式错乱,多半是静态资源路径的问题。可以在vite.config.js里设base: "./",让打包后的资源引用变成相对路径;如果直接部署在域名根路径,也可以保持默认的/

还有一个容易被忽略的点:Django 的静态文件和 Vue 打包后的静态文件最好分开目录,不要让 nginx 把/static/既指向前端又指向 Django Admin。我在服务器上把 Vue 的 dist 目录放在/opt/supermarket/dist,Django 的静态文件放在/opt/supermarket/backend_static,nginx 配置两条location分别处理。

5.4 mysqlclient 在 Windows 上安装失败

mysqlclient是 Django 连 MySQL 最常用的驱动,但 Windows 上装它经常报error: Microsoft Visual C++ 14.0 is required或者找不到mysql.h。这是因为 mysqlclient 需要编译原生扩展。

最快的解决办法是去 PyPI 或者第三方 wheel 仓库下载对应的mysqlclientwheel 文件,然后本地安装。如果你实在不想折腾,可以直接改用pymysql

import pymysql pymysql.install_as_MySQLdb()

settings.py同级的__init__.py里加上面两行,Django 就可以继续用django.db.backends.mysql的方式访问 MySQL。用 pymysql 的好处是纯 Python 实现,不需要编译,缺点是大规模读写性能比 mysqlclient 稍微差一点,但对于进销存这种并发量级完全够用。

5.5 时间与时区不统一导致的问题

Django 的settings.py里时区设置直接关系到订单时间、库存流水的记录准确性。我第一版把USE_TZ = True开着,MySQL 连接配置用了本地时区,结果发现 Django 存到数据库的时间比本地时间慢了 8 小时,前台小票上的下单时间完全不对。

后来统一了方案:后端USE_TZ = TrueTIME_ZONE = "Asia/Shanghai",MySQL 的DATABASES配置里加上"OPTIONS": {"charset": "utf8mb4"},同时让 MySQL 连接的时区也保持为系统时区。前端展示时间时再把 UTC 时间转成本地时间。建议你在开发环境一开始就把时区定好,不要等看到时间差再来改,否则历史数据时间全是歪的。

6. 实测效果与下一步可以继续加的功能

6.1 用造数脚本跑了一遍,性能瓶颈浮出水面

我写了一个造数脚本,造了 200 个商品、8 个供应商、2 个仓库、5000 条库存流水,然后在 Vue 前台页面里连续模拟扫码下单。开发模式下接口响应基本在 80ms 到 150ms,但商品搜索如果直接用name__icontains,数据量到 1 万条时明显变慢,原因很简单:LIKE '%关键词%'扫全表,索引用不上。

解决办法是给常用的搜索字段加索引,或者在商品表里增加一个专门用于搜索的字段,比如search_text = CharField,写入时冗余保存“条码 + 名称 + 分类名”,搜索时用icontains去查这个冗余字段,能稍微缓解一点。数据量再大的话,就该考虑接入全文检索中间件了,但超市进销存一般几万条商品规模,MySQL 加索引够用。

6.2 高并发下单不超卖的关键在事务边界

我在本地用多线程并发测同一个商品下单,create_sale_order接口在加了select_for_update()后没有出现负库存。但如果把锁的范围扩大,整个事务时间会变长,前台收银可能感觉有点卡。所以锁的粒度一定要小:只锁当前仓库里当前商品的StockProduct行,不要锁整张表或者锁所有商品。

如果以后要做秒杀或者大促,光靠数据库行锁可能不够,可以在 Redis 里先把库存预热,下单时先扣 Redis,再异步同步数据库。但对于超市日常收银,这个数据库事务方案已经足够了。

6.3 盘点、退货、多仓库这些扩展点

当前系统已经跑通了采购入库、前台销售出库的基本流程,但实际超市业务还有几个高频场景没覆盖:

  • 盘点:仓库管理员不会一个个数商品,而是拿着手持终端扫码,一个 SKU 扫到实际数量后和系统库存对比,生成盈亏调整单。
  • 销售退货:顾客拿小票退货,需要原单反查,生成退货单,库存加回去。
  • 多仓库调拨:连锁超市会有总仓和门店仓,门店缺货从总仓调入,涉及调拨单和双仓库存变动。
  • 保质期批次:食品、饮料有批次和保质期,商品表里要增加批次表,销售出库时默认先进先出。

这些扩展都可以基于现有的StockFlow流水机制往上加,因为流水已经记录了变动前和变动后的库存,新增一种业务单据只是新增一种flow_type,不会破坏原有数据链路。

6.4 给同样做进销存系统的朋友三个建议

最后说三个我用真金白银换来的经验。

采购入库和前台销售两个模块,一定要先做,而且一定要先定好库存流水的格式再动手。流水表是整个系统最容易返工的地方,一开始没设计好,后面所有模块都要跟着改。

Django Admin 和 Vue 页面不是二选一的关系。给内部员工用的查询、批量处理、报表导出,优先用 Admin;给收银员、供应商这些需要良好交互的角色,用 Vue。不要为了追求“前后端分离”而把 Admin 丢掉,那是浪费现成的生产力。

开发阶段尽量贴近真实业务模拟数据。我用脚本造了几百个商品和几千条流水后,前端分页、搜索、订单列表的性能问题才真正暴露出来。空表状态下一切都是流畅的,别等到上线后才发现报表加载要好几秒。

版权声明: 本文来自互联网用户投稿,该文观点仅代表作者本人,不代表本站立场。本站仅提供信息存储空间服务,不拥有所有权,不承担相关法律责任。如若内容造成侵权/违法违规/事实不符,请联系邮箱:809451989@qq.com进行投诉反馈,一经查实,立即删除!
网站建设 2026/9/14 23:26:57

三句话定方向:2026目标梳理法实操指南

每年年底到年初&#xff0c;都会冒出一大批"焕新宣言"&#xff1a;我要彻底改变自己、我要早睡早起、我要瘦二十斤、我要靠副业月入过万。等到二月底&#xff0c;这些宣言里有一半已经没人记得了。我做了十多年目标管理相关的工作&#xff0c;见过太多人在热血上头和…

作者头像 李华
网站建设 2026/9/14 23:25:18

OpenHarness框架:轻量级AI智能体开发实战解析

1. OpenHarness框架核心定位解析OpenHarness是2023年出现在AI工程化领域的一个现象级开源项目&#xff0c;它用不到2000行Python代码重构了智能体开发的基础设施。与传统AI框架不同&#xff0c;它的设计哲学非常明确&#xff1a;只做智能体运行时的"方向盘和油门踏板"…

作者头像 李华
网站建设 2026/9/14 23:22:06

电信海量工单处理智能体推荐:按话务、故障与投诉工单场景筛选

电信运营商的工单体系里&#xff0c;话务、故障、投诉三类工单的底层逻辑差异很大&#xff1a;话务工单拼的是实时响应和一解率&#xff0c;故障工单拼的是定位速度和跨专业协同&#xff0c;投诉工单拼的是合规策略和知识沉淀。用一套通用方案通吃三类场景&#xff0c;落地效果…

作者头像 李华
网站建设 2026/9/14 23:21:57

Python编程入门:从零开始的环境搭建与基础语法

1. Python初体验&#xff1a;从零开始的编程之旅第一次接触Python时&#xff0c;我被它的简洁语法所震撼。记得当时在交互式环境中输入print("Hello World")后&#xff0c;屏幕上立即显示出结果的那种成就感。Python作为当下最受欢迎的编程语言之一&#xff0c;以其易…

作者头像 李华
网站建设 2026/9/14 23:21:56

沈阳网站建设tlmh哪家好,网站被黑挂马3招救活

沈阳网站建设tlmh哪家好,网站被黑挂马3招救活 网站被黑挂马不知道怎么办?这是很多沈阳本地企业站长的噩梦。半夜收到报警短信,打开浏览器满屏乱码,或者谷歌搜索自家域名直接跳出色情页面,那种无助感真的让人崩溃。这时候你才会真正意识到,当初选建站公司的时候,光看价格够不够便宜,完全是在给未来的安全埋雷。…

作者头像 李华