Springboot + vue + uni-app小程序web端全套家具商场
文章目录
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 timer
return (...args) => {
clearTimeout(timer)
timer = setTimeout(() => fn.apply(this, args), delay)
}
}
const fetchData = async () => {
try {
loading.value = true
const res = await DashBoardAPI()
dashboardData.value = res.data.data
initCharts()
} 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 = true
try {
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 = val
currentPage.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'
})
// 调用删除API
await 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.data
await getProductByCategoryData()
// 获取规格数据
const spec = await getProductSpecAPI(query.id)
// 记录规格顺序(重要!)
const specOrder = spec.data.data.map(v => v.name)
// 获取SKU数据
const skus = await getProductSkuAPI(query.id)
// 构建 localdata
localdata.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?.categoryId
if (!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 {
@Autowired
private IProductSpecService productSpecService;
@Autowired
private ProductMapper productManager;
/**
* 根据商品id获取商品sku信息
*
* @param productId
* @return
*/
@Override
public 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;
}
@Override
public 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 {
@Autowired
private ProductMapper productMapper;
@Autowired
private IProductImageService productImageService;
@Autowired
private IProductAttributeService productAttributeService;
/**
* 商品列表分页查询
*
* @param page
* @return
*/
@Override
public IPage<ProductVO> getList(IPage<ProductVO> page, Integer hotId) {
return productMapper.getList(page, hotId);
}
/**
* 根据分类id查询商品
*
* @param categoryId
* @return
*/
@Override
public 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
*/
@Override
public 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 {
@Autowired
private ProductSkuService productSkuService;
@Autowired
private ProductMapper productMapper;
@Autowired
private IProductSpecService productSpecService;
/**
* 添加购物车
*
* @param cartDTO
*/
@Override
public 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
*/
@Override
public 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());
}
}