Springboot + vue + uni-app小程序web端全套家具商场
Springboot + vue + uni-app小程序web端全套家具商场
文章目录
- Springboot + vue + uni-app小程序web端全套家具商场
- 1、项目概述
- 2、技术栈
- 3、系统功能模块
- 4、项目截图
- 4.1、web端
- 4.2、小程序端
- 5、核心代码
- 5.1、管理端
- 5.2、小程序端
- 5.3、后端
1、项目概述
这是一个基于SpringBoot + Vue3 + uni-app的全栈家具电商平台,包含Web后台管理系统和微信小程序端,专注于家具产品的在线销售与管理。平台实现了从商品管理、订单处理到用户交互的完整电商业务流程。
2、技术栈
后端技术栈
- 核心框架: Spring Boot 3
- 数据库: MySQL 8.0
- ORM框架: MyBatis-Plus
- 认证授权: Spring Security + JWT
- 缓存: Redis
- 文件存储: 阿里云OSS/七牛云
前端管理系统
- 前端框架: Vue 3 + Composition API
- UI组件库: Element Plus
- 状态管理: Pinia
- 路由: Vue Router
- HTTP客户端: Axios
- 可视化: ECharts
- 构建工具: Vite
微信小程序端
- 开发框架: uni-app (基于Vue.js)
- UI组件库: uView UI
- 状态管理: Vuex
- 网络请求: uni.request封装
- 推送通知: 微信模板消息
3、系统功能模块
后台管理系统
商品管理:家具分类管理、商品SPU/SKU管理、商品上下架、商品评价管理
订单管理:订单列表与状态跟踪、退款/退货处理、订单统计与分析
用户管理:用户维护、用户行为分析
内容管理:首页轮播图配置、家具搭配推荐
数据统计:销售数据可视化、用户增长分析、商品热度排行
小程序端
首页:个性化推荐、促销活动展示、分类快捷入口
商品模块:家具分类浏览、商品搜索与筛选、商品详情、收藏与分享
购物流程:购物车管理、地址选择、支付、订单状态追踪
用户中心:个人信息管理、订单历史、收藏夹
4、项目截图
4.1、web端
登录页
数据看板
主页推荐
订单管理
分类管理
商品管理
4.2、小程序端
登录页
首页
分类页
详情页
个人页面
5、核心代码
5.1、管理端
项目结构
核心代码
<template><div class="dashboard-container"><!-- 顶部卡片区域 --><el-row :gutter="20" class="mb-8"><el-col v-for="(item, index) in statItems" :key="index" :xs="24" :sm="12" :md="8" :lg="4" class="mb-4"><el-card shadow="hover" class="stat-card"><div class="stat-value">{{ dashboardData[item.key] }}</div><div class="stat-label">{{ item.label }}</div></el-card></el-col></el-row><!-- 图表区域 --><el-row :gutter="20" class="chart-row"><el-col :xs="12" :sm="24" :md="12" class="mb-8"><el-card shadow="hover"><template #header><span class="chart-title">近七日订单趋势</span></template><div ref="orderChart" style="height: 400px;"></div></el-card></el-col><el-col :xs="24" :sm="24" :md="12" class="mb-8"><el-card shadow="hover"><template #header><span class="chart-title">近七日用户趋势</span></template><div ref="userChart" style="height: 400px;"></div></el-card></el-col></el-row></div>
</template><script setup>
import { ref, onMounted, watch, onActivated } from 'vue'
import { useRoute } from 'vue-router'
import * as echarts from 'echarts'
import { DashBoardAPI } from '@/api/admin/dashboard'const route = useRoute()
let orderChartInstance = null
let userChartInstance = null
// 新增加载状态
const loading = ref(false)
// 响应式数据
const dashboardData = ref({orderCount: 0,orderPreCount: 0,orderNowCount: 0,orderReceiptCount: 0,orderFinishCount: 0,orderCancelCount: 0,orderCountList: [],userCountList: []
})const orderChart = ref(null)
const userChart = ref(null)// 统计项配置
const statItems = ref([{ label: '订单总数', key: 'orderCount' },{ label: '待付款', key: 'orderPreCount' },{ label: '待发货', key: 'orderNowCount' },{ label: '待收货', key: 'orderReceiptCount' },{ label: '已完成', key: 'orderFinishCount' },{ label: '已取消', key: 'orderCancelCount' }
])// 防抖函数
const debounce = (fn, delay = 300) => {let timerreturn (...args) => {clearTimeout(timer)timer = setTimeout(() => fn.apply(this, args), delay)}
}const fetchData = async () => {try {loading.value = trueconst res = await DashBoardAPI()dashboardData.value = res.data.datainitCharts()} finally {loading.value = false}
}// 生成最近7天日期
const generateDates = () => {const dates = []for (let i = 6; i >= 0; i--) {const date = new Date()date.setDate(date.getDate() - i)dates.push(`${date.getMonth() + 1}/${date.getDate()}`)}return dates
}// 初始化图表
const initCharts = () => {const dates = generateDates()// 订单图表const orderChartInstance = echarts.init(orderChart.value)orderChartInstance.setOption({xAxis: {type: 'category',data: dates,axisLabel: {color: '#666'}},yAxis: {type: 'value'},series: [{data: dashboardData.value.orderCountList,type: 'bar',itemStyle: {color: '#409EFF'},barWidth: '30%'}],tooltip: {trigger: 'axis'},grid: {left: '3%',right: '3%',bottom: '3%',containLabel: true}})// 用户图表const userChartInstance = echarts.init(userChart.value)userChartInstance.setOption({xAxis: {type: 'category',data: dates,axisLabel: {color: '#666'}},yAxis: {type: 'value'},series: [{data: dashboardData.value.userCountList,type: 'bar',itemStyle: {color: '#67C23A'},barWidth: '30%'}],tooltip: {trigger: 'axis'},grid: {left: '3%',right: '3%',bottom: '3%',containLabel: true}})
}// 监听路由变化
watch(() => route.path,(newVal, oldVal) => {if (newVal === '/dashboard') { // 根据实际路由路径调整debounce(fetchData)()}}
)// 处理keep-alive缓存
onActivated(() => {if (orderChartInstance) orderChartInstance.dispose()if (userChartInstance) userChartInstance.dispose()fetchData()
})onMounted(() => {fetchData()
})// 添加窗口resize监听
const handleResize = debounce(() => {orderChartInstance?.resize()userChartInstance?.resize()
}, 200)window.addEventListener('resize', handleResize)
</script><style scoped>
.dashboard-container {padding: 20px;max-width: 1600px;margin: 0 auto;
}.stat-card {text-align: center;transition: transform 0.3s;margin-bottom: 16px;
}.stat-card:hover {transform: translateY(-3px);
}.chart-row {display: flex;flex-wrap: wrap;
}/* 响应式调整 */
@media (max-width: 768px) {.el-col-md-12 {max-width: 100%;flex: 0 0 100%;}.stat-card {margin-bottom: 12px;}
}@media (min-width: 1200px) {.el-col-lg-8 {max-width: 33.3333%;flex: 0 0 33.3333%;}
}.dashboard-container {padding: 20px;
}.stat-card {text-align: center;transition: transform 0.3s;
}.stat-card:hover {transform: translateY(-5px);
}.stat-value {font-size: 24px;font-weight: bold;color: #409EFF;margin-bottom: 8px;
}.stat-label {color: #666;font-size: 14px;
}.chart-title {font-size: 16px;font-weight: bold;color: #333;
}.mb-8 {margin-bottom: 32px;
}
</style>
<template><div class="product-container"><!-- 页面标题 --><el-card class="header-card"><template #header><div class="card-header"><h2><el-icon><Goods /></el-icon> 商品管理</h2><el-button type="primary" :icon="Plus" @click="openDialog">添加商品</el-button></div></template><!-- 搜索区域 --><div class="search-area"><el-input v-model="searchText" placeholder="请输入关键词搜索(名称、描述)" clearable class="search-input"@clear="handleSearch" @keyup.enter="handleSearch"><template #prefix><el-icon><Search /></el-icon></template><template #append><el-button @click="handleSearch">搜索</el-button></template></el-input></div></el-card><!-- 数据表格 --><el-card class="table-card"><el-table :data="currentPageData" border stripe highlight-current-row style="width: 100%"v-loading="loading" :header-cell-style="{ background: '#f5f7fa', color: '#606266' }"><el-table-column label="序号" width="60"><template #default="{ $index }">{{ (currentPage - 1) * pageSize + $index + 1 }}</template></el-table-column><el-table-column prop="name" label="商品名称" width="150"><template #default="{ row }"><div class="product-name"><el-tag size="small" effect="plain" class="product-tag">商品</el-tag>{{ row.name }}</div></template></el-table-column><el-table-column prop="description" label="商品描述" show-overflow-tooltip width="200" /><!-- 图片列 --><el-table-column label="图片" width="180"><template #default="{ row }"><div class="image-container"><el-image v-for="(img, index) in row.images" :key="index" :src="img":preview-src-list="row.images" :initial-index="index" fit="cover" class="product-image"hide-on-click-modal><template #error><div class="image-error"><el-icon><Picture /></el-icon></div></template></el-image></div></template></el-table-column><el-table-column prop="price" label="价格" width="100"><template #default="{ row }"><span class="price-tag">¥{{ row.price.toFixed(2) }}</span></template></el-table-column><el-table-column prop="stock" label="库存" width="100"><template #default="{ row }"><el-tag :type="row.stock > 10 ? 'success' : row.stock > 0 ? 'warning' : 'danger'"effect="light">{{ row.stock }}</el-tag></template></el-table-column><el-table-column prop="categoryName" label="分类" width="120"><template #default="{ row }"><el-tag effect="plain" size="small">{{ row.categoryName }}</el-tag></template></el-table-column><el-table-column prop="createTime" label="创建时间" width="160"><template #default="{ row }"><el-tooltip :content="row.createTime" placement="top"><span>{{ formatDate(row.createTime) }}</span></el-tooltip></template></el-table-column><!-- 规格列修改 --><el-table-column label="规格" width="280"><template #default="{ row }"><div class="spec-container"><div v-for="(spec, index) in row.groupedSpecs" :key="index" class="spec-item"><el-tag size="small" effect="plain" type="info" class="spec-tag">{{ spec.name}}:</el-tag><span class="spec-values">{{ spec.values.join('、') }}</span></div></div></template></el-table-column><!-- 属性列修改 --><el-table-column label="属性" width="280"><template #default="{ row }"><div class="attr-container"><div v-for="(attr, index) in row.attributeList" :key="index" class="attr-item"><el-tag size="small" effect="plain" type="success" class="attr-tag">{{ attr.name}}</el-tag><span class="attr-value">{{ attr.value }}</span></div></div></template></el-table-column><el-table-column label="操作" width="100" fixed="right"><template #default="{ row }"><el-button-group><!-- <el-button type="primary" size="small" :icon="Edit" plain>编辑</el-button> --><el-button type="danger" size="small" :icon="Delete" plain @click="handleDelete(row.id)">删除</el-button></el-button-group></template></el-table-column></el-table><!-- 分页 --><div class="pagination-container"><el-pagination background layout="total, sizes, prev, pager, next, jumper" :total="filteredData.length"v-model:current-page="currentPage" v-model:page-size="pageSize" :page-sizes="[5, 10, 20, 50]"@size-change="handleSizeChange" @current-change="handleCurrentChange" /></div></el-card><!-- 新增商品对话框 --><el-dialog v-model="dialogVisible" title="新增商品" width="800px"><el-form :model="form" :rules="rules" ref="formRef" label-width="100px"><!-- 商品基本信息 --><el-form-item label="商品名称" prop="name"><el-input v-model="form.name" placeholder="请输入商品名称" /></el-form-item><el-form-item label="商品描述" prop="description"><el-input v-model="form.description" type="textarea" placeholder="请输入商品描述" /></el-form-item><!-- 价格库存 --><el-row><el-col :span="12"><el-form-item label="价格" prop="price"><el-input-number v-model="form.price" :min="0" :precision="2" /></el-form-item></el-col><el-col :span="12"><el-form-item label="库存" prop="stock"><el-input-number v-model="form.stock" :min="0" /></el-form-item></el-col></el-row><!-- 分类选择 --><el-form-item label="商品分类" prop="cascaderValue"><el-cascader v-model="form.cascaderValue" :options="cascaderOptions" :props="cascaderProps"placeholder="请选择商品分类" clearable /></el-form-item><!-- 图片上传 --><el-form-item label="商品图片" prop="images"><el-upload v-model:file-list="fileList" multiple list-type="picture-card" :auto-upload="false":on-change="handleUploadChange" :on-remove="handleRemove"><el-icon><Plus /></el-icon></el-upload></el-form-item><!-- 规格管理 --><el-form-item label="商品规格"><div v-for="(spec, index) in form.specs" :key="index" class="spec-item"><el-input v-model="spec.name" placeholder="规格名称" style="width: 120px" /><el-input v-model="spec.values" placeholder="多个值用逗号隔开"style="width: 200px; margin-left: 10px" /><el-button type="danger" circle :icon="Delete" @click="removeSpec(index)"style="margin-left: 10px" /></div><el-button type="primary" @click="addSpec" :icon="Plus">添加规格</el-button></el-form-item><!-- 商品属性 --><el-form-item label="商品属性"><div v-for="(attr, index) in form.attributes" :key="index" class="attr-item"><el-input v-model="attr.name" placeholder="属性名称" style="width: 120px" /><el-input v-model="attr.value" placeholder="属性值" style="width: 200px; margin-left: 10px" /><el-button type="danger" circle :icon="Delete" @click="removeAttr(index)"style="margin-left: 10px" /></div><el-button type="primary" @click="addAttr" :icon="Plus">添加属性</el-button><el-form-item label="是否热门"><el-switch v-model="form.isHot" active-text="是" inactive-text="否" :active-value="1":inactive-value="0" /></el-form-item></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><script setup>
import { ref, computed, onMounted, reactive } from 'vue'
import { useRoute } from 'vue-router' // 引入 useRoute
import {Search, Plus, Delete, Goods,Picture
} from '@element-plus/icons-vue'
import { getProductsAPI } from '@/api/product/product'
import { ElMessage, ElMessageBox } from 'element-plus'
import { getCategoriesAPI } from '@/api/category/category'
//上��图片
import { uploadFile } from '@/api/baseApi'
//新增接口
import { addProductAPI, deleteProductAPI } from '@/api/product/product'const route = useRoute() // 获取路由实例
const categoryId = computed(() => route.params.id) // 获取路由参数 id
const products = ref([])
const searchText = ref('')
const currentPage = ref(1)
const pageSize = ref(5)
const loading = ref(false)
const categoryOptions = ref([])// 在获取数据时处理规格分组
const getProductsData = async () => {loading.value = truetry {const res = await getProductsAPI()products.value = res.data.data.map(product => {// 规格分组处理const groupedSpecs = product.specList.reduce((acc, spec) => {const existing = acc.find(item => item.name === spec.name)if (existing) {existing.values.push(spec.value)} else {acc.push({ name: spec.name, values: [spec.value] })}return acc}, [])return {...product,groupedSpecs // 添加分组后的规格数据}})ElMessage.success('商品数据加载成功')} catch (error) {ElMessage.error('获取商品数据失败')console.error(error)} finally {loading.value = false}
}//获取新增分类
const categoryData = async () => {try {const res = await getCategoriesAPI()categoryOptions.value = res.data.data} catch (error) {console.error('获取分类数据失败', error)}
}// 搜索处理
const filteredData = computed(() => {let result = products.value// 根据路由参数筛选商品result = result.filter(item => item.categoryParentId == categoryId.value)// 文本搜索if (searchText.value) {const search = searchText.value.toLowerCase()result = result.filter(item => {return (item.name.toLowerCase().includes(search) ||item.description.toLowerCase().includes(search))})}return result
})// 当前页数据
const currentPageData = computed(() => {return filteredData.value.slice((currentPage.value - 1) * pageSize.value,currentPage.value * pageSize.value)
})// 分页事件处理
const handleSizeChange = (val) => {pageSize.value = valcurrentPage.value = 1
}const handleCurrentChange = (val) => {currentPage.value = val
}// 搜索事件
const handleSearch = () => {currentPage.value = 1
}// 格式化日期
const formatDate = (dateString) => {const date = new Date(dateString)return date.toLocaleDateString('zh-CN', {year: 'numeric',month: '2-digit',day: '2-digit',hour: '2-digit',minute: '2-digit'})
}// 对话框相关状态
const dialogVisible = ref(false)
const formRef = ref(null)// 表单数据
const form = reactive({name: '',description: '',price: 0,stock: 0,cascaderValue: [], // 级联选择值isHot: 0,images: [],specs: [],attributes: []
})// 文件列表
const fileList = ref([])// 分类数据格式转换
const cascaderOptions = computed(() => {return categoryOptions.value.map(cat => ({value: cat.id,label: cat.categoryName,children: cat.children?.map(child => ({value: child.id,label: child.name})) || []}))
})// 表单验证规则
const rules = reactive({name: [{ required: true, message: '请输入商品名称', trigger: 'blur' }],price: [{ required: true, message: '请输入价格', trigger: 'blur' }],stock: [{ required: true, message: '请输入库存', trigger: 'blur' }],cascaderValue: [{ required: true, message: '请选择商品分类', trigger: 'change' }]
})// 图片上传处理
const handleUploadChange = async (file) => {try {const formData = new FormData()formData.append('file', file.raw)const res = await uploadFile(formData)form.images.push(res.data.data)ElMessage.success('图片上传成功')} catch (error) {ElMessage.error('图片上传失败')console.error(error)}
}const handleRemove = (file) => {const index = form.images.indexOf(file.url)if (index > -1) {form.images.splice(index, 1)}
}// 规格管理
const addSpec = () => {form.specs.push({ name: '', values: '' })
}const removeSpec = (index) => {form.specs.splice(index, 1)
}// 属性管理
const addAttr = () => {form.attributes.push({ name: '', value: '' })
}const removeAttr = (index) => {form.attributes.splice(index, 1)
}// 提交表单
const submitForm = async () => {try {await formRef.value.validate()const payload = {name: form.name,description: form.description,price: form.price,stock: form.stock,parentCategoryId: form.cascaderValue[0], // 第一个元素是父分类categoryId: form.cascaderValue[1], // 第二个元素是子分类isHot: form.isHot ? 1 : 0, // 处理是否热门images: form.images.join(','), // 图片路径拼接spec: form.specs.map(spec => `${spec.name},${spec.values}`),attribute: form.attributes.map(attr => `${attr.name},${attr.value}`)}await addProductAPI(payload)ElMessage.success('商品添加成功')dialogVisible.value = false// 刷新商品列表getProductsData()} catch (error) {console.error('提交失败:', error)ElMessage.error('商品添加失败')}
}const handleDelete = async (productId) => {try {// 添加确认对话框await ElMessageBox.confirm('确定要删除该商品吗?', '警告', {confirmButtonText: '确定',cancelButtonText: '取消',type: 'warning'})// 调用删除APIawait deleteProductAPI(productId)ElMessage.success('删除成功')// 重新获取商品列表数据await getProductsData()} catch (error) {// 处理用户取消的情况if (error !== 'cancel') {ElMessage.error('删除失败')console.error(error)}}
}// 暴露打开对话框方法
const openDialog = () => {dialogVisible.value = true// 重置表单Object.assign(form, {name: '',description: '',price: 0,stock: 0,cascaderValue: [],images: [],specs: [],attributes: []})fileList.value = []
}onMounted(() => {getProductsData()categoryData()
})
</script><style scoped>
.product-container {padding: 20px;background-color: #f5f7fa;min-height: calc(100vh - 120px);
}.header-card {margin-bottom: 20px;
}.card-header {display: flex;justify-content: space-between;align-items: center;
}.card-header h2 {margin: 0;font-size: 18px;display: flex;align-items: center;gap: 8px;
}.search-area {display: flex;gap: 15px;flex-wrap: wrap;align-items: center;
}.search-input {width: 350px;
}.filter-select {width: 180px;
}.stat-cards {display: grid;grid-template-columns: repeat(auto-fit, minmax(200px, 1fr));gap: 20px;margin-bottom: 20px;
}.stat-card {text-align: center;
}.stat-header {display: flex;align-items: center;justify-content: center;gap: 8px;font-size: 14px;color: #606266;
}.stat-value {font-size: 24px;font-weight: bold;color: #303133;margin-top: 10px;
}.table-card {margin-bottom: 20px;
}.pagination-container {margin-top: 20px;display: flex;justify-content: center;
}.product-name {display: flex;align-items: center;gap: 8px;
}.product-tag {font-size: 12px;
}.price-tag {color: #f56c6c;font-weight: bold;
}.spec-item,
.attr-item {display: flex;align-items: center;gap: 8px;margin-bottom: 5px;
}.image-container {display: flex;flex-wrap: wrap;gap: 8px;
}.product-image {width: 60px;height: 60px;border-radius: 4px;border: 1px solid #ebeef5;transition: transform 0.3s;
}.product-image:hover {transform: scale(1.05);box-shadow: 0 2px 12px 0 rgba(0, 0, 0, 0.1);
}.image-error {display: flex;justify-content: center;align-items: center;width: 100%;height: 100%;background-color: #f5f7fa;color: #909399;
}/* 响应式调整 */
@media (max-width: 768px) {.search-input {width: 100%;}.filter-select {width: 100%;}.stat-cards {grid-template-columns: repeat(2, 1fr);}
}/* 规格样式 */
.spec-container {display: flex;flex-direction: column;gap: 6px;
}.spec-item {display: flex;align-items: center;line-height: 1.4;
}.spec-tag {flex-shrink: 0;
}.spec-values {margin-left: 4px;word-break: break-word;
}/* 属性样式 */
.attr-container {display: grid;grid-template-columns: repeat(2, minmax(0, 1fr));gap: 8px;
}.attr-item {display: flex;align-items: center;gap: 4px;min-width: 0;
}.attr-tag {flex-shrink: 0;
}.attr-value {overflow: hidden;text-overflow: ellipsis;white-space: nowrap;
}/* 响应式调整 */
@media (max-width: 768px) {.attr-container {grid-template-columns: 1fr;}
}.image-container {display: flex;flex-wrap: wrap;gap: 8px;
}.product-image {width: 60px;height: 60px;border-radius: 4px;border: 1px solid #ebeef5;transition: transform 0.3s;cursor: pointer;
}.product-image:hover {transform: scale(1.05);box-shadow: 0 2px 12px 0 rgba(0, 0, 0, 0.1);
}.image-error {display: flex;justify-content: center;align-items: center;width: 100%;height: 100%;background-color: #f5f7fa;color: #909399;
}.spec-item,
.attr-item {margin-bottom: 10px;display: flex;align-items: center;
}
</style>
5.2、小程序端
项目结构
核心代码
<script setup lang="ts">
import { onLoad } from '@dcloudio/uni-app';
import {getProductDetailAPI,getProductByCategoryAPI,getProductSpecAPI,getProductSkuAPI
} from '@/services/product'
import { ref, computed } from 'vue'
import AddressPanel from './component/AddressPanel.vue'
import ServicePanel from './component/ServicePanel.vue'
import type { SkuPopupEvent, SkuPopupInstanceType } from '@/components/vk-data-goods-sku-popup/vk-data-goods-sku-popup';
import { postMemberCartAPI } from '@/services/cart'
import { useAddressStore } from '@/stores/modules/address'
const { safeAreaInsets } = uni.getSystemInfoSync()
const goods = ref(null)
const categoryProductData = ref([])
// 是否显示sku
const isShowSku = ref(false)
const localdata = ref({})
// 按钮模式
enum SkuMode {Both = 1,Cart = 2,Buy = 3,
}
const mode = ref<SkuMode>(SkuMode.Cart)
// 打开SKU弹窗修改按钮模式
const openSkuPopup = (val: SkuMode) => {// 显示SKU弹窗isShowSku.value = true// 修改按钮模式mode.value = val
}const query = defineProps<{id: number
}>()const getProductDetailData = async () => {try {const res = await getProductDetailAPI(query.id)goods.value = res.data.dataawait getProductByCategoryData()// 获取规格数据const spec = await getProductSpecAPI(query.id)// 记录规格顺序(重要!)const specOrder = spec.data.data.map(v => v.name)// 获取SKU数据const skus = await getProductSkuAPI(query.id)// 构建 localdatalocaldata.value = {id: goods.value?.id,name: goods.value?.name,imageUrl: goods.value?.images[0],spec_list: spec.data.data.map(v => ({name: v.name,list: [...new Set(v.values)].map(value => ({name: value,id: value // 保持id与sku_id_arr对应}))})),sku_list: skus.data.data.map(v => ({_id: v.id,goods_id: v.goodsId,goods_name: v.goodsName,image: v.image,price: v.price * 100,stock: v.stock,sku_name_arr: specOrder.map(specName => {const specItem = v.specs.find(s => s.name === specName)return specItem ? specItem.value : ''}),sku_id_arr: specOrder.map(specName => {const specItem = v.specs.find(s => s.name === specName)return specItem ? specItem.value : '' // 保持与sku_name_arr一致})}))}// 调试输出console.log('规格顺序:', specOrder)console.log('处理后的SKU数据:', JSON.stringify(localdata.value.sku_list))} catch (error) {console.error('请求失败:', error)uni.showToast({ title: '数据加载失败', icon: 'none' })}
}// SKU组件实例
const skuPopupRef = ref<SkuPopupInstanceType>()
// 计算被选中的值
const selectArrText = computed(() => {return skuPopupRef.value?.selectArr?.join(' ').trim() || '请选择商品规格'
})const getProductByCategoryData = async () => {try {// 使用可选链操作符和.value访问const categoryId = goods.value?.categoryIdif (!categoryId) return // 确保categoryId存在const res = await getProductByCategoryAPI(categoryId)categoryProductData.value = res.data.data} catch (error) {console.error('请求失败:', error)uni.showToast({ title: '数据加载失败', icon: 'none' })}
}// 轮播图变化事件
const currentIndex = ref(0)
const onChange = (e: any) => {currentIndex.value = e.detail.current
}
const onTapImage = (url: string) => {uni.previewImage({urls: goods.value!.images,current: url,})
}const popup = ref()
// 弹出层条件渲染
const popupName = ref<'address' | 'service'>()
const openPopup = (name: typeof popupName.value) => {// 修改弹出层名称popupName.value = name// 打开弹出层popup.value?.open()
}// 加入购物车事件
const onAddCart = async (ev: SkuPopupEvent) => {let res = await postMemberCartAPI({ skuId: ev._id, number: ev.buy_num })if (res.data.code === 200) {uni.showToast({ title: '加入购物车成功', icon: 'none' })} else {uni.showToast({ title: '加入购物车失败', icon: 'none' })}isShowSku.value = false
}const onByNow = async (ev: SkuPopupEvent) => {uni.navigateTo({ url: '/pagesOrder/create/create?skuId=' + ev._id + '&number=' + ev.buy_num })
}const addressStore = useAddressStore()// 收货地址
const selectAddress = computed(() => {return addressStore.selectedAddress
})onLoad(() => {getProductDetailData() // 只需要调用这一个,它内部会触发第二个请求
})
</script><template><!-- sku弹窗组件 --><!-- SKU弹窗组件 --><vk-data-goods-sku-popup v-model="isShowSku" :localdata="localdata" :mode="mode" add-cart-background-color="#FFA868"buy-now-background-color="#27BA9B" ref="skuPopupRef" :actived-style="{color: '#27BA9B',borderColor: '#27BA9B',backgroundColor: '#E9F8F5',}" @add-cart="onAddCart" @buy-now="onByNow" /><scroll-view scroll-y class="viewport"><!-- 基本信息 --><view class="goods"><!-- 商品主图 --><view class="preview"><swiper circular @change="onChange"><swiper-item v-for="item in goods?.images" :key="item"><image @tap="onTapImage(item)" mode="aspectFill" :src="item" /></swiper-item></swiper><view class="indicator"><text class="current">{{ currentIndex + 1 }}</text><text class="split">/</text><text class="total">{{ goods?.images.length }}</text></view></view><!-- 商品简介 --><view class="meta"><view class="price"><text class="symbol">¥</text><text class="number">{{ goods?.price }}</text></view><view class="name ellipsis">{{ goods?.name }}</view><view class="desc"> {{ goods?.description }} </view></view><!-- 操作面板 --><!-- 操作面板 --><view class="action"><view class="action"><view @tap="openSkuPopup(SkuMode.Both)" class="item arrow"><text class="label">选择</text><text class="text ellipsis"> {{ selectArrText }} </text></view></view><view @tap="openPopup('address')" class="item arrow"><text class="label">送至</text><text class="text ellipsis">{{ selectAddress ? `${selectAddress.fullLocation} ${selectAddress.address}` : '请选择收获地址' }}</text></view><view @tap="openPopup('service')" class="item arrow"><text class="label">服务</text><text class="text ellipsis"> 无忧退 快速退款 免费包邮 </text></view></view></view><!-- 商品详情 --><view class="detail panel"><view class="title"><text>详情</text></view><view class="content"><view class="properties" v-for="item in goods?.attributes" :key="item.id"><!-- 属性详情 --><view class="item"><text class="label">{{ item.name }}</text><text class="value">{{ item.value }}</text></view></view></view></view><!-- 同类推荐 --><view class="similar panel"><view class="title"><text>同类推荐</text></view><view class="content"><navigator v-for="item in categoryProductData" :key="item.id" class="goods" hover-class="none":url="`/pages/goods/goods?id=${item.id}`"><image class="image" mode="aspectFill" :src="item.imageUrl"></image><view class="name ellipsis">{{ item.name }}</view><view class="price"><text class="symbol">¥</text><text class="number">{{ item.price }}</text></view></navigator></view></view></scroll-view><!-- 用户操作 --><view class="toolbar" :style="{ paddingBottom: safeAreaInsets?.bottom + 'px' }"><view class="icons"><button class="icons-button"><text class="icon-heart"></text>收藏</button><button class="icons-button" open-type="contact"><text class="icon-handset"></text>客服</button><navigator class="icons-button" url="/pages/cart/cart2" open-type="navigate"><text class="icon-cart"></text>购物车</navigator></view><view class="buttons"><view class="addcart" @tap="openSkuPopup(SkuMode.Cart)"> 加入购物车 </view><view class="buynow" @tap="openSkuPopup(SkuMode.Buy)"> 立即购买 </view></view></view><uni-popup ref="popup" type="bottom" background-color="#fff"><AddressPanel v-if="popupName === 'address'" @close="popup?.close()" /><ServicePanel v-if="popupName === 'service'" @close="popup?.close()" /></uni-popup>
</template><style lang="scss">
page {height: 100%;overflow: hidden;display: flex;flex-direction: column;
}.viewport {background-color: #f4f4f4;
}.panel {margin-top: 20rpx;background-color: #fff;.title {display: flex;justify-content: space-between;align-items: center;height: 90rpx;line-height: 1;padding: 30rpx 60rpx 30rpx 6rpx;position: relative;text {padding-left: 10rpx;font-size: 28rpx;color: #333;font-weight: 600;border-left: 4rpx solid #27ba9b;}navigator {font-size: 24rpx;color: #666;}}
}.arrow {&::after {position: absolute;top: 50%;right: 30rpx;content: '\e6c2';color: #ccc;font-family: 'erabbit' !important;font-size: 32rpx;transform: translateY(-50%);}
}/* 商品信息 */
.goods {background-color: #fff;.preview {height: 750rpx;position: relative;.image {width: 750rpx;height: 750rpx;}.indicator {height: 40rpx;padding: 0 24rpx;line-height: 40rpx;border-radius: 30rpx;color: #fff;font-family: Arial, Helvetica, sans-serif;background-color: rgba(0, 0, 0, 0.3);position: absolute;bottom: 30rpx;right: 30rpx;.current {font-size: 26rpx;}.split {font-size: 24rpx;margin: 0 1rpx 0 2rpx;}.total {font-size: 24rpx;}}}.meta {position: relative;border-bottom: 1rpx solid #eaeaea;.price {height: 130rpx;padding: 25rpx 30rpx 0;color: #fff;font-size: 34rpx;box-sizing: border-box;background-color: #35c8a9;}.number {font-size: 56rpx;}.brand {width: 160rpx;height: 80rpx;overflow: hidden;position: absolute;top: 26rpx;right: 30rpx;}.name {max-height: 88rpx;line-height: 1.4;margin: 20rpx;font-size: 32rpx;color: #333;}.desc {line-height: 1;padding: 0 20rpx 30rpx;font-size: 24rpx;color: #cf4444;}}.action {padding-left: 20rpx;.item {height: 90rpx;padding-right: 60rpx;border-bottom: 1rpx solid #eaeaea;font-size: 26rpx;color: #333;position: relative;display: flex;align-items: center;&:last-child {border-bottom: 0 none;}}.label {width: 60rpx;color: #898b94;margin: 0 16rpx 0 10rpx;}.text {flex: 1;-webkit-line-clamp: 1;}}
}/* 商品详情 */
.detail {padding-left: 20rpx;.content {margin-left: -20rpx;.image {width: 100%;}}.properties {padding: 0 20rpx;margin-bottom: 30rpx;.item {display: flex;line-height: 2;padding: 10rpx;font-size: 26rpx;color: #333;border-bottom: 1rpx dashed #ccc;}.label {width: 200rpx;}.value {flex: 1;}}
}/* 同类推荐 */
.similar {.content {padding: 0 20rpx 200rpx;background-color: #f4f4f4;display: flex;flex-wrap: wrap;.goods {width: 340rpx;padding: 24rpx 20rpx 20rpx;margin: 20rpx 7rpx;border-radius: 10rpx;background-color: #fff;}.image {width: 300rpx;height: 260rpx;}.name {height: 80rpx;margin: 10rpx 0;font-size: 26rpx;color: #262626;}.price {line-height: 1;font-size: 20rpx;color: #cf4444;}.number {font-size: 26rpx;margin-left: 2rpx;}}navigator {&:nth-child(even) {margin-right: 0;}}
}/* 底部工具栏 */
.toolbar {position: fixed;left: 0;right: 0;bottom: 0;z-index: 1;background-color: #fff;height: 100rpx;padding: 0 20rpx var(--window-bottom);border-top: 1rpx solid #eaeaea;display: flex;justify-content: space-between;align-items: center;box-sizing: content-box;.buttons {display: flex;&>view {width: 220rpx;text-align: center;line-height: 72rpx;font-size: 26rpx;color: #fff;border-radius: 72rpx;}.addcart {background-color: #ffa868;}.buynow,.payment {background-color: #27ba9b;margin-left: 20rpx;}}.icons {padding-right: 10rpx;display: flex;align-items: center;flex: 1;.icons-button {flex: 1;text-align: center;line-height: 1.4;padding: 0;margin: 0;border-radius: 0;font-size: 20rpx;color: #333;background-color: #fff;&::after {border: none;}}text {display: block;font-size: 34rpx;}}
}
</style>
5.3、后端
项目结构
核心代码
@Service
public class ProductSkuServiceImpl extends ServiceImpl<ProductSkuMapper, ProductSku>implements ProductSkuService {@Autowiredprivate IProductSpecService productSpecService;@Autowiredprivate ProductMapper productManager;/*** 根据商品id获取商品sku信息** @param productId* @return*/@Overridepublic List<ProductSkuVO> getProductById(Integer productId) {Product product = productManager.selectById(productId);List<ProductSku> list = this.lambdaQuery().eq(ProductSku::getProductId, productId).list();List<ProductSkuVO> vos = new ArrayList<>();for (ProductSku productSku : list) {ProductSkuVO vo = new ProductSkuVO();vo.setId(productSku.getId());vo.setGoodsId(productSku.getProductId());vo.setGoodsName(product.getName());vo.setStock(productSku.getStock());vo.setPrice(productSku.getPrice());vo.setImage(productSku.getImage());List<ProductSpec> specs = productSpecService.lambdaQuery().eq(ProductSpec::getProductSkuId, productSku.getId()).list();vo.setSpecs(specs);vos.add(vo);}return vos;}@Overridepublic List<ProductSkuVO> getSkuById(Integer skuId) {List<ProductSku> list = this.lambdaQuery().eq(ProductSku::getId, skuId).list();List<ProductSkuVO> vos = new ArrayList<>();for (ProductSku productSku : list) {ProductSkuVO vo = new ProductSkuVO();Product product = productManager.selectById(productSku.getProductId());vo.setId(productSku.getId());vo.setGoodsId(productSku.getProductId());vo.setGoodsName(product.getName());vo.setStock(productSku.getStock());vo.setPrice(productSku.getPrice());vo.setImage(productSku.getImage());List<ProductSpec> specs = productSpecService.lambdaQuery().eq(ProductSpec::getProductSkuId, productSku.getId()).list();vo.setSpecs(specs);vos.add(vo);}return vos;}
}
@Service
public class ProductServiceImpl extends ServiceImpl<ProductMapper, Product> implements IProductService {@Autowiredprivate ProductMapper productMapper;@Autowiredprivate IProductImageService productImageService;@Autowiredprivate IProductAttributeService productAttributeService;/*** 商品列表分页查询** @param page* @return*/@Overridepublic IPage<ProductVO> getList(IPage<ProductVO> page, Integer hotId) {return productMapper.getList(page, hotId);}/*** 根据分类id查询商品** @param categoryId* @return*/@Overridepublic List<ProductVO> getByCategoryId(Integer categoryId) {List<Product> list = this.lambdaQuery().eq(Product::getCategoryId, categoryId).list();List<ProductVO> vos = new ArrayList<>();for (Product product : list) {ProductVO vo = new ProductVO();BeanUtils.copyProperties(product, vo);List<ProductImage> images = productImageService.lambdaQuery().eq(ProductImage::getProductId, product.getId()).list();if (images.size() > 0) {vo.setImageUrl(images.get(0).getImageUrl());}vos.add(vo);}return vos;}/*** 根据id查询商品详情** @param id* @return*/@Overridepublic ProductDetailVO getProductDetailById(Integer id) {Product product = this.getById(id);if (product != null) {ProductDetailVO vo = new ProductDetailVO();BeanUtils.copyProperties(product, vo);//获取商品图片List<ProductImage> productImages = productImageService.lambdaQuery().eq(ProductImage::getProductId, product.getId()).list();if (!productImages.isEmpty()) {List<String> imageList = productImages.stream().map(ProductImage::getImageUrl).collect(Collectors.toList());vo.setImages(imageList);}//获取属性值List<ProductAttribute> productAttributeList = productAttributeService.lambdaQuery().eq(ProductAttribute::getProductId, product.getId()).list();if (!productAttributeList.isEmpty()) {vo.setAttributes(productAttributeList);}return vo;}return null;}
}
package com.funrniur.app.service.impl;import com.funrniur.app.dto.CartDTO;
import com.funrniur.app.mapper.ProductMapper;
import com.funrniur.app.service.IProductService;
import com.funrniur.app.service.IProductSpecService;
import com.funrniur.app.service.ProductSkuService;
import com.funrniur.app.vo.CartVO;
import com.funrniur.common.login.LoginUser;
import com.funrniur.common.login.LoginUserHolder;
import com.funrniur.model.entity.Cart;
import com.funrniur.app.mapper.CartMapper;
import com.funrniur.app.service.ICartService;
import com.baomidou.mybatisplus.extension.service.impl.ServiceImpl;
import com.funrniur.model.entity.Product;
import com.funrniur.model.entity.ProductSku;
import com.funrniur.model.entity.ProductSpec;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.stereotype.Service;import java.time.LocalDateTime;
import java.util.Collections;
import java.util.List;
import java.util.stream.Collectors;/*** <p>* 购物车表 服务实现类* </p>** @author chen* @since 2025-03-09*/
@Service
public class CartServiceImpl extends ServiceImpl<CartMapper, Cart> implements ICartService {@Autowiredprivate ProductSkuService productSkuService;@Autowiredprivate ProductMapper productMapper;@Autowiredprivate IProductSpecService productSpecService;/*** 添加购物车** @param cartDTO*/@Overridepublic void addCart(CartDTO cartDTO) {LoginUser loginUser = LoginUserHolder.getLoginUser();ProductSku sku = this.productSkuService.getById(cartDTO.getSkuId());Cart dbCart = this.lambdaQuery().eq(Cart::getUserId, loginUser.getUserId()).eq(Cart::getProductSkuId, cartDTO.getSkuId()).eq(Cart::getProductId, sku.getProductId()).one();if (dbCart != null){dbCart.setQuantity(dbCart.getQuantity() + cartDTO.getNumber());this.updateById(dbCart);return;}Cart cart = new Cart();cart.setUserId(loginUser.getUserId().intValue());cart.setProductId(sku.getProductId());cart.setProductSkuId(cartDTO.getSkuId());cart.setQuantity(cartDTO.getNumber());cart.setAddTime(LocalDateTime.now());cart.setSelected(0);this.save(cart);}/*** 获取购物车列表** @return*/@Overridepublic List<CartVO> getList() {LoginUser loginUser = LoginUserHolder.getLoginUser();List<Cart> list = this.lambdaQuery().eq(Cart::getUserId, loginUser.getUserId()).list();if (list == null || list.isEmpty()){return Collections.emptyList();}return list.stream().map(cart -> {CartVO cartVO = new CartVO();cartVO.setId(cart.getId());cartVO.setSkuId(cart.getProductSkuId());cartVO.setNumber(cart.getQuantity());cartVO.setProductId(cart.getProductId());Product product = productMapper.selectById(cart.getProductId());cartVO.setName(product.getName());ProductSku productSku = productSkuService.getById(cart.getProductSkuId());cartVO.setImage(productSku.getImage());cartVO.setPrice(productSku.getPrice());cartVO.setStock(productSku.getStock());cartVO.setSelected(cart.getSelected());List<ProductSpec> specList = productSpecService.lambdaQuery().eq(ProductSpec::getProductSkuId, cart.getProductSkuId()).list();String attrsText = specList.stream().map(ProductSpec::getValue) // 提取规格名称.collect(Collectors.joining(" ")); // 用空格连接cartVO.setAttrsText(attrsText);return cartVO;}).collect(Collectors.toList());}
}