1. 项目概述与需求拆解
先说结论:这套系统的本质,就是把一家家电维修店里最琐碎、最容易出错的几件事——接单登记、维修进度、配件出入库、客户欠账、师傅提成——全部从本子上挪到web页面里。我前后帮两家维修店做过类似的内部工具,一家是做冰箱空调的,一家专修小家电,需求高度重叠,所以这个搭法基本是通用解。
做之前先想清楚一件事:维修店老板真正要的是什么?不是好看的后台界面,不是花哨的技术栈,而是三个字:省事情。报修电话一进来,店员能快速录单;师傅上门回来要能查到工单状态;月底算账的时候,配件卖了多少钱、师傅提成该发多少、哪个客户还欠着钱,都能直接拉出来。围绕这三件事去设计,系统就不会跑偏。我把核心需求拆成五个模块:客户管理、工单管理(维修流程)、配件库存、员工(师傅)管理、统计报表,外加一个最简单的登录权限。这个范围做下来,小团队半个月到一个月的业余时间完全能交付。
技术选型上,后端用Python的Flask框架,前端用Vue3搭配Vite和Element Plus,数据库用MySQL。为什么这么搭?Python在后端写业务逻辑快,Flask轻量、没有Django那一套强约束,适合这种接口数量在二十个左右的中小型系统;Vue3的组合式API写页面状态管理比Vue2顺手得多,Element Plus本身就是为后台管理场景准备的组件库,表格、表单、弹窗、日期选择器都是现成的,比从零写样式省一半时间。这套组合在同类“管理信息系统”里非常常见,社区资料多,踩坑了也容易搜到解决方案。
适合谁参考这篇内容?正在做课程设计、毕业设计的学生,或者想给自己门店搞一套内部管理工具的个体老板,又或者单纯想练手前后端分离项目的Vue3初学者。下面我把整个系统的设计思路、核心代码、以及我实际踩过的坑全部拆开讲,照着敲就能跑起来。
2. 数据库设计与表结构规划
2.1 五张核心业务表的关系梳理
数据库是整个系统的地基,表设计错了后面改起来非常痛苦。我第一版做的时候把配件和工单塞在同一张表里,结果后期统计配件的出入库数量时逻辑绕得想砸电脑,第二版老老实实拆表。这里直接给出我最终稳定使用的五张表结构。
第一张是用户表(users),存登录账号、密码哈希、角色。角色就分两种:管理员和员工。管理员能看报表、管理配件库存,员工主要操作工单和客户。密码不能用明文,用werkzeug自带的generate_password_hash和check_password_hash,这是Flask生态里最省事的做法,不用额外引第三方库。
第二张是客户表(customers),字段包括客户姓名、电话、地址、备注。注意电话要做唯一约束,因为维修店经常需要按电话回访或查历史记录,手机号是客户最稳定的身份标识。
第三张是工单表(repair_orders),这是整个系统的核心。字段要覆盖:工单编号(order_no)、客户ID、设备类型、品牌、故障描述、维修状态、接单时间、完成时间、维修费用、配件费用、师傅ID、备注。维修状态我设计成四档:待接单、维修中、待取件、已完成。这里有一个容易忽略的点:要把“维修费用”和“配件费用”分开存,不要合并成一个总价,否则月底算师傅提成和配件利润的时候你只能干瞪眼。
第四张是配件表(parts),字段包括配件名称、型号、适配设备类型、进货单价、售价、库存数量、预警阈值。售价用decimal类型存,别用float,浮点数在金额计算上会有精度问题。
第五张是配件出入库流水表(part_stock_logs),记录每次入库、出库、维修消耗的数量变化,字段包括配件ID、变动数量(正数入库、负数出库)、变动类型、关联工单ID、操作时间、备注。这张表一定要有,不然库存对不上账的时候根本没地方查。
2.2 建表SQL与字段类型踩坑记录
直接上建表SQL,字段类型和约束都测试过。MySQL版本8.0以上,utf8mb4字符集,避免中文乱码。
CREATE DATABASE IF NOT EXISTS repair_shop DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci; USE repair_shop; CREATE TABLE users ( id INT AUTO_INCREMENT PRIMARY KEY, username VARCHAR(50) NOT NULL UNIQUE, password_hash VARCHAR(255) NOT NULL, role ENUM('admin', 'staff') DEFAULT 'staff', created_at DATETIME DEFAULT CURRENT_TIMESTAMP ); CREATE TABLE customers ( id INT AUTO_INCREMENT PRIMARY KEY, name VARCHAR(50) NOT NULL, phone VARCHAR(20) NOT NULL UNIQUE, address VARCHAR(255), remark TEXT, created_at DATETIME DEFAULT CURRENT_TIMESTAMP ); CREATE TABLE repair_orders ( id INT AUTO_INCREMENT PRIMARY KEY, order_no VARCHAR(32) NOT NULL UNIQUE, customer_id INT NOT NULL, device_type VARCHAR(50), brand VARCHAR(50), fault_desc TEXT, status ENUM('pending', 'repairing', 'ready', 'done', 'canceled') DEFAULT 'pending', technician_id INT, repair_fee DECIMAL(10, 2) DEFAULT 0.00, parts_fee DECIMAL(10, 2) DEFAULT 0.00, create_time DATETIME DEFAULT CURRENT_TIMESTAMP, finish_time DATETIME, remark TEXT, FOREIGN KEY (customer_id) REFERENCES customers(id), FOREIGN KEY (technician_id) REFERENCES users(id) ); CREATE TABLE parts ( id INT AUTO_INCREMENT PRIMARY KEY, name VARCHAR(100) NOT NULL, model VARCHAR(100), device_type VARCHAR(50), purchase_price DECIMAL(10, 2) NOT NULL, sale_price DECIMAL(10, 2) NOT NULL, stock_quantity INT DEFAULT 0, warn_threshold INT DEFAULT 5, updated_at DATETIME DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP ); CREATE TABLE part_stock_logs ( id INT AUTO_INCREMENT PRIMARY KEY, part_id INT NOT NULL, change_quantity INT NOT NULL, change_type ENUM('in', 'out', 'repair_use') NOT NULL, related_order_id INT, operator_id INT, created_at DATETIME DEFAULT CURRENT_TIMESTAMP, remark VARCHAR(255), FOREIGN KEY (part_id) REFERENCES parts(id) );有几个字段设计上的细节必须说清楚。工单编号order_no不要用自增ID直接展示,因为客户报修时经常要报单号,自增ID容易暴露门店一天接了多少单,而且位数不稳定。我在生成时用日期加随机数,格式长这样:20250611 001,也就是年月日加当天流水号,前端展示和客户沟通都很方便。
status字段的四档状态看起来简单,实际操作时发现“待取件”和“已完成”很容易被忽略。很多维修店的习惯是修好了就给客户打电话,客户拿走时才结账,但系统里如果只有“维修中”和“已完成”两档,老板就不知道哪些机器修好了还没被取走,时间长了机器堆在店里没人认领。加一个“待取件”状态,门店可以定期拉出清单主动联系客户。
还有一个不起眼但很重要的字段:维修费用和配件费用分开这个决定,直接决定了月底算账能不能轻松。维修费是师傅的手工钱,配件费是门店卖配件的收入,这两个钱在财务逻辑上是完全不同的流向。有经验之后再看那些把所有费用拢在一起的系统设计,都是给后期做报表埋雷。
3. 后端Flask接口设计与核心逻辑
3.1 项目目录结构与Flask工厂模式
后端我不用Django,就用Flask,因为这里没有复杂的用户权限体系和后台管理需求,Flask的灵活度刚好够用。项目目录结构如下,各文件职责清晰:
repair-server/ ├── app.py # 入口文件,Flask实例化与蓝图注册 ├── config.py # 数据库连接等配置文件 ├── models.py # SQLAlchemy模型定义 ├── auth.py # 登录与token校验 ├── routes/ │ ├── customers.py # 客户相关接口 │ ├── orders.py # 工单相关接口 │ ├── parts.py # 配件与库存接口 │ └── stats.py # 统计报表接口 ├── requirements.txt └── .env # 环境变量(不提交到git)app.py核心代码不长,但有几个关键配置点需要说明。
# app.py from flask import Flask, jsonify, request from flask_cors import CORS from flask_sqlalchemy import SQLAlchemy from flask_jwt_extended import JWTManager, jwt_required, get_jwt_identity, create_access_token db = SQLAlchemy() def create_app(): app = Flask(__name__) app.config.from_object('config.Config') # CORS必须开启,前后端分离项目联调阶段跨域问题能省一大半 CORS(app, resources={r"/api/*": {"origins": "*"}}) db.init_app(app) jwt = JWTManager(app) # 蓝图注册,每个模块独立路由文件,避免app.py变成千行大杂烩 from routes.customers import customers_bp from routes.orders import orders_bp from routes.parts import parts_bp from routes.stats import stats_bp app.register_blueprint(customers_bp, url_prefix='/api/customers') app.register_blueprint(orders_bp, url_prefix='/api/orders') app.register_blueprint(parts_bp, url_prefix='/api/parts') app.register_blueprint(stats_bp, url_prefix='/api/stats') return app app = create_app() if __name__ == '__main__': # 调试阶段host设0.0.0.0,方便局域网内测试,别用127.0.0.1 app.run(host='0.0.0.0', port=5000, debug=True)config.py里有一个容易踩坑的点:数据库连接串里的密码如果包含特殊字符,要先做URL编码,否则SQLAlchemy直接报连接错误。我自己遇到过密码里有@符号的情况,折腾了半小时才反应过来是连接串的问题。
# config.py import os from datetime import timedelta class Config: SECRET_KEY = 'your-secret-key-here' SQLALCHEMY_DATABASE_URI = 'mysql+pymysql://root:yourpassword@localhost:3306/repair_shop?charset=utf8mb4' SQLALCHEMY_TRACK_MODIFICATIONS = False JWT_EXPIRATION_DELTA = timedelta(hours=12)JWT的过期时间不要太短,维修店的员工用系统是从早到晚连续使用的,token过期了还要重新登录,店里的老员工会直接说这系统难用。12小时是合理的,再长就不安全了。
3.2 客户管理与工单创建接口
客户接口相对简单,就是增删改查,但工单创建接口藏着几个业务细节。先看代码再解释。
# routes/orders.py from flask import Blueprint, request, jsonify from models import RepairOrder, Customer, Part, PartStockLog, db from datetime import datetime from decimal import Decimal orders_bp = Blueprint('orders', __name__) def generate_order_no(): """生成工单号:日期+当天流水,例如20250611-0001""" today = datetime.now().strftime('%Y%m%d') count = RepairOrder.query.filter( RepairOrder.order_no.like(f'{today}%') ).count() + 1 return f'{today}-{count:04d}' @orders_bp.route('', methods=['POST']) @jwt_required() def create_order(): """创建维修工单,这是门店每天最频繁的操作""" data = request.get_json() # 校验客户是否存在,前端如果传了customer_id就直接复用 customer_id = data.get('customer_id') if not customer_id: # 如果没传id,尝试通过手机号找客户,找不到就创建新客户 phone = data.get('customer_phone') customer = Customer.query.filter_by(phone=phone).first() if not customer: customer = Customer( name=data.get('customer_name', '未知客户'), phone=phone, address=data.get('customer_address', '') ) db.session.add(customer) db.session.flush() # 立即获取自增id customer_id = customer.id order = RepairOrder( order_no=generate_order_no(), customer_id=customer_id, device_type=data.get('device_type', ''), brand=data.get('brand', ''), fault_desc=data.get('fault_desc', ''), status='pending', technician_id=get_jwt_identity(), remark=data.get('remark', '') ) db.session.add(order) db.session.commit() return jsonify({'code': 0, 'message': 'success', 'data': {'order_no': order.order_no}}) @orders_bp.route('/<order_id>/status', methods=['PUT']) @jwt_required() def update_status(order_id): """状态流转:接单→维修中→待取件→已完成""" order = RepairOrder.query.get(order_id) if not order: return jsonify({'code': 1, 'message': '工单不存在'}), 404 data = request.get_json() new_status = data.get('status') valid_status = ['pending', 'repairing', 'ready', 'done', 'canceled'] if new_status not in valid_status: return jsonify({'code': 1, 'message': '无效状态'}), 400 order.status = new_status if new_status == 'done': order.finish_time = datetime.now() order.repair_fee = Decimal(str(data.get('repair_fee', 0))) # 关键:Decimal转字符串 order.parts_fee = Decimal(str(data.get('parts_fee', 0))) db.session.commit() return jsonify({'code': 0, 'message': 'success'})有几个细节必须说透。第一,生成工单号那部分用like '20250611%'查当天数量,这个操作在数据量小的门店完全没问题,但如果将来单量大了记得给order_no加索引。第二,接收前端传的金额时用Decimal(str(...)),前端传过来的数字可能是JSON的number类型,直接转Decimal会有精度问题,先转成字符串再转Decimal才精确。我初期直接Decimal(data.get('repair_fee')),一位小数时没事,两位小数偶尔冒出来一个长尾巴的浮点误差,后来全部改成字符串中转。
状态流转接口在这里其实承担了“师傅确认接单”和“前台确认完成”两个场景。实际操作中我发现,让师傅自己登录系统点“开始维修”不太现实,师傅在维修间里手上全是油,让他们掏手机登录网页操作太反人性。最终的使用方式是前台或者老板帮师傅操作状态流转,师傅只口头汇报“这台修好了”。所以系统设计不要太理想化,要考虑真实门店里谁在操作、什么场景下操作。
3.3 配件库存与出库扣减逻辑
配件模块是整个系统里最容易出bug的地方,核心难点是:维修工单完成时,配件库存要同步扣减,而且这条扣减记录必须关联到工单,方便事后对账。
# routes/parts.py @parts_bp.route('/<part_id>/stock', methods=['POST']) @jwt_required() def change_stock(part_id): """入库出库操作,negative表示为出库""" part = Part.query.get(part_id) if not part: return jsonify({'code': 1, 'message': '配件不存在'}), 404 data = request.get_json() change_qty = int(data.get('change_quantity', 0)) change_type = data.get('change_type', 'in') related_order_id = data.get('related_order_id') # 关键校验:出库时库存够不够 if change_qty < 0 and part.stock_quantity + change_qty < 0: return jsonify({'code': 1, 'message': f'库存不足,当前库存: {part.stock_quantity}'}), 400 part.stock_quantity += change_qty log = PartStockLog( part_id=part_id, change_quantity=change_qty, change_type=change_type, related_order_id=related_order_id, operator_id=get_jwt_identity(), remark=data.get('remark', '') ) db.session.add(log) db.session.commit() return jsonify({'code': 0, 'message': 'success', 'data': {'stock_quantity': part.stock_quantity}})这个“库存不足就拒绝操作”的前置校验是必须的,不然后端没有这层保护,前端并发操作或者用户多点了几下,库存就变负数了。另外注意日志表里的operator_id记录的是操作人,这个字段在出问题时追责很有用,虽然门店里基本不会互相信任到能当面问“那个订单是不是你扣的库存”,但后台一查就知道。
工单完成时如果涉及换配件,要从两个接口配合:一是工单的状态更新接口,二是配件库存的扣减接口。前端分开调用,中间任何一个失败都不影响另一个的数据一致性。这个设计不算完美,但对小门店够用了。要做到事务一致性需要引入消息队列或者分布式事务,对二十个接口的系统来说纯属过度设计。
4. 前端Vue3页面实现与联调实战
4.1 Vite创建项目与Element Plus引入
前端工程用Vite创建,比Webpack快太多。这里我踩过一个坑:用npm create vue@latest创建的项目默认不带Vue Router,需要手动装。执行下面命令一把梭:
npm create vue@latest cd repair-web npm install npm install vue-router@4 element-plus @element-plus/icons-vue axios npm install sass -DElement Plus引入方式有全量引入和按需引入两种,小项目图省事直接全量引入,首屏加载慢一点但开发体验好。在main.js里加:
// main.js import { createApp } from 'vue' import App from './App.vue' import router from './router' import ElementPlus from 'element-plus' import 'element-plus/dist/index.css' import zhCn from 'element-plus/es/locale/lang/zh-cn' const app = createApp(App) app.use(router) app.use(ElementPlus, { locale: zhCn }) app.mount('#app')加上zhCn语言包是因为Element Plus默认英文,日期组件、分页器的提示全是英文的,维修店老板看不懂“No Data”是什么鬼。这是新手很容易漏掉的一步。
4.2 前端路由与权限拦截设计
路由设计分两部分:登录页和主布局页。主布局页用嵌套路由,左侧菜单固定显示客户管理、工单管理、配件库存、统计报表四个入口,顶部显示当前登录用户名和退出按钮。
// router/index.js import { createRouter, createWebHistory } from 'vue-router' const router = createRouter({ history: createWebHistory(), routes: [ { path: '/login', component: () => import('@/views/Login.vue') }, { path: '/', component: () => import('@/layout/MainLayout.vue'), redirect: '/orders', children: [ { path: 'customers', component: () => import('@/views/Customers.vue'), meta: { title: '客户管理' } }, { path: 'orders', component: () => import('@/views/Orders.vue'), meta: { title: '工单管理' } }, { path: 'parts', component: () => import('@/views/Parts.vue'), meta: { title: '配件库存' } }, { path: 'stats', component: () => import('@/views/Stats.vue'), meta: { title: '统计报表' } }, ], }, ], }) // 全局路由守卫:没token就跳登录页 router.beforeEach((to, from, next) => { const token = localStorage.getItem('token') if (to.path !== '/login' && !token) { next('/login') } else { next() } }) export default router权限拦截别搞太复杂,判断有没有token就够了。角色区分在前端只做一件事:管理员能看到统计报表菜单,员工不显示。但真正安全的做法是后端接口也要鉴权,统计接口加一个@jwt_required()加roles判断,不然前端藏着菜单、用户直接拼URL照样能访问接口。
4.3 工单管理页面:表格、状态流转、配件选择
工单管理页面是这个系统用得最频繁的页面,我把核心代码框架贴出来并解释关键点。
<!-- views/Orders.vue --> <template> <div class="order-page"> <el-card> <div class="toolbar"> <el-button type="primary" @click="openCreateDialog">新增工单</el-button> <el-select v-model="statusFilter" placeholder="按状态筛选" clearable @change="fetchOrders"> <el-option label="待接单" value="pending" /> <el-option label="维修中" value="repairing" /> <el-option label="待取件" value="ready" /> <el-option label="已完成" value="done" /> <el-option label="已取消" value="canceled" /> </el-select> </div> <el-table :data="orders" border stripe v-loading="loading"> <el-table-column prop="order_no" label="工单号" width="130" /> <el-table-column prop="customer_name" label="客户" width="100" /> <el-table-column prop="device_type" label="设备类型" width="100" /> <el-table-column prop="brand" label="品牌" width="100" /> <el-table-column prop="fault_desc" label="故障描述" show-overflow-tooltip /> <el-table-column label="状态" width="100"> <template #default="{ row }"> <el-tag :type="statusTagMap[row.status]">{{ row.status }}</el-tag> </template> </el-table-column> <el-table-column label="操作" width="220"> <template #default="{ row }"> <el-button size="small" @click="updateStatus(row)">状态流转</el-button> <el-button size="small" type="danger" @click="finishOrder(row)" v-if="row.status === 'repairing' || row.status === 'pending'">完成结算</el-button> </template> </el-table-column> </el-table> </el-card> <!-- 新增/编辑工单弹窗,省略模板细节 --> <el-dialog v-model="dialogVisible" title="新增工单" width="600px"> <!-- 客户电话输入,失焦时自动查询客户信息填充 --> <el-form :model="orderForm" label-width="100px"> <el-form-item label="客户电话"> <el-input v-model="orderForm.customer_phone" @blur="queryCustomerByPhone" /> </el-form-item> <el-form-item label="客户姓名"> <el-input v-model="orderForm.customer_name" /> </el-form-item> <el-form-item label="设备类型"> <el-select v-model="orderForm.device_type"> <el-option label="冰箱" value="冰箱" /> <el-option label="空调" value="空调" /> <el-option label="洗衣机" value="洗衣机" /> <el-option label="电视" value="电视" /> <el-option label="其他" value="其他" /> </el-select> </el-form-item> <el-form-item label="品牌"> <el-input v-model="orderForm.brand" /> </el-form-item> <el-form-item label="故障描述"> <el-input v-model="orderForm.fault_desc" type="textarea" :rows="3" /> </el-form-item> </el-form> <template #footer> <el-button @click="dialogVisible = false">取消</el-button> <el-button type="primary" @click="submitOrder">提交</el-button> </template> </el-dialog> </div> </template>JS部分的axios封装我单独拎出来讲。API请求统一走axios拦截器,在请求头带上token,响应统一处理错误码。
// utils/request.js import axios from 'axios' import { ElMessage } from 'element-plus' import router from '@/router' const request = axios.create({ baseURL: 'http://localhost:5000/api', timeout: 10000, }) // 请求拦截器:带上token request.interceptors.request.use(config => { const token = localStorage.getItem('token') if (token) { config.headers.Authorization = `Bearer ${token}` } return config }) // 响应拦截器:统一处理错误 request.interceptors.response.use( response => { const res = response.data if (res.code !== 0) { ElMessage.error(res.message) return Promise.reject(new Error(res.message)) } return res }, error => { if (error.response && error.response.status === 401) { ElMessage.error('登录已过期,请重新登录') localStorage.removeItem('token') router.push('/login') } else { ElMessage.error(error.message || '请求失败') } return Promise.reject(error) } ) export default request这里有个细节,请求拦截器里的Authorization字段大小写是有讲究的。HTTP标准头是Authorization,首字母大写,很多新手写成authorization小写,后端JWT扩展默认也是认标准格式,结果前端一直401排查半天。这种问题不出现在教程里,但实际项目中特别常见。
4.4 客户电话模糊查询减少录入工作量
门店前台用系统最烦的是每个客户都要重新录入。我的方案是:输入电话的输入框绑定了blur事件,失焦时调用后端接口模糊查询。如果查到了直接回填空客户信息,查不到再手动录姓名和地址。这个交互细节能让录单速度提升一半,是我在实际调研中跟店老板聊出来的真实需求——他们录入最耗时的就是客户信息字段。
前端实现如下:
const queryCustomerByPhone = async () => { if (orderForm.customer_phone.length < 3) return const res = await request.get('/customers/search', { params: { phone: orderForm.customer_phone } }) if (res.data && res.data.length > 0) { orderForm.customer_name = res.data[0].name orderForm.customer_address = res.data[0].address ElMessage.success('已自动匹配到客户信息') } }后端搜索接口做模糊匹配用LIKE %phone%就行,没必要上全文索引。搜索接口返回的结果可能有多条,前端直接取第一条填充,然后提示用户“已自动匹配到客户信息”,用户如果发现匹配错了可以手动改。用户体验比直接弹窗选择要好,因为弹窗选择多一步操作,对高频录单场景来说每一步多余操作都是在消耗员工耐心。
5. 常见问题与排查技巧实录
5.1 跨域配置与前端联调阶段的高频报错
前后端分离项目联调,第一个拦路虎就是CORS。前端请求后端接口报错信息是“CORS policy: No 'Access-Control-Allow-Origin' header is present”,排查步骤我按经验顺序排列。
第一,确认后端Flask是否装了flask-cors并调用了CORS(app)。第二,如果后端没有任何问题,检查是不是请求头里带了Authorization导致触发了预检请求OPTIONS。Flask-CORS默认会处理预检请求,但如果你在后端自己写的全局中间件里拦了OPTIONS,就会出问题。第三,如果后端部署在Nginx后面,检查Nginx配置有没有把Access-Control-Allow-*响应头放过去。
我在开发阶段最常用的一招是:后端开debug模式,host设为0.0.0.0,前端用Vite的proxy代理,这样浏览器端看起来是同源请求。Vite配置文件vite.config.js里加一段:
// vite.config.js export default defineConfig({ server: { host: '0.0.0.0', port: 5173, proxy: { '/api': { target: 'http://localhost:5000', changeOrigin: true, }, }, }, })用代理之后,前端请求路径直接写/api/xxx,不用写完整域名,这样既能避免CORS问题,部署到生产环境时只要把代理的目标地址改成线上后端地址就行,前端代码完全不用改。
5.2 Python依赖版本导致的数据库连接报错
requirements.txt里如果直接写Flask-SQLAlchemy、pymysql的最新版本,在部分环境下会启动报错。这个坑的具体表现是:运行app.py时报ModuleNotFoundError: No module named 'MySQLdb',或者报TypeError: __init__() got an unexpected keyword argument 'isolation_level'。
第一个错误是因为Flask-SQLAlchemy旧版本默认用的MySQLDB驱动,而Python3里MySQLDB基本不可用,必须显式指定pymysql驱动。我的做法是在app.py里加一行import pymysql; pymysql.install_as_MySQLdb(),或者在配置里写清楚连接串用mysql+pymysql://。第二个错误是通过升级Flask-SQLAlchemy版本解决,版本区间锁在2.5.1到3.0.x之间比较稳。我最终稳定的依赖组合如下:
Flask==3.0.3 Flask-SQLAlchemy==3.1.1 Flask-JWT-Extended==4.6.0 Flask-Cors==4.0.1 PyMySQL==1.1.1 cryptography==42.0.8cryptography这个包值得单独说:PyMySQL连接MySQL 8.0时用默认的caching_sha2_password认证方式,没装cryptography库会直接报错Authentication plugin 'caching_sha2_password' cannot be loaded。很多人卡在这里,以为是数据库密码不对,其实是差一个加密库。我把这个依赖写进requirements.txt就是怕后来人又踩一遍。
5.3 Element Plus表格数据量过大时的性能优化
维修店的工单量一天几十条,一年也就一万多条,用Element Plus的el-table直接渲染完全没问题,不需要引入虚拟滚动。但如果统计报表接口返回几百条数据且表格列多,首次渲染会卡顿,原因是Element Plus在数据更新时会全量diff所有列。我的解决方法有两个:一是给el-table加border属性,二是给数据量大的列加show-overflow-tooltip,这样表格内部会优化DOM渲染,实测下来流畅度提升明显。
更深一层的问题是某些表格单元格里嵌套了el-select、el-input这类组件,量大了之后组件实例太多导致卡顿。比如状态筛选下拉框里如果每个单元格都用el-select而不是用el-tag展示,页面就会明显变慢。方案是:预览场景只用el-tag或纯文本,只有在点击编辑时才弹出表单。这个原则在我后续做所有管理后台时都适用——表格是给人看的,不是给人在里面操作的。
前端还有个大坑是Element Plus的el-date-picker组件在某些版本下弹层错位。遇到这类问题先升级组件库到最新版本,不要在样式上硬调,否则换版本又白改。
5.4 中文乱码问题的一站式排查
中文乱码的根源只有一个:字符集不一致。必须保证全链路都是utf8mb4,任何一个环节用了latin1或gbk都会乱码。检查清单如下:
- MySQL建库时指定utf8mb4,建表语句里也能看到
DEFAULT CHARACTER SET utf8mb4,SQL连接串带charset=utf8mb4。 - 后端Flask的JSON响应编码:Flask默认JSON响应是UTF-8,但是如果Windows上的Python环境里
PYTHONIOENCODING没设对,日志里的中文会乱码,接口返回的JSON倒是不受影响。 - 前端页面html标签里设置
<meta charset="UTF-8">,Vite脚手架默认就有,但如果是手动接老项目,检查一下可能遗漏。
我实际遇到一次奇葩情况:数据库数据正常,前端显示正常,但导出CSV文件用Excel打开是乱码。原因是CSV文件没有BOM头,Excel用ANSI编码去解析。解决办法是在导出接口返回的CSV内容前面加\ufeff,也就是UTF-8 BOM,Excel就自动识别成UTF-8了。这种小问题在教程里永远没人讲,但真实业务中特别常见。
6. 统计报表与经营数据分析
6.1 维修收入、配件利润、师傅提成三大核心报表
系统做到这一步,老板最关心的就是钱。统计报表模块我做了三个维度:按日/周/月筛选的维修收入、配件出库明细、师傅工作量排行。
维修收入统计接口的关键SQL逻辑是:按状态为“已完成”的工单聚合维修费用的总和。注意不能把“维修中”或“待取件”的单子算进去,因为钱还没收到。这里有一个业务判断值得讨论:很多维修店是修好后客户取件时才付钱,但也有先付定金后修的情况。我最终采用的规则是“完工即计入收入”,因为老板要的是经营趋势,不是财务账面。
# routes/stats.py @stats_bp.route('/income', methods=['GET']) @jwt_required() def income_stats(): """按日期范围统计维修收入与配件收入""" start_date = request.args.get('start_date', datetime.now().strftime('%Y-%m-%d')) end_date = request.args.get('end_date', datetime.now().strftime('%Y-%m-%d')) result = db.session.execute( db.text(""" SELECT DATE(create_time) as day, SUM(repair_fee) as total_repair_fee, SUM(parts_fee) as total_parts_fee, COUNT(*) as order_count FROM repair_orders WHERE status = 'done' AND DATE(create_time) BETWEEN :start_date AND :end_date GROUP BY DATE(create_time) ORDER BY day DESC """), {'start_date': start_date, 'end_date': end_date} ).fetchall() data = [ {'day': row.day, 'repair_fee': float(row.total_repair_fee), 'parts_fee': float(row.total_parts_fee), 'count': row.order_count} for row in result ] return jsonify({'code': 0, 'data': data})师傅提成的计算规则每家店都不一样,我的实现是做成一个可以配置的公式:提成 = 维修费 × 比例(默认30%) + 配件费的利润 × 比例(默认50%)。为什么配件费要算利润而不是按售价提成?因为配件有进货成本,师傅如果无脑换高端件,按售价提成门店反而亏。这个逻辑是跟做家电维修的老师傅聊出来的,他认为配件提成应该按“卖配件的净利润”来算,而不是按售价抽成。虽然各家算法有差异,但系统里关键是把维修费和配件费分开存储,这样无论采用什么提成公式都能灵活计算。
6.2 配件低库存预警与采购建议
配件库存的预警逻辑很简单:查询所有stock_quantity < warn_threshold的配件,前端在配件页面顶部用醒目颜色提示“以下配件库存不足”。但光有预警不够,还要配合采购建议——根据过去30天每个配件的出库数量,估算平均每日消耗量,再结合老板设定的采购周期(比如7天补一次货),给出建议采购量。
采购建议的计算公式:建议采购量 = (每日平均出库量 × 采购周期天数) - 当前库存。这个公式不是拍脑袋想的,本质是库存管理里经典的订货点法。门店小老板不需要懂这些名词,但系统给出的数字他们一眼就能看懂:“这个配件再进10个就够了”。
这个模块的开发重点在后端SQL的写法上,要同时做聚合和关联查询。我建议先把查询结果打印出来人工核对一遍再写前端,因为SQL聚合容易写出一堆检查不出来的逻辑错误——比如把“维修中”状态的工单误算进配件消耗统计里。
7. 部署方案与日常维护建议
7.1 在家用NAS或旧电脑上部署的轻量方案
这套系统的部署不用上云服务器,门店本地有台旧电脑或者家用NAS就够跑。最省钱的方案是:后端用Waitress或Gunicorn跑Flask,前端用Nginx托管打包后的静态文件,MySQL继续装在同台机器上,全店通过局域网访问。
前端打包命令:
npm run build打包完的dist目录里有静态文件,把dist目录放到Nginx的html目录下。后端启动命令如果用Waitress:
pip install waitress waitress-serve --host 0.0.0.0 --port 5000 app:appNginx配置文件里最关键的一步是反向代理/api路径到后端5000端口:
server { listen 80; server_name localhost; root /path/to/repair-web/dist; index index.html; # 前端路由使用的history模式需要这个配置 location / { try_files $uri $uri/ /index.html; } # API反向代理 location /api/ { proxy_pass http://127.0.0.1:5000; proxy_set_header Host $host; proxy_set_header X-Real-IP $remote_addr; } }try_files这一行的作用很关键但新手最容易漏。Vue Router用的history模式,刷新某个子路由页面时Nginx会去找真实文件,找不到就404,必须把所有路径都回退到index.html。如果不做这步,部署后“登录后刷新页面就404”的情况会让人抓狂。
7.2 数据备份最简单的方案:mysqldump定时任务
门店系统的数据量不算大,但客户资料和维修记录是长期积累的资产,丢不得。备份方案不用复杂,mysqldump加cron定时任务就够了。
Windows上写个bat脚本:
@echo off set timestamp=%date:~0,4%%date:~5,2%%date:~8,2% mysqldump -uroot -p你的密码 repair_shop > D:\backup\repair_shop_%timestamp%.sql echo backup done然后用Windows任务计划程序每天凌晨2点执行,保留最近30天的备份文件即可。Linux上则直接用crontab:
0 2 * * * mysqldump -uroot -p你的密码 repair_shop > /backup/repair_shop_$(date +%Y%m%d).sql关于备份多说一句:光有数据库备份不够,如果有上传的图片文件(比如客户设备的故障照片),也要定期同步。我这个系统第一版没有做图片上传功能,因为维修师傅基本不拍照记录,但如果你的业务需要拍故障件照片留证,记住把图片目录也纳入备份范围。
8. 经验总结与实际使用心得
做这类门店管理系统的核心经验是:少做花哨功能,把高频操作的体验打磨到位。我第一版做完给店里试用时,老板反馈:“系统是好系统,但我们最常用就是录单和查单,你把这两个流程弄顺了比什么都强。”这句话对我触动很大,后来砍掉了排班管理、消息通知这些低频功能,集中精力优化录单效率。
另一个心得是:跟用户(维修店老板和店员)确认需求时,应该问“你每天最烦做什么”,而不是“你想要什么功能”。他们的回答往往是“最烦月底算师傅提成”或者“最烦客户打电话来查维修进度”。解决这些具体的痛点,比提供一个功能齐全但没人用的系统有价值得多。
最后说一下这套系统的扩展空间。如果门店开了分店,可以在users表加一个store_id字段,所有业务表都按store_id隔离,并把简单的权限系统改成多层级。如果未来要做客户回访或者会员营销,在customers表里加生日字段,配合定期任务推送回访记录即可。技术底子在这里,业务往哪个方向延展都能接得上。