简介一套基于uniapp小程序、Vue后台管理系统与Node.js服务端构成的全栈完整项目面向需要从零搭建移动端到管理端全流程的小程序开发工程师、前端工程师及全栈学习者。小程序端实现轮播图与招聘车队展示、赛事规则与精彩十佳球查看用户登录采用JWTtoken认证并支持搜索关注、修改资料、查看战绩、接收通知、意见反馈、绑定手机号、注销等功能PC端基于vuewebpackelement-ui包含axios二次封装、动态路由导航、vuex权限管理覆盖用户权限与数据异步处理。压缩包共488个文件以Vue页面、JS脚本、JSON配置、WXML页面结构与WXSS样式、SCSS样式和PNG/JPG图片为主另含MySQL数据库SQL脚本与MP4演示视频压缩后约91.31MB目录结构清晰便于按模块学习。目前已有28650人学习/下载适合希望系统掌握全栈项目开发流程、快速迁移业务代码的开发者参考。1. uniapp 小程序 Vue 后台 Node.js MySQL这类全栈完整项目到底该怎么拆把这四个技术词放在一起描述的是一套最小可交付的业务闭环C 端用户在小程序里完成浏览、下单、支付运营和客服人员通过 Vue 后台管理系统处理订单、管理商品两个前端共用同一个 Node.js 接口层最终所有业务状态落到 MySQL。实际的立项场景里难点从来不是某个框架会不会写而是四端之间靠什么契约协作接口返回结构怎么定、登录态怎么传、权限怎么控制、数据不一致了去哪里查。这套组合最吸引人的地方是把整个团队的技术栈收敛到 JavaScript 一门语言从建表 SQL 到小程序页面一个人就能从零走通。适合刚拿到需求不知道从哪里下手的初中级开发者也适合想用最小成本验证业务模型的小团队。2. uniapp 小程序端搭建用 HBuilderX 创建跨端项目并封装统一请求层2.1 用 HBuilderX 或 CLI 创建 uniapp 项目跑通微信小程序编译链路创建 uniapp 项目有两条常见路线。追求零配置就用 HBuilderX新建项目时选择 uni-app模板选 Vue3/Vite编译器已经内置在 IDE 里打开就能预览。习惯命令行操作的团队我一般用官方 preset 模板初始化npx degit dcloudio/uni-preset-vue#vite my-uniapp-app cd my-uniapp-app npm install npm run dev:mp-weixindev:mp-weixin会把源码实时编译到dist/dev/mp-weixin目录用微信开发者工具导入这个目录一个能交互的小程序骨架就出来了。注意npx degit是从 GitHub 仓库拉取模板第一次执行会稍慢。要发测试版或正式包时改用npm run build:mp-weixin产物在dist/build/mp-weixin微信开发者工具里点「上传」就能走微信的版本管理流程。HBuilderX 的图形化导出功能做的其实是同一件事区别在于 CLI 更适合接到 Jenkins 或 GitHub Actions 流水线里。趁项目还小把目录结构先定下来。一个能支撑多端开发、又不会让后端同事迷路的划分方式是这样的src/ ├── api/ # 按业务模块拆分接口定义 ├── components/ # 全局公共组件 ├── pages/ # 页面按 tabBar 和分包组织 ├── static/ # 图片等静态资源 ├── store/ # Pinia 状态管理 └── utils/ # 请求封装、格式化工具目录规范的价值要等后端接口路径变动时才会体现路径一改只有api/目录里的文件会动页面业务代码完全不用碰。同时定一条硬约定页面内禁止直接写uni.request所有网络请求必须走utils/request.js。这条规则越早立越好否则到了联调阶段全工程散落着几十处带回调的请求代码想加一个统一的错误上报都无从下手。2.2 封装 uniapp 的 request 请求库token 注入、401 统一拦截和超时兜底uniapp 自带的uni.request是回调式的直接往页面里塞会导致每个页面的 onLoad 都被 success/fail 回调撑满。一般做法是包一层 Promise顺便把登录态和错误提示统一收口// utils/request.js export function request(options {}) { return new Promise((resolve, reject) { const token uni.getStorageSync(token) uni.request({ url: import.meta.env.VITE_API_BASE options.url, method: options.method || GET, data: options.data || {}, timeout: options.timeout || 10000, header: { Content-Type: application/json, ...(token ? { Authorization: Bearer ${token} } : {}) }, success: (res) { // 后端统一返回 { code, data, message } 结构 if (res.data.code 0) { resolve(res.data.data) } else if (res.data.code 401) { uni.removeStorageSync(token) uni.navigateTo({ url: /pages/login/login }) reject(res.data) } else { uni.showToast({ title: res.data.message || 请求失败, icon: none }) reject(res.data) } }, fail: (err) { uni.showToast({ title: 网络异常请稍后重试, icon: none }) reject(err) } }) }) }这段封装做了三件关键事从本地缓存取 token 并注入请求头以code 0作为业务成功约定遇到 401 时清掉本地 token 并跳转登录页。timeout默认设 10 秒小程序弱网环境比浏览器更需要显式的超时控制否则请求卡死用户只能杀进程。import.meta.env.VITE_API_BASE是 uniapp 内置的 Vite 环境变量变量名必须以VITE_开头才暴露给前端代码。提示VITE_前缀的变量会编译进前端产物里面只放接口地址这类非敏感配置数据库密码、JWT 密钥这类东西绝不能写在这里。关于成功和失败的通道这里用 Promise 的resolve和reject分开。页面调用时拿到的是已经剥离过的res.data.data不会再被多层嵌套结构烦扰。参数类型必填说明urlstring是拼在VITE_API_BASE后面的接口路径methodstring否默认 GET传 POST/PUT/DELETE 时 data 会进 bodydataobject否请求参数timeoutnumber否超时毫秒数默认 100002.3 用条件编译处理微信小程序和 H5 的定位差异分享入口也走同一套逻辑uniapp 的跨端能力不是免费的最典型的就是定位。公众号 H5 页面里定位要靠浏览器的 Geolocation API微信小程序里则要用uni.getLocation两者返回值结构不同硬写兼容代码会让工具函数越来越臃肿。uniapp 编译期指令在这种场景下很好用// utils/location.js export function getLocation() { // #ifdef MP-WEIXIN return new Promise((resolve, reject) { uni.getLocation({ type: gcj02, success: resolve, fail: reject }) }) // #endif // #ifdef H5 return new Promise((resolve, reject) { navigator.geolocation.getCurrentPosition( (pos) resolve({ latitude: pos.coords.latitude, longitude: pos.coords.longitude }), reject ) }) // #endif }MP-WEIXIN是微信小程序的条件编译标识H5是网页端标识。编译时 uniapp 只会保留当前平台命中的代码块另一个平台的分支会被直接剔除不会进入最终包体积。这个机制同样适用于自定义分享、微信支付、蓝牙扫描这类平台差异很大的能力各写各的调用逻辑互不干扰。小程序端到这里基本骨架齐了目录分层清晰、请求统一收口、跨端差异被隔离在工具函数里。接下来需要后端按code / message / data的契约把这些接口实现出来。3. Node.js 后端从零搭建Express 路由分层、JWT 鉴权与 MySQL 连接池3.1 初始化 Node.js 项目并装齐四个关键依赖Node.js 后端框架里 Express 仍然是最稳的选择生态成熟、中间件机制简单中小全栈项目的复杂度用 Express 刚刚好不必一开始就上 NestJS。初始化命令和依赖如下mkdir node-backend cd node-backend npm init -y npm install express mysql2 jsonwebtoken bcryptjs cors npm install -D nodemon逐个说明为什么是这些包express提供路由和中间件骨架mysql2是 MySQL 官方推荐的驱动原生支持 Promise比老牌mysql包更适合现代写法jsonwebtoken负责签发和校验 JWTbcryptjs是纯 JavaScript 实现的密码哈希库不需要 node-gyp 编译原生模块Windows 环境安装零负担cors解决 Vue 后台开发期的跨域问题nodemon监听文件变更自动重启服务开发效率高很多。生产环境部署时npm install --production会自动跳过devDependencies。后端目录按职责拆开每一层都有明确的边界node-backend/ ├── routes/ # 路由定义只做路径分发 ├── controllers/ # 业务逻辑 ├── middleware/ # 鉴权、日志等中间件 ├── db/ # 数据库连接池 ├── utils/ # 工具函数 └── app.js # 入口文件路由文件一旦超过三百行就按业务模块继续拆成user.js、order.js、product.js。项目越大这条规则的收益越明显。3.2 用 mysql2 连接池访问 MySQL环境变量、连接池参数与查询写法数据库连接不能被每次请求重复创建连接池复用连接是基本操作。先配置环境变量DB_HOST127.0.0.1 DB_PORT3306 DB_USERroot DB_PASSWORDyourpassword DB_NAMEfullstack_app JWT_SECRETreplace-with-a-long-random-string JWT_EXPIRES_IN7d然后初始化连接池// db/index.js const mysql require(mysql2/promise) const pool mysql.createPool({ host: process.env.DB_HOST, port: Number(process.env.DB_PORT), user: process.env.DB_USER, password: process.env.DB_PASSWORD, database: process.env.DB_NAME, waitForConnections: true, connectionLimit: 10, queueLimit: 0 }) module.exports pool关键参数里connectionLimit是池内最大连接数默认 10 够用高并发可以调到 20但不要太大MySQL 默认最大连接数是 151池设过高反而把数据库线程占满。waitForConnections设成 true 表示池满时请求排队等待而不是直接抛错queueLimit为 0 表示队列不再限制长度。用.env加载环境变量时记得装dotenv并在app.js第一行require(dotenv).config()。参数默认值含义使用建议connectionLimit10连接池最大连接数中小项目 10高并发调到 20-30queueLimit0排队的最大请求数0 表示不限制waitForConnectionstrue池满时是否等待必须为 true否则直接报错connectTimeout10000建立连接的超时网络抖动时建议调大写查询时用?占位符传参是防止 SQL 注入的红线// controllers/user.js const pool require(../db) async function getUserByPhone(phone) { const [rows] await pool.query( SELECT id, nickname, avatar, password FROM users WHERE phone ?, [phone] ) return rows[0] }pool.query返回的是二维数组第一项是行数据第二项是字段信息。永远不要用字符串拼接的方式组装 SQL哪怕是内部管理系统的临时脚本这条规则没有例外。3.3 用 JWT 实现登录鉴权和接口保护登录链路是前端传手机号和密码后端查用户、比对密码哈希、签发 token// controllers/auth.js const jwt require(jsonwebtoken) const bcrypt require(bcryptjs) async function login(req, res) { const { phone, password } req.body const user await getUserByPhone(phone) if (!user) { return res.status(400).json({ code: 1001, message: 用户不存在 }) } const ok await bcrypt.compare(password, user.password) if (!ok) { return res.status(400).json({ code: 1002, message: 密码错误 }) } const token jwt.sign( { id: user.id, role: user.role }, process.env.JWT_SECRET, { expiresIn: process.env.JWT_EXPIRES_IN } ) res.json({ code: 0, data: { token, user: { id: user.id, nickname: user.nickname } } }) }jwt.sign的 payload 里只放用户 id 和角色字段不要放手机号、密码哈希等敏感信息JWT 是 base64 编码的任何人拿到都能解码看到内容。bcrypt.compare会从已有的哈希串里自动提取盐值完成比对不需要手动处理盐。注册用户的密码加密用bcrypt.hash(password, 10)盐轮数为 10 是性能和安全的平衡点。有了签发逻辑还需要一个保护接口的中间件// middleware/auth.js const jwt require(jsonwebtoken) function authRequired(req, res, next) { const header req.headers.authorization || const token header.replace(Bearer , ) if (!token) { return res.status(401).json({ code: 401, message: 未登录 }) } try { const payload jwt.verify(token, process.env.JWT_SECRET) req.user payload next() } catch (e) { return res.status(401).json({ code: 401, message: token 已过期 }) } }authRequired的用法是挂载到需要登录的路由上router.get(/user/info, authRequired, getUserInfo)。用户在 Vue 后台和 uniapp 小程序端的请求头里带上Authorization: Bearer token这个中间件校验通过后会把 payload 挂到req.user后面的 controller 直接取用。token 过期时jwt.verify抛异常统一返回 401小程序端的 request 封装收到 401 会自动跳转登录页。3.4 统一错误处理中间件把异常全部收口最后在app.js里挂一个四参数错误处理中间件app.use((err, req, res, next) { console.error(err) res.status(500).json({ code: 500, message: 服务器内部错误 }) })四个参数是 Express 识别错误处理器的标志一个不能少。controller 里用next(err)把异常抛到这里后面所有的接口都不需要各自写 try catch 兜底。到这里后端的业务契约和鉴权链路完整了下一步要把 Vue 后台管理系统接进来。4. Vue 后台管理系统开发路由守卫、动态菜单与 Node.js 接口对接4.1 初始化 Vue3 Vite 后台项目并接入 Element Plus后台管理系统面向运营和管理员组件库优先选 Element Plus它是 Vue3 生态里资料最全、踩坑记录最多的选择。初始化命令npm create vuelatest admin cd admin npm install element-plus element-plus/icons-vue axios脚手架会交互式询问是否启用 TypeScript、Router、Pinia这一步建议全部选是。后面要用vue-router的守卫做登录拦截用 Pinia 存用户信息和菜单权限TypeScript 则让接口返回的结构在编码期就暴露问题。axios选它是因为后台管理系统跑在浏览器里生态比uni.request更顺手。在main.js里全量注册 Element Plusimport { createApp } from vue import ElementPlus from element-plus import element-plus/dist/index.css import zhCn from element-plus/es/locale/lang/zh-cn import App from ./App.vue const app createApp(App) app.use(ElementPlus, { locale: zhCn }) app.mount(#app)locale: zhCn的作用是把分页器、日期选择器这类组件自带文案切成中文不配的话默认是英文。全量引入对后台项目足够首屏体积敏感时再换unplugin-vue-components按需加载但需要多配一个 Vite 插件这里不展开。4.2 实现路由守卫和动态菜单权限控制后台系统的权限模型通常分成两个层面路由访问控制和按钮可见性控制。先说路由层面用 vue-router 的全局前置守卫// router/index.js router.beforeEach((to) { const token localStorage.getItem(admin_token) if (!token to.path ! /login) { return /login } if (token to.path /login) { return /dashboard } })vue-router 4 的守卫可以直接用返回值控制跳转。返回路径字符串就是重定向什么都不返回或返回 true 表示放行。这里用localStorage存 token 是后台管理系统的通行做法刷新不丢只有用户主动退出或清浏览器数据才消失。对比一下uniapp 小程序端用的是同性质的uni.getStorageSync只是封装不同。动态菜单在登录后处理后端/user/info接口返回当前用户的menus数组前端把菜单渲染和路由实例解耦。管理员和普通用户的菜单权限通常用角色区分配合 Node.js 的 JWT payload 里的role字段做判断。角色可访问菜单说明admindashboard、用户管理、订单管理、商品管理全部菜单operatordashboard、订单管理只能处理订单user无后台菜单只使用小程序端动态路由的注册用router.addRoute把有权限的页面组件映射到路由表里。这一步一定要小心顺序先让用户登录拿到菜单再动态注册路由最后跳转目标页面否则刷新后路由表是空的页面直接白屏。常规解法是在 Pinia 的 action 里保存menusLoaded状态守卫发现路由已注册就放行。4.3 用 axios 封装后台请求并通过 Vite 代理对接 Node.jsaxios 封装思路和 uniapp 的 request 保持一致// api/request.js import axios from axios import { ElMessage } from element-plus const http axios.create({ baseURL: import.meta.env.VITE_ADMIN_API_BASE, timeout: 10000 }) http.interceptors.request.use((config) { const token localStorage.getItem(admin_token) if (token) { config.headers.Authorization Bearer ${token} } return config }) http.interceptors.response.use( (res) { if (res.data.code 0) return res.data.data ElMessage.error(res.data.message) return Promise.reject(res.data) }, (err) { if (err.response?.status 401) { localStorage.removeItem(admin_token) location.href /login } ElMessage.error(网络错误) return Promise.reject(err) } )baseURL在开发阶段可以直接配成 Node.js 服务地址但更干净的做法是用 Vite 的代理把/api转发到后端这样前端的请求是同源的从根上避开跨域// vite.config.js export default defineConfig({ server: { proxy: { /api: { target: http://127.0.0.1:3000, changeOrigin: true } } } })target指向 Node.js 服务的地址和端口changeOrigin让后端收到请求时 Host 头变成127.0.0.1:3000避免个别环境下的 Host 校验拦截。这样配置的好处是联调阶段后端没部署时前端已经可以并行开发两边的产出最后通过接口文档对齐即可。5. MySQL 表结构设计与三端联调建表、数据验证和部署前清单5.1 三张核心表的建表要点以 users 表为例一个最小可用的全栈项目至少需要用户表、商品表、订单表。用户表是登录入口字段设计的容忍度最低CREATE TABLE users ( id INT UNSIGNED AUTO_INCREMENT PRIMARY KEY, phone VARCHAR(20) NOT NULL UNIQUE COMMENT 手机号登录账号, password VARCHAR(100) NOT NULL COMMENT bcrypt 哈希值, nickname VARCHAR(50) NOT NULL DEFAULT COMMENT 昵称, avatar VARCHAR(255) DEFAULT NULL COMMENT 头像 URL, role TINYINT NOT NULL DEFAULT 1 COMMENT 1 普通用户 2 管理员, status TINYINT NOT NULL DEFAULT 1 COMMENT 1 正常 0 禁用, created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP ) ENGINEInnoDB DEFAULT CHARSETutf8mb4 COLLATEutf8mb4_unicode_ciphone加唯一索引保证登录账号不重复password字段长度设 100 是因为 bcrypt 哈希输出 60 字符留出余量created_at和updated_at交给数据库维护后端不手动塞时间。存储引擎用 InnoDB因为订单和商品库存之间要事务支持。字符串统一用utf8mb4否则用户的 emoji 昵称写入会报错。订单表的金额字段用DECIMAL(10,2)而不是 FLOAT固定位数的浮点运算在商品金额上是不可接受的。5.2 用 mysql workbench 做三端数据一致性验证三端联调时最隐蔽的问题是接口返回的数据和表里实际数据对不上。联调完一个接口我会打开 mysql workbench 跑一遍对应的聚合查询SELECT u.phone, u.nickname, COUNT(o.id) AS order_count, SUM(o.amount) AS total_amount FROM users u LEFT JOIN orders o ON o.user_id u.id GROUP BY u.id;如果这条 SQL 的结果和后台管理系统页面显示的数字不一致那 90% 是后端查询语句的 join 条件写错了。用可视化工具还有一层好处能一眼看出字段类型选得是否合理。全栈项目里业务逻辑集中在 Node.js 层尽量不写存储过程和触发器否则排查问题时要在两套语言之间来回跳。5.3 部署前逐项核对三端配置上线前检查清单要具体到每端的配置项小程序端的VITE_API_BASE必须指向正式服务器域名且为 https微信小程序正式版不认 httpVue 后台的VITE_ADMIN_API_BASE同样切换到 https 地址Node.js 端的JWT_SECRET换成生产随机串DB_PASSWORD改成高强度密码.env文件确认加入了.gitignore。最后在微信开发者工具上传代码后用真机把登录、列表、下单这条主链路完整走一遍再回到 Vue 后台确认这条订单的状态和金额一致三端数据全部对上这个全栈项目才算真正交付。本文还有配套的精品资源点击获取