Vue3+uni-app全栈开发:一番赏手办商城小程序实战指南

发布时间:2026/7/19 2:28:49
Vue3+uni-app全栈开发:一番赏手办商城小程序实战指南 最近在开发一番赏手办商城小程序时发现很多开发者对Vueuni-app的全栈开发流程不够熟悉特别是前后端分离架构下的接口对接和后台管理系统设计。本文将完整分享一套基于Vue3uni-app的一番赏商城开发方案包含小程序前端和后台管理系统附带完整的接口文档说明。1. 项目概述与技术选型1.1 一番赏商城业务特点一番赏是源自日本的抽奖式手办销售模式具有以下业务特点多奖池设计每个奖池包含A赏、B赏、C赏等不同等级奖品概率抽奖用户支付固定金额参与抽奖库存管理每个奖品的库存需要精确控制订单系统支持抽奖记录、发货状态跟踪1.2 技术栈选择理由前端技术栈Vue3 uni-app ViteVue3 Composition API更适合复杂状态管理uni-app支持多端发布微信小程序、H5、AppVite提供更快的开发体验和构建速度后端技术栈Node.js Express MySQL轻量级且性能优秀与前端技术栈同属JavaScript生态易于部署和维护后台管理系统Vue3 Element Plus组件丰富开发效率高与小程序前端技术栈统一2. 开发环境搭建2.1 环境要求与工具准备确保本地环境满足以下要求Node.js 16.0 或更高版本HBuilder X 或 VS CodeMySQL 5.7 或更高版本微信开发者工具2.2 项目初始化创建uni-app项目# 使用Vite创建uni-app项目 npm create uni-applatest ichiban-mall cd ichiban-mall # 安装依赖 npm install # 安装必要的UI库 npm install dcloudio/uni-ui npm install sass -D创建后端项目结构# 创建后端目录 mkdir ichiban-server cd ichiban-server # 初始化package.json npm init -y # 安装后端依赖 npm install express mysql2 cors bcryptjs jsonwebtoken npm install nodemon -D2.3 数据库设计创建核心数据表结构-- 奖池表 CREATE TABLE prize_pools ( id INT PRIMARY KEY AUTO_INCREMENT, name VARCHAR(100) NOT NULL, description TEXT, price DECIMAL(10,2) NOT NULL, total_tickets INT DEFAULT 0, remaining_tickets INT DEFAULT 0, status ENUM(active, inactive) DEFAULT active, created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP ); -- 奖品表 CREATE TABLE prizes ( id INT PRIMARY KEY AUTO_INCREMENT, pool_id INT NOT NULL, level ENUM(A, B, C, D, E, F, G) NOT NULL, name VARCHAR(100) NOT NULL, image_url VARCHAR(255), total_stock INT NOT NULL, remaining_stock INT NOT NULL, probability DECIMAL(5,4) NOT NULL, FOREIGN KEY (pool_id) REFERENCES prize_pools(id) ); -- 用户表 CREATE TABLE users ( id INT PRIMARY KEY AUTO_INCREMENT, openid VARCHAR(100) UNIQUE, nickname VARCHAR(100), avatar_url VARCHAR(255), balance DECIMAL(10,2) DEFAULT 0.00, created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP ); -- 订单表 CREATE TABLE orders ( id INT PRIMARY KEY AUTO_INCREMENT, order_no VARCHAR(50) UNIQUE NOT NULL, user_id INT NOT NULL, pool_id INT NOT NULL, amount DECIMAL(10,2) NOT NULL, status ENUM(pending, paid, shipped, completed) DEFAULT pending, prize_id INT, created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, FOREIGN KEY (user_id) REFERENCES users(id), FOREIGN KEY (pool_id) REFERENCES prize_pools(id), FOREIGN KEY (prize_id) REFERENCES prizes(id) );3. 前端小程序开发3.1 项目目录结构规划src/ ├── pages/ # 页面文件 │ ├── index/ # 首页 │ ├── pool-detail/ # 奖池详情 │ ├── lottery/ # 抽奖页面 │ ├── order/ # 订单页面 │ └── profile/ # 个人中心 ├── components/ # 公共组件 │ ├── prize-card/ # 奖品卡片 │ ├── lottery-wheel/ # 抽奖转盘 │ └── tab-bar/ # 自定义tabbar ├── stores/ # 状态管理 │ ├── user.js # 用户状态 │ └── lottery.js # 抽奖状态 ├── api/ # 接口封装 │ ├── request.js # 请求封装 │ ├── user.js # 用户接口 │ ├── pool.js # 奖池接口 │ └── order.js # 订单接口 ├── utils/ # 工具函数 │ ├── auth.js # 登录验证 │ ├── common.js # 通用函数 │ └── wxapi.js # 微信API封装 └── static/ # 静态资源3.2 核心页面开发首页实现index.vuetemplate view classcontainer !-- 轮播图 -- swiper classbanner-swiper indicator-dots autoplay circular swiper-item v-forbanner in banners :keybanner.id image :srcbanner.image modeaspectFill classbanner-image / /swiper-item /swiper !-- 奖池列表 -- view classpool-section view classsection-title热门一番赏/view scroll-view classpool-list scroll-x view v-forpool in prizePools :keypool.id classpool-item prize-card :poolpool clickgoToPoolDetail(pool.id) / /view /scroll-view /view !-- 抽奖记录 -- view classrecord-section view classsection-title最新中赏/view view v-forrecord in recentRecords :keyrecord.id classrecord-item image :srcrecord.user_avatar classavatar / text classusername{{ record.user_nickname }}/text text classprize-info抽中了{{ record.prize_name }}/text /view /view /view /template script setup import { ref, onMounted } from vue import { onLoad } from dcloudio/uni-app import { getPrizePools, getRecentRecords } from /api/pool import { getBanners } from /api/common const banners ref([]) const prizePools ref([]) const recentRecords ref([]) onLoad(async () { await loadHomeData() }) const loadHomeData async () { try { const [bannerRes, poolRes, recordRes] await Promise.all([ getBanners(), getPrizePools({ page: 1, limit: 10 }), getRecentRecords({ limit: 5 }) ]) banners.value bannerRes.data prizePools.value poolRes.data.list recentRecords.value recordRes.data } catch (error) { uni.showToast({ title: 数据加载失败, icon: none }) } } const goToPoolDetail (poolId) { uni.navigateTo({ url: /pages/pool-detail/pool-detail?id${poolId} }) } /script style scoped .container { padding: 20rpx; } .banner-swiper { height: 300rpx; border-radius: 16rpx; overflow: hidden; } .banner-image { width: 100%; height: 100%; } .section-title { font-size: 32rpx; font-weight: bold; margin: 30rpx 0 20rpx; } .pool-list { white-space: nowrap; } .pool-item { display: inline-block; width: 280rpx; margin-right: 20rpx; } .record-item { display: flex; align-items: center; padding: 20rpx; background: #f5f5f5; border-radius: 12rpx; margin-bottom: 16rpx; } .avatar { width: 60rpx; height: 60rpx; border-radius: 50%; margin-right: 20rpx; } .username { font-size: 28rpx; color: #666; margin-right: 10rpx; } .prize-info { font-size: 28rpx; color: #e74c3c; font-weight: bold; } /style抽奖转盘组件lottery-wheel.vuetemplate view classlottery-wheel view classwheel-container :stylewheelStyle view v-for(prize, index) in prizes :keyprize.id classwheel-item :stylegetItemStyle(index) image v-ifprize.image :srcprize.image classprize-image / text classprize-name{{ prize.name }}/text /view /view view classlottery-btn clickstartLottery :class{ disabled: isLotting } {{ isLotting ? 抽奖中... : 开始抽奖 }} /view /view /template script setup import { ref, computed, defineProps, defineEmits } from vue const props defineProps({ prizes: { type: Array, default: () [] }, duration: { type: Number, default: 4000 } }) const emit defineEmits([lottery-start, lottery-end]) const isLotting ref(false) const currentAngle ref(0) const wheelStyle computed(() ({ transform: rotate(${currentAngle.value}deg), transition: isLotting.value ? transform ${props.duration}ms cubic-bezier(0.2, 0.8, 0.3, 1) : none })) const getItemStyle (index) { const angle 360 / props.prizes.length return { transform: rotate(${index * angle}deg), transformOrigin: center center } } const startLottery async () { if (isLotting.value) return isLotting.value true emit(lottery-start) // 模拟抽奖结果实际应调用接口 const resultIndex Math.floor(Math.random() * props.prizes.length) const targetAngle 360 - (resultIndex * (360 / props.prizes.length)) 360 * 5 currentAngle.value targetAngle setTimeout(() { isLotting.value false emit(lottery-end, props.prizes[resultIndex]) }, props.duration) } /script style scoped .lottery-wheel { display: flex; flex-direction: column; align-items: center; } .wheel-container { width: 600rpx; height: 600rpx; border-radius: 50%; background: linear-gradient(45deg, #ff6b6b, #ffa726, #66bb6a, #42a5f5); position: relative; overflow: hidden; } .wheel-item { position: absolute; width: 50%; height: 50%; left: 50%; top: 0; transform-origin: left bottom; display: flex; flex-direction: column; align-items: center; justify-content: center; } .prize-image { width: 60rpx; height: 60rpx; margin-bottom: 10rpx; } .prize-name { font-size: 24rpx; color: white; text-align: center; } .lottery-btn { margin-top: 40rpx; padding: 20rpx 60rpx; background: linear-gradient(45deg, #ff6b6b, #ffa726); color: white; border-radius: 50rpx; font-size: 32rpx; font-weight: bold; } .lottery-btn.disabled { background: #ccc; pointer-events: none; } /style3.3 接口请求封装请求封装request.jsimport { getToken, refreshToken } from /utils/auth const BASE_URL https://your-api-domain.com/api // 请求队列用于处理token刷新时的请求重发 let requestQueue [] let isRefreshing false const request (options) { return new Promise((resolve, reject) { const token getToken() const requestTask uni.request({ url: BASE_URL options.url, method: options.method || GET, data: options.data, header: { Content-Type: application/json, Authorization: token ? Bearer ${token} : , ...options.header }, success: (res) { if (res.statusCode 200) { if (res.data.code 0) { resolve(res.data) } else if (res.data.code 401) { // token过期加入队列等待刷新 if (!isRefreshing) { isRefreshing true handleTokenRefresh().finally(() { isRefreshing false // 重发队列中的请求 requestQueue.forEach(cb cb()) requestQueue [] }) } // 将当前请求加入队列 requestQueue.push(() { request(options).then(resolve).catch(reject) }) } else { uni.showToast({ title: res.data.message || 请求失败, icon: none }) reject(res.data) } } else { uni.showToast({ title: 网络错误: ${res.statusCode}, icon: none }) reject(res) } }, fail: (error) { uni.showToast({ title: 网络连接失败, icon: none }) reject(error) } }) // 返回requestTask用于取消请求 return requestTask }) } const handleTokenRefresh async () { try { const token getToken() if (!token) { // 跳转到登录页 uni.navigateTo({ url: /pages/login/login }) return } const result await refreshToken() if (result.code 0) { // 刷新成功更新token uni.setStorageSync(token, result.data.token) } else { // 刷新失败跳转登录 uni.navigateTo({ url: /pages/login/login }) } } catch (error) { uni.navigateTo({ url: /pages/login/login }) } } // 封装常用方法 export const get (url, data {}) request({ url, method: GET, data }) export const post (url, data {}) request({ url, method: POST, data }) export const put (url, data {}) request({ url, method: PUT, data }) export const del (url, data {}) request({ url, method: DELETE, data }) export default request奖池接口封装pool.jsimport { get, post } from /api/request export const getPrizePools (params) { return get(/prize-pools, params) } export const getPoolDetail (id) { return get(/prize-pools/${id}) } export const getPoolPrizes (poolId) { return get(/prize-pools/${poolId}/prizes) } export const startLottery (poolId) { return post(/lottery/start, { pool_id: poolId }) } export const confirmLottery (lotteryId) { return post(/lottery/confirm, { lottery_id: lotteryId }) } export const getLotteryHistory (params) { return get(/lottery/history, params) }4. 后端API开发4.1 服务器架构设计server/ ├── app.js # 应用入口 ├── config/ # 配置文件 │ └── database.js # 数据库配置 ├── routes/ # 路由文件 │ ├── auth.js # 认证路由 │ ├── pools.js # 奖池路由 │ ├── lottery.js # 抽奖路由 │ └── orders.js # 订单路由 ├── controllers/ # 控制器 │ ├── authController.js │ ├── poolController.js │ ├── lotteryController.js │ └── orderController.js ├── models/ # 数据模型 │ ├── User.js │ ├── PrizePool.js │ ├── Prize.js │ └── Order.js ├── middleware/ # 中间件 │ ├── auth.js # 认证中间件 │ └── validation.js # 参数验证 └── utils/ # 工具函数 ├── jwt.js # JWT工具 └── response.js # 响应封装4.2 核心业务逻辑实现抽奖算法实现lotteryController.jsconst { PrizePool, Prize, Order, User } require(../models) const { AppError } require(../utils/response) class LotteryController { // 开始抽奖 async startLottery(req, res, next) { try { const { pool_id } req.body const user_id req.user.id // 验证奖池状态 const pool await PrizePool.findByPk(pool_id, { include: [{ model: Prize, as: prizes }] }) if (!pool || pool.status ! active) { throw new AppError(奖池不存在或已结束, 400) } if (pool.remaining_tickets 0) { throw new AppError(奖池已抽完, 400) } // 验证用户余额 const user await User.findByPk(user_id) if (user.balance pool.price) { throw new AppError(余额不足, 400) } // 执行抽奖算法 const prize await this.calculatePrize(pool) // 创建订单 const order await Order.create({ order_no: this.generateOrderNo(), user_id, pool_id, amount: pool.price, prize_id: prize.id, status: paid }) // 更新库存 await pool.decrement(remaining_tickets) await prize.decrement(remaining_stock) await user.decrement(balance, { by: pool.price }) // 记录抽奖日志 await this.recordLotteryLog(user_id, pool_id, prize.id, order.id) res.json({ code: 0, data: { order_id: order.id, prize: { id: prize.id, name: prize.name, level: prize.level, image: prize.image_url } } }) } catch (error) { next(error) } } // 计算中奖奖品 async calculatePrize(pool) { const prizes pool.prizes const totalProbability prizes.reduce((sum, prize) sum parseFloat(prize.probability), 0) // 验证概率总和 if (Math.abs(totalProbability - 1) 0.0001) { throw new AppError(奖池概率配置错误, 500) } // 生成随机数 const random Math.random() let currentProbability 0 for (const prize of prizes) { currentProbability parseFloat(prize.probability) if (random currentProbability prize.remaining_stock 0) { return prize } } // 如果所有奖品库存为0返回最后一个奖品通常为谢谢参与 return prizes[prizes.length - 1] } // 生成订单号 generateOrderNo() { const timestamp new Date().getTime() const random Math.random().toString().slice(2, 8) return ORD${timestamp}${random} } // 记录抽奖日志 async recordLotteryLog(userId, poolId, prizeId, orderId) { // 实现抽奖日志记录逻辑 // 这里可以记录到专门的抽奖日志表 } } module.exports new LotteryController()JWT认证中间件auth.jsconst jwt require(jsonwebtoken) const { User } require(../models) const { AppError } require(../utils/response) const auth async (req, res, next) { try { const token req.header(Authorization)?.replace(Bearer , ) if (!token) { throw new AppError(访问令牌缺失, 401) } const decoded jwt.verify(token, process.env.JWT_SECRET) const user await User.findByPk(decoded.userId) if (!user) { throw new AppError(用户不存在, 401) } req.user user next() } catch (error) { if (error.name JsonWebTokenError) { next(new AppError(无效的访问令牌, 401)) } else if (error.name TokenExpiredError) { next(new AppError(访问令牌已过期, 401)) } else { next(error) } } } module.exports auth5. 后台管理系统开发5.1 管理端功能规划后台管理系统需要包含以下核心模块奖池管理创建、编辑、上下架奖池奖品管理配置奖品信息、库存、概率订单管理查看订单、发货处理用户管理用户信息、余额管理数据统计销售数据、中奖统计5.2 奖池管理实现template div classpool-management el-card template #header div classcard-header span奖池管理/span el-button typeprimary clickhandleCreate新增奖池/el-button /div /template el-table :datapoolList v-loadingloading el-table-column propid labelID width80 / el-table-column propname label奖池名称 / el-table-column propprice label价格 width120 template #default{ row } {{ row.price }} /template /el-table-column el-table-column proptotal_tickets label总票数 width100 / el-table-column propremaining_tickets label剩余票数 width100 / el-table-column propstatus label状态 width100 template #default{ row } el-tag :typerow.status active ? success : info {{ row.status active ? 进行中 : 已结束 }} /el-tag /template /el-table-column el-table-column label操作 width200 template #default{ row } el-button sizesmall clickhandleEdit(row)编辑/el-button el-button sizesmall clickhandlePrizeManage(row)奖品管理/el-button el-button sizesmall :typerow.status active ? warning : success clickhandleToggleStatus(row) {{ row.status active ? 结束 : 开启 }} /el-button /template /el-table-column /el-table el-pagination v-model:current-pagepagination.current v-model:page-sizepagination.size :totalpagination.total current-changeloadPools layouttotal, prev, pager, next / /el-card !-- 奖池编辑对话框 -- el-dialog v-modeldialogVisible :titledialogTitle el-form :modelform :rulesrules refformRef label-width100px el-form-item label奖池名称 propname el-input v-modelform.name placeholder请输入奖池名称 / /el-form-item el-form-item label价格 propprice el-input-number v-modelform.price :min1 :precision2 / /el-form-item el-form-item label总票数 proptotal_tickets el-input-number v-modelform.total_tickets :min1 / /el-form-item el-form-item label描述 propdescription el-input v-modelform.description typetextarea :rows3 / /el-form-item /el-form template #footer el-button clickdialogVisible false取消/el-button el-button typeprimary clickhandleSubmit确定/el-button /template /el-dialog /div /template script setup import { ref, reactive, onMounted, computed } from vue import { ElMessage, ElMessageBox } from element-plus import { getPrizePools, createPrizePool, updatePrizePool, togglePoolStatus } from /api/admin/pool const loading ref(false) const dialogVisible ref(false) const formRef ref() const isEditing ref(false) const poolList ref([]) const pagination reactive({ current: 1, size: 10, total: 0 }) const form reactive({ id: null, name: , price: 0, total_tickets: 0, description: }) const rules { name: [{ required: true, message: 请输入奖池名称, trigger: blur }], price: [{ required: true, message: 请输入价格, trigger: blur }], total_tickets: [{ required: true, message: 请输入总票数, trigger: blur }] } const dialogTitle computed(() isEditing.value ? 编辑奖池 : 新增奖池) onMounted(() { loadPools() }) const loadPools async () { loading.value true try { const res await getPrizePools({ page: pagination.current, limit: pagination.size }) poolList.value res.data.list pagination.total res.data.total } catch (error) { ElMessage.error(加载失败) } finally { loading.value false } } const handleCreate () { isEditing.value false Object.keys(form).forEach(key { if (key ! id) form[key] }) dialogVisible.value true } const handleEdit (row) { isEditing.value true Object.keys(form).forEach(key { form[key] row[key] }) dialogVisible.value true } const handleSubmit async () { if (!formRef.value) return try { await formRef.value.validate() if (isEditing.value) { await updatePrizePool(form.id, form) ElMessage.success(更新成功) } else { await createPrizePool(form) ElMessage.success(创建成功) } dialogVisible.value false loadPools() } catch (error) { ElMessage.error(操作失败) } } const handleToggleStatus async (row) { try { await ElMessageBox.confirm( 确定要${row.status active ? 结束 : 开启}这个奖池吗, 提示, { type: warning } ) await togglePoolStatus(row.id, row.status active ? inactive : active) ElMessage.success(操作成功) loadPools() } catch (error) { // 用户取消操作 } } const handlePrizeManage (row) { // 跳转到奖品管理页面 window.open(/admin/prize-management?pool_id${row.id}, _blank) } /script style scoped .card-header { display: flex; justify-content: space-between; align-items: center; } /style6. 接口文档设计6.1 API文档结构采用OpenAPI 3.0规范编写接口文档openapi: 3.0.0 info: title: 一番赏商城API version: 1.0.0 description: 一番赏手办商城小程序后端API接口文档 servers: - url: https://api.ichiban-mall.com description: 生产环境 paths: /api/prize-pools: get: tags: [奖池] summary: 获取奖池列表 parameters: - name: page in: query schema: type: integer default: 1 - name: limit in: query schema: type: integer default: 10 responses: 200: description: 成功 content: application/json: schema: type: object properties: code: type: integer example: 0 data: type: object properties: list: type: array items: $ref: #/components/schemas/PrizePool total: type: integer example: 100 /api/lottery/start: post: tags: [抽奖] summary: 开始抽奖 security: - bearerAuth: [] requestBody: required: true content: application/json: schema: type: object properties: pool_id: type: integer example: 1 responses: 200: description: 抽奖成功 content: application/json: schema: type: object properties: code: type: integer example: 0 data: type: object properties: order_id: type: integer example: 1001 prize: $ref: #/components/schemas/Prize components: schemas: PrizePool: type: object properties: id: type: integer example: 1 name: type: string example: 海贼王一番赏 price: type: number example: 50.00 total_tickets: type: integer example: 1000 remaining_tickets: type: integer example: 500 status: type: string enum: [active, inactive] example: active Prize: type: object properties: id: type: integer example: 1 name: type: string example: A赏-路飞手办 level: type: string enum: [A, B, C, D, E, F, G] example: A image_url: type: string example: https://example.com/prize.jpg securitySchemes: bearerAuth: type: http scheme: bearer bearerFormat: JWT6.2 接口测试示例使用Postman进行接口测试// 获取奖池列表 const getPools async () { const response await fetch(https://api.ichiban-mall.com/api/prize-pools?page1limit10, { method: GET, headers: { Content-Type: application/json } }) const result await response.json() console.log(奖池列表:, result) } // 开始抽奖 const startLottery async (poolId, token) { const response await fetch(https://api.ichiban-mall.com/api/lottery/start, { method: POST, headers: { Content-Type: application/json, Authorization: Bearer ${token} }, body: JSON.stringify({ pool_id: poolId }) }) const result await response.json() console.log(抽奖结果:, result) }7. 部署与运维7.1 生产环境部署服务器配置# 安装PM2进程管理 npm install pm2 -g # 创建PM2配置文件 ecosystem.config.js module.exports { apps: [{ name: ichiban-server, script: ./app.js, instances: max, exec_mode: cluster, env: { NODE_ENV: production, PORT: 3000 }, error_file: ./logs/err.log, out_file: ./logs/out.log, log_file: ./logs/combined.log, time: true }] } # 启动服务 pm2 start ecosystem.config.jsNginx配置server { listen 80; server_name api.ichiban-mall.com; location / { proxy_pass http://localhost:3000; proxy_http_version 1.1; proxy_set_header Upgrade $http_upgrade; proxy_set_header Connection upgrade; proxy_set_header Host $host; proxy_set_header X-Real-IP $remote_addr; proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; proxy_set_header X-Forwarded-Proto $scheme