更改了购物车逻辑,增加了地址管理

This commit is contained in:
2025-08-27 10:16:48 +08:00
parent 2ae7f21b68
commit ff5b70345e
7 changed files with 1626 additions and 90 deletions

View File

@@ -247,9 +247,21 @@ const routes = [
{
path: '/pay',
name: 'Pay',
component: () => import('../views/Pay.vue'),
component: () => import('@/views/Pay.vue'),
meta: { title: '确认支付' }
},
{
path: '/cart',
name: 'Cart',
component: () => import('@/views/Cart.vue'),
meta: { title: '购物车' }
},
{
path: '/address',
name: 'Address',
component: () => import('@/views/Address.vue'),
meta: { title: '地址管理', requiresAuth: true }
},
{
path: '/payfailed',
name: 'PayFailed',

594
src/views/Address.vue Normal file
View File

@@ -0,0 +1,594 @@
<template>
<div class="address-page">
<!-- 导航栏 -->
<nav class="navbar">
<div class="nav-left">
<el-button
type="text"
@click="$router.go(-1)"
class="back-btn"
>
<el-icon><ArrowLeft /></el-icon>
返回
</el-button>
</div>
<div class="nav-center">
<h1 class="nav-title">收货地址</h1>
</div>
<div class="nav-right">
<el-button
type="primary"
@click="showAddDialog = true"
class="add-btn"
>
新增地址
</el-button>
</div>
</nav>
<div v-loading="loading" class="address-content">
<!-- 地址列表为空 -->
<div v-if="addresses.length === 0 && !loading" class="empty-address">
<el-icon class="empty-icon"><Location /></el-icon>
<p>暂无收货地址</p>
<p class="empty-tip">点击右上角添加收货地址</p>
</div>
<!-- 地址列表 -->
<div v-else class="address-list">
<div
v-for="address in addresses"
:key="address.id"
class="address-item"
:class="{ 'default-address': address.isDefault }"
>
<div class="address-info">
<div class="address-header">
<span class="recipient-name">{{ address.recipientName }}</span>
<span class="recipient-phone">{{ address.recipientPhone }}</span>
<el-tag v-if="address.isDefault" type="danger" size="small" class="default-tag">
默认
</el-tag>
</div>
<div class="address-detail">
<el-icon><Location /></el-icon>
<span>{{ address.province }} {{ address.city }} {{ address.district }} {{ address.detailAddress }}</span>
</div>
</div>
<div class="address-actions">
<el-button
type="text"
@click="editAddress(address)"
class="edit-btn"
>
编辑
</el-button>
<el-button
type="text"
@click="deleteAddress(address.id)"
class="delete-btn"
>
删除
</el-button>
<el-button
v-if="!address.isDefault"
type="text"
@click="setDefaultAddress(address.id)"
class="default-btn"
>
设为默认
</el-button>
</div>
</div>
</div>
</div>
<!-- 新增/编辑地址对话框 -->
<el-dialog
v-model="showAddDialog"
:title="editingAddress ? '编辑地址' : '新增地址'"
width="90%"
class="address-dialog"
>
<el-form
ref="addressFormRef"
:model="addressForm"
:rules="addressRules"
label-width="80px"
class="address-form"
>
<el-form-item label="收货人" prop="recipientName">
<el-input
v-model="addressForm.recipientName"
placeholder="请输入收货人姓名"
maxlength="20"
/>
</el-form-item>
<el-form-item label="手机号" prop="recipientPhone">
<el-input
v-model="addressForm.recipientPhone"
placeholder="请输入手机号"
maxlength="11"
/>
</el-form-item>
<el-form-item label="省市区" prop="region">
<el-cascader
v-model="addressForm.region"
:options="regionOptions"
placeholder="请选择省市区"
style="width: 100%"
@change="handleRegionChange"
/>
</el-form-item>
<el-form-item label="详细地址" prop="detailAddress">
<el-input
v-model="addressForm.detailAddress"
type="textarea"
:rows="3"
placeholder="请输入详细地址"
maxlength="100"
show-word-limit
/>
</el-form-item>
<el-form-item>
<el-checkbox v-model="addressForm.isDefault">
设为默认地址
</el-checkbox>
</el-form-item>
</el-form>
<template #footer>
<div class="dialog-footer">
<el-button @click="cancelEdit">取消</el-button>
<el-button type="primary" @click="saveAddress" :loading="saving">
{{ editingAddress ? '保存' : '添加' }}
</el-button>
</div>
</template>
</el-dialog>
</div>
</template>
<script setup>
import { ref, reactive, onMounted } from 'vue'
import { useRouter } from 'vue-router'
import { ElMessage, ElMessageBox } from 'element-plus'
import {
ArrowLeft,
Location
} from '@element-plus/icons-vue'
import api from '@/utils/api'
const router = useRouter()
// 响应式数据
const loading = ref(false)
const saving = ref(false)
const addresses = ref([])
const showAddDialog = ref(false)
const editingAddress = ref(null)
const addressFormRef = ref(null)
// 地址表单数据
const addressForm = reactive({
recipientName: '',
recipientPhone: '',
region: [],
province: '',
city: '',
district: '',
detailAddress: '',
isDefault: false
})
// 省市区选项数据简化版实际项目中应该从API获取
const regionOptions = ref([
{
value: '北京市',
label: '北京市',
children: [
{
value: '北京市',
label: '北京市',
children: [
{ value: '东城区', label: '东城区' },
{ value: '西城区', label: '西城区' },
{ value: '朝阳区', label: '朝阳区' },
{ value: '丰台区', label: '丰台区' },
{ value: '石景山区', label: '石景山区' },
{ value: '海淀区', label: '海淀区' }
]
}
]
},
{
value: '上海市',
label: '上海市',
children: [
{
value: '上海市',
label: '上海市',
children: [
{ value: '黄浦区', label: '黄浦区' },
{ value: '徐汇区', label: '徐汇区' },
{ value: '长宁区', label: '长宁区' },
{ value: '静安区', label: '静安区' },
{ value: '普陀区', label: '普陀区' },
{ value: '虹口区', label: '虹口区' }
]
}
]
},
{
value: '广东省',
label: '广东省',
children: [
{
value: '广州市',
label: '广州市',
children: [
{ value: '天河区', label: '天河区' },
{ value: '越秀区', label: '越秀区' },
{ value: '荔湾区', label: '荔湾区' },
{ value: '海珠区', label: '海珠区' }
]
},
{
value: '深圳市',
label: '深圳市',
children: [
{ value: '福田区', label: '福田区' },
{ value: '罗湖区', label: '罗湖区' },
{ value: '南山区', label: '南山区' },
{ value: '宝安区', label: '宝安区' }
]
}
]
}
])
// 表单验证规则
const addressRules = {
recipientName: [
{ required: true, message: '请输入收货人姓名', trigger: 'blur' },
{ min: 2, max: 20, message: '姓名长度在 2 到 20 个字符', trigger: 'blur' }
],
recipientPhone: [
{ required: true, message: '请输入手机号', trigger: 'blur' },
{ pattern: /^1[3-9]\d{9}$/, message: '请输入正确的手机号', trigger: 'blur' }
],
region: [
{ required: true, message: '请选择省市区', trigger: 'change' }
],
detailAddress: [
{ required: true, message: '请输入详细地址', trigger: 'blur' },
{ min: 5, max: 100, message: '详细地址长度在 5 到 100 个字符', trigger: 'blur' }
]
}
// 方法
const loadAddresses = async () => {
try {
loading.value = true
const response = await api.get('/addresses')
if (response.data.success) {
addresses.value = response.data.data.addresses || []
} else {
throw new Error(response.data.message || '获取地址列表失败')
}
} catch (error) {
ElMessage.error(error.message || '获取地址列表失败')
} finally {
loading.value = false
}
}
const handleRegionChange = (value) => {
if (value && value.length === 3) {
addressForm.province = value[0]
addressForm.city = value[1]
addressForm.district = value[2]
}
}
const editAddress = (address) => {
editingAddress.value = address
addressForm.recipientName = address.recipientName
addressForm.recipientPhone = address.recipientPhone
addressForm.region = [address.province, address.city, address.district]
addressForm.province = address.province
addressForm.city = address.city
addressForm.district = address.district
addressForm.detailAddress = address.detailAddress
addressForm.isDefault = address.isDefault
showAddDialog.value = true
}
const saveAddress = async () => {
if (!addressFormRef.value) return
try {
await addressFormRef.value.validate()
saving.value = true
const addressData = {
recipientName: addressForm.recipientName,
recipientPhone: addressForm.recipientPhone,
province: addressForm.province,
city: addressForm.city,
district: addressForm.district,
detailAddress: addressForm.detailAddress,
isDefault: addressForm.isDefault
}
let response
if (editingAddress.value) {
// 编辑地址
response = await api.put(`/addresses/${editingAddress.value.id}`, addressData)
} else {
// 新增地址
response = await api.post('/addresses', addressData)
}
if (response.data.success) {
ElMessage.success(editingAddress.value ? '地址更新成功' : '地址添加成功')
showAddDialog.value = false
resetForm()
await loadAddresses()
} else {
throw new Error(response.data.message || '保存地址失败')
}
} catch (error) {
if (error.message) {
ElMessage.error(error.message)
}
} finally {
saving.value = false
}
}
const deleteAddress = async (addressId) => {
try {
await ElMessageBox.confirm(
'确定要删除这个地址吗?',
'确认删除',
{
confirmButtonText: '确定',
cancelButtonText: '取消',
type: 'warning'
}
)
const response = await api.delete(`/addresses/${addressId}`)
if (response.data.success) {
ElMessage.success('地址删除成功')
await loadAddresses()
} else {
throw new Error(response.data.message || '删除地址失败')
}
} catch (error) {
if (error !== 'cancel') {
ElMessage.error(error.message || '删除地址失败')
}
}
}
const setDefaultAddress = async (addressId) => {
try {
const response = await api.put(`/addresses/${addressId}/default`)
if (response.data.success) {
ElMessage.success('默认地址设置成功')
await loadAddresses()
} else {
throw new Error(response.data.message || '设置默认地址失败')
}
} catch (error) {
ElMessage.error(error.message || '设置默认地址失败')
}
}
const cancelEdit = () => {
showAddDialog.value = false
resetForm()
}
const resetForm = () => {
editingAddress.value = null
addressForm.recipientName = ''
addressForm.recipientPhone = ''
addressForm.region = []
addressForm.province = ''
addressForm.city = ''
addressForm.district = ''
addressForm.detailAddress = ''
addressForm.isDefault = false
if (addressFormRef.value) {
addressFormRef.value.resetFields()
}
}
// 生命周期
onMounted(() => {
loadAddresses()
})
</script>
<style scoped>
.address-page {
min-height: 100vh;
background-color: #f5f5f5;
}
.navbar {
display: flex;
align-items: center;
justify-content: space-between;
padding: 0 16px;
height: 60px;
background: white;
border-bottom: 1px solid #eee;
position: sticky;
top: 0;
z-index: 100;
}
.nav-left, .nav-right {
flex: 1;
}
.nav-right {
display: flex;
justify-content: flex-end;
}
.nav-center {
flex: 2;
text-align: center;
}
.nav-title {
margin: 0;
font-size: 18px;
font-weight: 500;
color: #333;
}
.back-btn {
color: #666;
font-size: 16px;
}
.add-btn {
font-size: 14px;
}
.address-content {
padding: 16px;
}
.empty-address {
text-align: center;
padding: 80px 20px;
color: #999;
}
.empty-icon {
font-size: 64px;
color: #ddd;
margin-bottom: 16px;
}
.empty-tip {
font-size: 14px;
margin-top: 8px;
}
.address-list {
display: flex;
flex-direction: column;
gap: 12px;
}
.address-item {
background: white;
border-radius: 8px;
padding: 16px;
box-shadow: 0 2px 4px rgba(0, 0, 0, 0.1);
transition: all 0.3s ease;
}
.address-item:hover {
box-shadow: 0 4px 8px rgba(0, 0, 0, 0.15);
}
.default-address {
border: 2px solid #409eff;
}
.address-info {
margin-bottom: 12px;
}
.address-header {
display: flex;
align-items: center;
gap: 12px;
margin-bottom: 8px;
}
.recipient-name {
font-size: 16px;
font-weight: 500;
color: #333;
}
.recipient-phone {
font-size: 14px;
color: #666;
}
.default-tag {
font-size: 12px;
}
.address-detail {
display: flex;
align-items: flex-start;
gap: 8px;
color: #666;
font-size: 14px;
line-height: 1.5;
}
.address-actions {
display: flex;
gap: 16px;
justify-content: flex-end;
}
.edit-btn {
color: #409eff;
}
.delete-btn {
color: #f56c6c;
}
.default-btn {
color: #67c23a;
}
.address-dialog {
border-radius: 8px;
}
.address-form {
padding: 0 8px;
}
.dialog-footer {
display: flex;
justify-content: flex-end;
gap: 12px;
}
@media (max-width: 768px) {
.address-item {
padding: 12px;
}
.address-actions {
gap: 12px;
}
.address-dialog {
width: 95% !important;
}
}
</style>

View File

@@ -24,19 +24,42 @@
<div class="address-header">
<el-icon><Location /></el-icon>
<span class="address-label">收货地址</span>
<el-icon class="edit-icon"><Edit /></el-icon>
<el-button
type="text"
@click="goToAddressManage"
class="manage-address-btn"
>
管理地址
</el-button>
</div>
<div class="address-content">
<div v-if="!showAddressEdit" class="address-text" @click="showAddressEdit = true">{{ shippingAddress }}</div>
<el-input
v-else
v-model="shippingAddress"
@blur="showAddressEdit = false"
@keyup.enter="showAddressEdit = false"
placeholder="请输入收货地址"
class="address-input"
autofocus
/>
<el-select
v-model="selectedAddressId"
placeholder="请选择收货地址"
class="address-select"
@change="handleAddressChange"
>
<el-option
v-for="address in addresses"
:key="address.id"
:label="`${address.recipientName} ${address.recipientPhone} ${address.province}${address.city}${address.district}${address.detailAddress}`"
:value="address.id"
>
<div class="address-option">
<div class="address-info">
<span class="recipient-info">{{ address.recipientName }} {{ address.recipientPhone }}</span>
<el-tag v-if="address.isDefault" type="danger" size="small" class="default-tag">默认</el-tag>
</div>
<div class="address-detail">{{ address.province }}{{ address.city }}{{ address.district }}{{ address.detailAddress }}</div>
</div>
</el-option>
</el-select>
<div v-if="addresses.length === 0" class="no-address">
<span class="no-address-text">暂无收货地址</span>
<el-button type="text" @click="goToAddressManage" class="add-address-btn">
立即添加
</el-button>
</div>
</div>
</div>
@@ -115,7 +138,6 @@
class="note-input"
autofocus
/>
<el-icon v-if="!showNoteEdit" class="arrow-icon"><ArrowRight /></el-icon>
</div>
</div>
@@ -127,7 +149,7 @@
<el-button
size="large"
class="cart-button"
@click="addToCart"
@click="handleAddToCart"
:disabled="!canPurchase"
>
加入购物车
@@ -170,9 +192,10 @@ const categories = ref([])
const sizes = ref([])
const selectedCategory = ref(null)
const selectedSize = ref(null)
const shippingAddress = ref('请输入收货地址')
const addresses = ref([])
const selectedAddressId = ref('')
const selectedAddress = ref(null)
const orderNote = ref('')
const showAddressEdit = ref(false)
const showNoteEdit = ref(false)
// 计算属性
@@ -272,15 +295,21 @@ const addToCart = async () => {
}
}
// 立即购买功能
const handlePurchase = async () => {
if (!canPurchase.value) {
ElMessage.error('请选择完整的商品信息')
return
}
if (!selectedAddress.value) {
ElMessage.error('请选择收货地址')
return
}
try {
// 先将商品添加到购物车
const cartItem = {
// 创建单独的购买订单
const orderData = {
productId: product.value.id,
quantity: quantity.value,
categoryId: selectedCategory.value.id,
@@ -289,11 +318,11 @@ const handlePurchase = async () => {
name: product.value.name,
image: product.value.image,
stock: product.value.stock,
shippingAddress: shippingAddress.value,
addressId: selectedAddress.value.id,
orderNote: orderNote.value
}
const response = await api.post('/cart/add', cartItem)
const response = await api.post('/cart/buy-now', orderData)
if (response.data.success) {
const cartId = response.data.data.cartId
@@ -306,13 +335,73 @@ const handlePurchase = async () => {
}
})
} else {
throw new Error(response.data.message || '添加到购物车失败')
throw new Error(response.data.message || '创建订单失败')
}
} catch (error) {
ElMessage.error(error.message || '操作失败,请重试')
}
}
// 添加到购物车功能(新增)
const handleAddToCart = async () => {
if (!canPurchase.value) {
ElMessage.error('请选择完整的商品信息')
return
}
try {
const cartItem = {
productId: product.value.id,
quantity: quantity.value,
categoryId: selectedCategory.value.id,
sizeId: selectedSize.value.id,
points: product.value.points,
name: product.value.name,
image: product.value.image,
stock: product.value.stock
}
const response = await api.post('/cart/add', cartItem)
if (response.data.success) {
ElMessage.success('商品已加入购物车!')
// 可以选择返回上一页或跳转到购物车页面
router.go(-1)
} else {
throw new Error(response.data.message || '添加到购物车失败')
}
} catch (error) {
ElMessage.error(error.message || '添加到购物车失败,请重试')
}
}
// 获取用户地址列表
const getAddressList = async () => {
try {
const response = await api.get('/address/list')
addresses.value = response.data.data.addresses || []
// 如果有默认地址,自动选中
const defaultAddress = addresses.value.find(addr => addr.isDefault)
if (defaultAddress) {
selectedAddressId.value = defaultAddress.id
selectedAddress.value = defaultAddress
}
} catch (error) {
console.error('获取地址列表失败:', error)
ElMessage.error('获取地址列表失败')
}
}
// 处理地址选择变化
const handleAddressChange = (addressId) => {
selectedAddress.value = addresses.value.find(addr => addr.id === addressId)
}
// 跳转到地址管理页面
const goToAddressManage = () => {
router.push('/address')
}
// 生命周期
onMounted(() => {
// 从URL参数获取初始数量
@@ -324,6 +413,7 @@ onMounted(() => {
getProductInfo()
getCategories()
getSizes()
getAddressList()
})
</script>
@@ -382,10 +472,7 @@ onMounted(() => {
color: #666;
}
.address-text {
color: #666;
font-size: 14px;
}
.product-section {
background: white;
@@ -557,12 +644,67 @@ onMounted(() => {
color: #999;
}
.arrow-icon {
color: #ccc;
.address-select {
width: 100%;
margin-top: 10px;
}
.address-input {
margin-top: 8px;
.manage-address-btn {
color: #409eff;
font-size: 14px;
margin-left: auto;
}
.manage-address-btn:hover {
color: #66b1ff;
}
.address-option {
padding: 8px 0;
}
.address-info {
display: flex;
align-items: center;
justify-content: space-between;
margin-bottom: 4px;
}
.recipient-info {
font-weight: 500;
color: #303133;
}
.default-tag {
margin-left: 8px;
}
.address-detail {
color: #606266;
font-size: 12px;
line-height: 1.4;
}
.no-address {
display: flex;
align-items: center;
justify-content: center;
padding: 20px;
color: #909399;
font-size: 14px;
}
.no-address-text {
margin-right: 8px;
}
.add-address-btn {
color: #409eff;
font-size: 14px;
}
.add-address-btn:hover {
color: #66b1ff;
}
.note-input {
@@ -575,10 +717,7 @@ onMounted(() => {
flex: 1;
}
.address-text {
cursor: pointer;
padding: 4px 0;
}
.note-content {
cursor: pointer;

559
src/views/Cart.vue Normal file
View File

@@ -0,0 +1,559 @@
<template>
<div class="cart-page">
<!-- 导航栏 -->
<nav class="navbar">
<div class="nav-left">
<el-button
type="text"
@click="$router.go(-1)"
class="back-btn"
>
<el-icon><ArrowLeft /></el-icon>
</el-button>
</div>
<div class="nav-center">
<h1 class="nav-title">购物车</h1>
</div>
<div class="nav-right">
<el-button
type="text"
@click="clearCart"
class="clear-btn"
v-if="cartItems.length > 0"
>
清空
</el-button>
</div>
</nav>
<div v-loading="loading" class="page-content">
<!-- 空购物车状态 -->
<div v-if="cartItems.length === 0" class="empty-cart">
<div class="empty-icon">
<el-icon size="80"><ShoppingCart /></el-icon>
</div>
<div class="empty-text">购物车是空的</div>
<div class="empty-desc">快去挑选心仪的商品吧</div>
<el-button type="primary" @click="$router.push('/shop')" class="go-shop-btn">
去购物
</el-button>
</div>
<!-- 购物车商品列表 -->
<div v-else class="cart-content">
<div class="cart-header">
<el-checkbox
v-model="selectAll"
@change="handleSelectAll"
class="select-all-checkbox"
>
全选
</el-checkbox>
<span class="item-count">{{ cartItems.length }}件商品</span>
</div>
<div class="cart-items">
<div
v-for="item in cartItems"
:key="item.id"
class="cart-item"
>
<div class="item-checkbox">
<el-checkbox
v-model="item.selected"
@change="updateSelection"
/>
</div>
<div class="item-image">
<img :src="item.image || '/imgs/productdetail/商品主图.png'" :alt="item.name" />
</div>
<div class="item-info">
<div class="item-name">{{ item.name }}</div>
<div class="item-details">
<span v-if="item.category" class="item-category">{{ item.category }}</span>
<span v-if="item.size" class="item-size">{{ item.size }}</span>
</div>
<div class="item-price">
<el-icon class="coin-icon"><Coin /></el-icon>
<span class="price-value">{{ item.points }}</span>
</div>
</div>
<div class="item-actions">
<div class="quantity-controls">
<el-button
size="small"
@click="decreaseQuantity(item)"
:disabled="item.quantity <= 1"
class="quantity-btn"
>
-
</el-button>
<span class="quantity">{{ item.quantity }}</span>
<el-button
size="small"
@click="increaseQuantity(item)"
:disabled="item.quantity >= item.stock"
class="quantity-btn"
>
+
</el-button>
</div>
<el-button
type="text"
@click="removeItem(item)"
class="remove-btn"
>
删除
</el-button>
</div>
</div>
</div>
</div>
</div>
<!-- 底部结算栏 -->
<div v-if="cartItems.length > 0" class="bottom-bar">
<div class="total-info">
<div class="selected-count">已选{{ selectedCount }}</div>
<div class="total-price">
<span class="total-label">合计</span>
<el-icon class="coin-icon"><Coin /></el-icon>
<span class="total-value">{{ totalPrice }}</span>
</div>
</div>
<el-button
type="primary"
size="large"
@click="checkout"
:disabled="selectedCount === 0"
class="checkout-btn"
>
结算({{ selectedCount }})
</el-button>
</div>
</div>
</template>
<script setup>
import { ref, computed, onMounted } from 'vue'
import { useRouter } from 'vue-router'
import { ElMessage, ElMessageBox } from 'element-plus'
import {
ArrowLeft,
ShoppingCart,
Coin
} from '@element-plus/icons-vue'
import api from '@/utils/api'
const router = useRouter()
// 响应式数据
const loading = ref(false)
const cartItems = ref([])
const selectAll = ref(false)
// 计算属性
const selectedItems = computed(() => {
return cartItems.value.filter(item => item.selected)
})
const selectedCount = computed(() => {
return selectedItems.value.length
})
const totalPrice = computed(() => {
return selectedItems.value.reduce((total, item) => {
return total + (item.points * item.quantity)
}, 0)
})
// 方法
const loadCartData = async () => {
loading.value = true
try {
const response = await api.get('/cart')
if (response.data.success) {
cartItems.value = response.data.data.map(item => ({
...item,
selected: false
}))
}
} catch (error) {
ElMessage.error('加载购物车失败')
} finally {
loading.value = false
}
}
const handleSelectAll = () => {
cartItems.value.forEach(item => {
item.selected = selectAll.value
})
}
const updateSelection = () => {
const selectedCount = cartItems.value.filter(item => item.selected).length
selectAll.value = selectedCount === cartItems.value.length && cartItems.value.length > 0
}
const increaseQuantity = async (item) => {
if (item.quantity >= item.stock) {
ElMessage.warning('库存不足')
return
}
try {
await api.put(`/cart/${item.id}`, {
quantity: item.quantity + 1
})
item.quantity++
} catch (error) {
ElMessage.error('更新数量失败')
}
}
const decreaseQuantity = async (item) => {
if (item.quantity <= 1) return
try {
await api.put(`/cart/${item.id}`, {
quantity: item.quantity - 1
})
item.quantity--
} catch (error) {
ElMessage.error('更新数量失败')
}
}
const removeItem = async (item) => {
try {
await ElMessageBox.confirm(
'确定要删除这件商品吗?',
'确认删除',
{
confirmButtonText: '确定',
cancelButtonText: '取消',
type: 'warning'
}
)
await api.delete(`/cart/${item.id}`)
const index = cartItems.value.findIndex(i => i.id === item.id)
if (index > -1) {
cartItems.value.splice(index, 1)
}
ElMessage.success('删除成功')
updateSelection()
} catch (error) {
if (error !== 'cancel') {
ElMessage.error('删除失败')
}
}
}
const clearCart = async () => {
try {
await ElMessageBox.confirm(
'确定要清空购物车吗?',
'确认清空',
{
confirmButtonText: '确定',
cancelButtonText: '取消',
type: 'warning'
}
)
await api.delete('/cart/clear')
cartItems.value = []
ElMessage.success('购物车已清空')
} catch (error) {
if (error !== 'cancel') {
ElMessage.error('清空失败')
}
}
}
const checkout = async () => {
if (selectedCount.value === 0) {
ElMessage.error('请选择要结算的商品')
return
}
try {
const cartData = {
items: selectedItems.value.map(item => ({
productId: item.productId || item.id,
quantity: item.quantity,
points: item.points,
name: item.name,
image: item.image,
categoryId: item.categoryId,
sizeId: item.sizeId
}))
}
const response = await api.post('/cart/checkout', cartData)
if (response.data.success) {
const cartId = response.data.data.cartId
// 跳转到支付页面
router.push({
path: '/pay',
query: {
cartId: cartId
}
})
} else {
throw new Error(response.data.message || '创建订单失败')
}
} catch (error) {
ElMessage.error(error.message || '结算失败,请重试')
}
}
// 生命周期
onMounted(() => {
loadCartData()
})
</script>
<style scoped>
.cart-page {
min-height: 100vh;
background-color: #f5f5f5;
display: flex;
flex-direction: column;
}
.navbar {
background: white;
padding: 10px 15px;
display: flex;
align-items: center;
justify-content: space-between;
box-shadow: 0 2px 4px rgba(0,0,0,0.1);
position: sticky;
top: 0;
z-index: 100;
}
.nav-title {
font-size: 18px;
font-weight: 600;
margin: 0;
}
.back-btn, .clear-btn {
color: #666;
font-size: 16px;
}
.clear-btn {
color: #ff4757;
}
.page-content {
flex: 1;
padding: 10px;
}
.empty-cart {
text-align: center;
padding: 80px 20px;
background: white;
border-radius: 8px;
}
.empty-icon {
color: #ddd;
margin-bottom: 20px;
}
.empty-text {
font-size: 18px;
color: #666;
margin-bottom: 8px;
}
.empty-desc {
color: #999;
margin-bottom: 30px;
}
.go-shop-btn {
padding: 12px 30px;
}
.cart-content {
background: white;
border-radius: 8px;
overflow: hidden;
}
.cart-header {
padding: 15px;
border-bottom: 1px solid #f0f0f0;
display: flex;
align-items: center;
justify-content: space-between;
}
.select-all-checkbox {
font-weight: 500;
}
.item-count {
color: #666;
font-size: 14px;
}
.cart-items {
max-height: calc(100vh - 300px);
overflow-y: auto;
}
.cart-item {
display: flex;
align-items: center;
padding: 15px;
border-bottom: 1px solid #f0f0f0;
}
.cart-item:last-child {
border-bottom: none;
}
.item-checkbox {
margin-right: 12px;
}
.item-image {
width: 80px;
height: 80px;
margin-right: 12px;
border-radius: 6px;
overflow: hidden;
}
.item-image img {
width: 100%;
height: 100%;
object-fit: cover;
}
.item-info {
flex: 1;
margin-right: 12px;
}
.item-name {
font-size: 16px;
font-weight: 500;
margin-bottom: 8px;
line-height: 1.4;
}
.item-details {
display: flex;
gap: 10px;
margin-bottom: 8px;
}
.item-category, .item-size {
font-size: 12px;
color: #666;
background: #f5f5f5;
padding: 2px 6px;
border-radius: 3px;
}
.item-price {
display: flex;
align-items: center;
color: #ff6b35;
font-weight: 600;
}
.coin-icon {
margin-right: 4px;
}
.price-value {
font-size: 16px;
}
.item-actions {
display: flex;
flex-direction: column;
align-items: center;
gap: 10px;
}
.quantity-controls {
display: flex;
align-items: center;
gap: 8px;
}
.quantity-btn {
width: 28px;
height: 28px;
padding: 0;
border-radius: 50%;
}
.quantity {
min-width: 30px;
text-align: center;
font-weight: 500;
}
.remove-btn {
color: #ff4757;
font-size: 12px;
}
.bottom-bar {
background: white;
padding: 15px;
display: flex;
align-items: center;
justify-content: space-between;
box-shadow: 0 -2px 4px rgba(0,0,0,0.1);
}
.total-info {
display: flex;
flex-direction: column;
gap: 4px;
}
.selected-count {
font-size: 12px;
color: #666;
}
.total-price {
display: flex;
align-items: center;
font-size: 16px;
font-weight: 600;
}
.total-label {
color: #333;
margin-right: 4px;
}
.total-value {
color: #ff6b35;
font-size: 18px;
}
.checkout-btn {
padding: 12px 24px;
font-size: 16px;
font-weight: 600;
}
</style>

View File

@@ -146,8 +146,8 @@ export default {
{text:'隐私协议'},
]);
const functionItems = ref([
{ image: "/imgs/mainpage/交易记录.png", text: "购物车", path: "" },
{ image: "/imgs/mainpage/订单查询.png", text: "地址", path: "" },
{ image: "/imgs/mainpage/交易记录.png", text: "购物车", path: "/cart" },
{ image: "/imgs/mainpage/订单查询.png", text: "地址", path: "/address" },
{ image: "/imgs/mainpage/客服中心.png", text: "收藏", path: "" }
]);

View File

@@ -64,6 +64,36 @@
</div>
</div>
<!-- 商品列表 -->
<div class="items-section" v-if="paymentData.items && paymentData.items.length > 0">
<h3 class="section-title">商品清单 ({{ paymentData.items.length }})</h3>
<div class="items-list">
<div
v-for="item in paymentData.items"
:key="item.id || item.productId"
class="item-card"
>
<div class="item-image">
<img :src="item.image || '/imgs/productdetail/商品主图.png'" :alt="item.name" />
</div>
<div class="item-info">
<div class="item-name">{{ item.name }}</div>
<div class="item-details">
<span v-if="item.category" class="item-category">{{ item.category }}</span>
<span v-if="item.size" class="item-size">{{ item.size }}</span>
</div>
<div class="item-price">
<el-icon><Coin /></el-icon>
<span>{{ item.points || item.price }}</span>
</div>
</div>
<div class="item-quantity">
<span class="quantity-label">x{{ item.quantity }}</span>
</div>
</div>
</div>
</div>
<!-- 支付方式选择 -->
<div class="payment-method-section">
<h3 class="section-title">支付方式</h3>
@@ -162,7 +192,8 @@ const paymentData = ref({
totalAmount: 0,
pointsAmount: 0,
beansAmount: 0,
cartId: null
cartId: null,
items: [] // 添加商品列表
})
// 计算属性
@@ -201,7 +232,8 @@ const fetchPaymentData = async () => {
totalAmount: 0,
pointsAmount: 0,
beansAmount: 0,
cartId: null
cartId: null,
items: []
}
timeLeft.value = 900 // 默认15分钟
startCountdown()
@@ -218,7 +250,8 @@ const fetchPaymentData = async () => {
totalAmount: data.totalAmount || 0,
pointsAmount: data.pointsAmount || 0,
beansAmount: data.beansAmount || 0,
cartId: cartId
cartId: cartId,
items: data.items || [] // 获取商品列表
}
// 设置倒计时时间(从后端获取,单位:秒)
@@ -245,8 +278,8 @@ const handleGoBack = async () => {
'确认要放弃付款吗?',
'提示',
{
confirmButtonText: '狠心离开',
cancelButtonText: '继续付款',
confirmButtonText: '确认',
cancelButtonText: '取消',
type: 'warning'
}
)
@@ -447,6 +480,7 @@ onUnmounted(() => {
}
.amount-section,
.items-section,
.payment-method-section {
background: white;
padding: 16px;
@@ -460,6 +494,85 @@ onUnmounted(() => {
color: #333;
}
.items-list {
display: flex;
flex-direction: column;
gap: 12px;
}
.item-card {
display: flex;
align-items: center;
gap: 12px;
padding: 12px;
border: 1px solid #eee;
border-radius: 8px;
background: #fafafa;
}
.item-image {
width: 60px;
height: 60px;
border-radius: 6px;
overflow: hidden;
flex-shrink: 0;
}
.item-image img {
width: 100%;
height: 100%;
object-fit: cover;
}
.item-info {
flex: 1;
min-width: 0;
}
.item-name {
font-size: 14px;
font-weight: 500;
color: #333;
margin-bottom: 4px;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.item-details {
display: flex;
gap: 8px;
margin-bottom: 6px;
}
.item-category,
.item-size {
font-size: 12px;
color: #666;
background: #f0f0f0;
padding: 2px 6px;
border-radius: 4px;
}
.item-price {
display: flex;
align-items: center;
gap: 4px;
font-size: 14px;
color: #ffae00;
font-weight: 500;
}
.item-quantity {
flex-shrink: 0;
}
.quantity-label {
font-size: 14px;
color: #666;
font-weight: 500;
}
.amount-display {
text-align: center;
padding: 20px 0;

View File

@@ -125,17 +125,31 @@
<!-- 商品描述 -->
<div class="product-description">
<h3>商品描述</h3>
<h3 class="section-title">
商品描述
</h3>
<div v-if="showDescription" class="section-content">
<p>{{ product.description }}</p>
</div>
<div v-else class="section-placeholder">
<span class="placeholder-text" @click="toggleDescription">详情</span>
</div>
</div>
<!-- 商品详情 -->
<div class="product-details">
<h3>商品详情</h3>
<h3 class="section-title">
商品详情
</h3>
<div v-if="showDetails" class="section-content">
<div class="detail-content">
<p>{{ product.description || '暂无详细描述' }}</p>
</div>
</div>
<div v-else class="section-placeholder">
<span class="placeholder-text" @click="toggleDetails">详情</span>
</div>
</div>
<!-- 购买选项 -->
<div class="purchase-options">
@@ -261,10 +275,15 @@
<div v-else class="cart-items">
<div class="cart-header">
<span> {{ cartTotalItems }} 件商品</span>
<div class="cart-actions">
<el-button type="text" @click="goToCartPage" class="manage-btn">
管理
</el-button>
<el-button type="text" @click="clearCart" class="clear-btn">
清空购物车
</el-button>
</div>
</div>
<div class="cart-list">
<div
@@ -378,6 +397,10 @@ const cartLoading = ref(false)
const userPoints = ref(0)
const cartItems = ref([])
const cartCount = ref(0)
const showDescription = ref(false)
const showDetails = ref(false)
const selectedCategory = ref(null)
const selectedSize = ref(null)
// 计算属性
const totalPoints = computed(() => {
@@ -432,7 +455,7 @@ const getProductDetail = async () => {
}
}
const addToCart = () => {
const addToCart = async () => {
if (!product.value) {
ElMessage.error('商品信息加载中,请稍后再试')
return
@@ -443,7 +466,10 @@ const addToCart = () => {
return
}
// 跳转到BuyDetails页面进行确认订单
try {
// 检查是否已选择必要的商品属性
if (!selectedCategory.value || !selectedSize.value) {
// 如果没有选择属性跳转到BuyDetails页面进行详细配置
router.push({
path: '/buydetail',
query: {
@@ -451,6 +477,40 @@ const addToCart = () => {
quantity: quantity.value
}
})
return
}
// 构建购物车商品数据
const cartItem = {
productId: product.value.id,
quantity: quantity.value,
categoryId: selectedCategory.value.id,
sizeId: selectedSize.value.id,
points: product.value.points,
name: product.value.name,
image: product.value.images?.[0] || product.value.image,
stock: product.value.stock
}
// 添加到购物车
const response = await api.post('/cart/add', cartItem)
if (response.data.success) {
ElMessage.success('商品已加入购物车!')
// 更新本地购物车数据
await loadCartFromBackend()
// 重置选择状态
quantity.value = 1
selectedCategory.value = null
selectedSize.value = null
} else {
throw new Error(response.data.message || '添加到购物车失败')
}
} catch (error) {
ElMessage.error(error.message || '添加到购物车失败,请重试')
}
}
// 购物车商品管理方法
@@ -540,6 +600,12 @@ const loadCartFromBackend = async () => {
}
}
// 跳转到购物车管理页面
const goToCartPage = () => {
showCart.value = false
router.push('/cart')
}
// 购物车结算功能
const checkoutCart = async () => {
if (cartItems.value.length === 0) {
@@ -547,43 +613,40 @@ const checkoutCart = async () => {
return
}
if (cartTotalPoints.value > userPoints.value) {
ElMessage.error('积分不足,无法结算')
return
}
try {
await ElMessageBox.confirm(
`确定要花费 ${cartTotalPoints.value} 积分购买这些商品吗?`,
'确认结算',
{
confirmButtonText: '确定',
cancelButtonText: '取消',
type: 'warning'
}
)
const orderData = {
// 创建购物车结算请求
const cartData = {
items: cartItems.value.map(item => ({
productId: item.id,
productId: item.id || item.productId,
quantity: item.quantity,
points: item.points
})),
totalPoints: cartTotalPoints.value
points: item.points,
name: item.name,
image: item.image,
categoryId: item.categoryId,
sizeId: item.sizeId
}))
}
await api.post('/orders', orderData)
const response = await api.post('/cart/checkout', cartData)
// 清空购物车
cartItems.value = []
if (response.data.success) {
const cartId = response.data.data.cartId
// 跳转到支付页面
router.push({
path: '/pay',
query: {
cartId: cartId
}
})
// 关闭购物车弹窗
showCart.value = false
ElMessage.success('结算成功!')
router.push('/orders')
} catch (error) {
if (error !== 'cancel') {
ElMessage.error('结算失败,请重试')
} else {
throw new Error(response.data.message || '创建订单失败')
}
} catch (error) {
ElMessage.error(error.message || '结算失败,请重试')
}
}
@@ -652,6 +715,14 @@ const getUserPoints = async () => {
}
}
const toggleDescription = () => {
showDescription.value = !showDescription.value
}
const toggleDetails = () => {
showDetails.value = !showDetails.value
}
// 生命周期
onMounted(() => {
//getProductDetail()
@@ -881,19 +952,52 @@ watch(
margin-bottom: 20px;
}
.product-description h3,
.product-details h3 {
.section-title {
margin: 0 0 12px 0;
font-size: 16px;
color: #333;
padding: 8px 0;
border-bottom: 1px solid #eee;
}
.product-description p {
.section-content {
padding: 12px 0;
animation: slideDown 0.3s ease;
}
.section-content p {
margin: 0;
line-height: 1.6;
color: #666;
}
.section-placeholder {
padding: 12px 0;
}
.placeholder-text {
color: #999;
font-size: 14px;
font-style: italic;
cursor: pointer;
transition: color 0.3s ease;
}
.placeholder-text:hover {
color: #409eff;
}
@keyframes slideDown {
from {
opacity: 0;
max-height: 0;
}
to {
opacity: 1;
max-height: 200px;
}
}
.detail-item {
display: flex;
padding: 8px 0;
@@ -1172,6 +1276,21 @@ watch(
color: #666;
}
.cart-actions {
display: flex;
align-items: center;
gap: 10px;
}
.manage-btn {
color: #409eff;
font-size: 14px;
}
.manage-btn:hover {
color: #66b1ff;
}
.clear-btn {
color: #ff4757;
font-size: 12px;