基本实现了订单、购物车、商品等模块,商城v1.0
This commit is contained in:
25
src/apis/order.ts
Normal file
25
src/apis/order.ts
Normal file
@@ -0,0 +1,25 @@
|
||||
import http from "../utils/http";
|
||||
import type {
|
||||
// OrderListItem,
|
||||
OrderDetail,
|
||||
OrderPageParams,
|
||||
OrderPageResponse,
|
||||
} from "@/types/order";
|
||||
|
||||
// 获取订单列表
|
||||
export const getOrderList = (params: OrderPageParams) => {
|
||||
return http<OrderPageResponse>({
|
||||
url: "/order/page",
|
||||
method: "get",
|
||||
params,
|
||||
});
|
||||
};
|
||||
|
||||
// 获取订单详情
|
||||
export const getOrderDetail = (id: number, userId: number) => {
|
||||
return http<OrderDetail>({
|
||||
url: `/order/${id}`,
|
||||
method: "get",
|
||||
params: { userId },
|
||||
});
|
||||
};
|
||||
@@ -1,14 +1,41 @@
|
||||
import http from "../utils/http";
|
||||
import type {
|
||||
// ProductListItem,
|
||||
ProductCategory,
|
||||
ProductDetail,
|
||||
ProductPageParams,
|
||||
ProductPageResponse,
|
||||
} from "@/types/product";
|
||||
|
||||
export const addProduct = (data: {
|
||||
name: string;
|
||||
price: number;
|
||||
description: string;
|
||||
image: string;
|
||||
}) => {
|
||||
return http({
|
||||
url: "/product",
|
||||
method: "post",
|
||||
data,
|
||||
// 获取商品列表
|
||||
export const getProductList = (params: ProductPageParams) => {
|
||||
return http<ProductPageResponse>({
|
||||
url: "/product/page",
|
||||
method: "get",
|
||||
params,
|
||||
});
|
||||
};
|
||||
|
||||
// 获取商品分类
|
||||
export const getProductCategories = () => {
|
||||
return http<ProductCategory[]>({
|
||||
url: "/product/category",
|
||||
method: "get",
|
||||
});
|
||||
};
|
||||
|
||||
// 获取子分类
|
||||
export const getProductSubCategories = (id: number) => {
|
||||
return http<ProductCategory[]>({
|
||||
url: `/product/category/${id}`,
|
||||
method: "get",
|
||||
});
|
||||
};
|
||||
|
||||
// 获取商品详情
|
||||
export const getProductDetail = (id: number) => {
|
||||
return http<ProductDetail>({
|
||||
url: `/product/${id}`,
|
||||
method: "get",
|
||||
});
|
||||
};
|
||||
|
||||
47
src/apis/shoppingcart.ts
Normal file
47
src/apis/shoppingcart.ts
Normal file
@@ -0,0 +1,47 @@
|
||||
import http from "../utils/http";
|
||||
import type { ShoppingCartItem, ShoppingCartDTO } from "@/types/shoppingcart";
|
||||
|
||||
// 获取用户购物车列表
|
||||
export const getUserCartList = (userId: number) => {
|
||||
return http<ShoppingCartItem[]>({
|
||||
url: "/cart",
|
||||
method: "get",
|
||||
params: { userId },
|
||||
});
|
||||
};
|
||||
|
||||
// 添加商品到购物车
|
||||
export const addCart = (data: ShoppingCartDTO) => {
|
||||
return http({
|
||||
url: "/cart",
|
||||
method: "post",
|
||||
data,
|
||||
});
|
||||
};
|
||||
|
||||
// 更新购物车商品数量
|
||||
export const updateCartCount = (data: ShoppingCartDTO) => {
|
||||
return http({
|
||||
url: "/cart/count",
|
||||
method: "put",
|
||||
data,
|
||||
});
|
||||
};
|
||||
|
||||
// 更新购物车商品勾选状态
|
||||
export const updateCartChecked = (data: ShoppingCartDTO) => {
|
||||
return http({
|
||||
url: "/cart/checked",
|
||||
method: "put",
|
||||
data,
|
||||
});
|
||||
};
|
||||
|
||||
// 删除购物车商品
|
||||
export const deleteCart = (id: number, userId: number) => {
|
||||
return http({
|
||||
url: `/cart/${id}`,
|
||||
method: "delete",
|
||||
params: { userId },
|
||||
});
|
||||
};
|
||||
@@ -66,16 +66,19 @@ const goToProfile = () => {
|
||||
<a
|
||||
href="#"
|
||||
class="text-gray-600 hover:text-blue-600 transition-colors"
|
||||
>商品分类</a
|
||||
@click.prevent="$router.push('/product/product')"
|
||||
>购买商品</a
|
||||
>
|
||||
<a
|
||||
href="#"
|
||||
class="text-gray-600 hover:text-blue-600 transition-colors"
|
||||
@click.prevent="$router.push('/shoppingcart/content')"
|
||||
>购物车</a
|
||||
>
|
||||
<a
|
||||
href="#"
|
||||
class="text-gray-600 hover:text-blue-600 transition-colors"
|
||||
@click.prevent="$router.push('/order/content')"
|
||||
>我的订单</a
|
||||
>
|
||||
<a
|
||||
@@ -149,11 +152,13 @@ const goToProfile = () => {
|
||||
<a
|
||||
href="#"
|
||||
class="py-2 text-gray-600 hover:text-blue-600 transition-colors"
|
||||
>商品分类</a
|
||||
@click.prevent="$router.push('/product/product')"
|
||||
>购买商品</a
|
||||
>
|
||||
<a
|
||||
href="#"
|
||||
class="py-2 text-gray-600 hover:text-blue-600 transition-colors"
|
||||
@click.prevent="$router.push('/shoppingcart/content')"
|
||||
>购物车</a
|
||||
>
|
||||
<a
|
||||
|
||||
334
src/pages/order/ContentPage.vue
Normal file
334
src/pages/order/ContentPage.vue
Normal file
@@ -0,0 +1,334 @@
|
||||
<template>
|
||||
<MainLayout>
|
||||
<div class="order-page">
|
||||
<el-card class="order-card">
|
||||
<template #header>
|
||||
<div class="order-header">
|
||||
<span>我的订单</span>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<div v-if="orderList.length === 0" class="empty-orders">
|
||||
<el-empty description="暂无订单" />
|
||||
</div>
|
||||
|
||||
<div v-else>
|
||||
<!-- 订单列表 -->
|
||||
<div class="order-list">
|
||||
<el-table :data="orderList" style="width: 100%" row-key="id">
|
||||
<el-table-column prop="orderNo" label="订单号" width="300" />
|
||||
<el-table-column prop="createTime" label="下单时间" width="200">
|
||||
<template #default="scope">
|
||||
{{ formatDate(scope.row.createTime) }}
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column
|
||||
prop="productCount"
|
||||
label="商品数量"
|
||||
width="120"
|
||||
/>
|
||||
<el-table-column prop="totalAmount" label="订单总额" width="138">
|
||||
<template #default="scope">
|
||||
¥{{ scope.row.totalAmount.toFixed(2) }}
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column prop="statusText" label="订单状态" width="120" />
|
||||
<el-table-column label="操作" width="150">
|
||||
<template #default="scope">
|
||||
<el-button
|
||||
type="primary"
|
||||
link
|
||||
@click="viewOrderDetail(scope.row)"
|
||||
>
|
||||
查看详情
|
||||
</el-button>
|
||||
</template>
|
||||
</el-table-column>
|
||||
</el-table>
|
||||
</div>
|
||||
|
||||
<!-- 分页 -->
|
||||
<div class="pagination-container">
|
||||
<el-pagination
|
||||
v-model:current-page="currentPage"
|
||||
v-model:page-size="pageSize"
|
||||
:page-sizes="[5, 10, 20, 50]"
|
||||
:total="total"
|
||||
layout="total, sizes, prev, pager, next, jumper"
|
||||
@size-change="handleSizeChange"
|
||||
@current-change="handleCurrentChange"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</el-card>
|
||||
</div>
|
||||
|
||||
<!-- 订单详情模态框 -->
|
||||
<el-dialog
|
||||
v-model="dialogVisible"
|
||||
title="订单详情"
|
||||
width="900px"
|
||||
class="order-detail-dialog"
|
||||
>
|
||||
<div v-if="currentOrder" class="order-detail">
|
||||
<h3 class="section-title">订单信息</h3>
|
||||
<el-descriptions :column="2" border>
|
||||
<el-descriptions-item label="订单号">
|
||||
{{ currentOrder.orderNo }}
|
||||
</el-descriptions-item>
|
||||
<el-descriptions-item label="下单时间">
|
||||
{{ formatDate(currentOrder.createTime) }}
|
||||
</el-descriptions-item>
|
||||
<el-descriptions-item label="订单状态">
|
||||
{{ getOrderStatusText(currentOrder.status) }}
|
||||
</el-descriptions-item>
|
||||
<el-descriptions-item label="支付方式">
|
||||
{{ getPayTypeText(currentOrder.payType) }}
|
||||
</el-descriptions-item>
|
||||
<el-descriptions-item label="订单总额">
|
||||
¥{{ currentOrder.totalAmount.toFixed(2) }}
|
||||
</el-descriptions-item>
|
||||
<el-descriptions-item label="实付金额">
|
||||
¥{{ currentOrder.payAmount.toFixed(2) }}
|
||||
</el-descriptions-item>
|
||||
</el-descriptions>
|
||||
|
||||
<h3 class="section-title">收货信息</h3>
|
||||
<el-descriptions :column="1" border>
|
||||
<el-descriptions-item label="收货人">
|
||||
{{ currentOrder.receiverName }}
|
||||
</el-descriptions-item>
|
||||
<el-descriptions-item label="联系电话">
|
||||
{{ currentOrder.receiverPhone }}
|
||||
</el-descriptions-item>
|
||||
<el-descriptions-item label="收货地址">
|
||||
{{ currentOrder.receiverAddress }}
|
||||
</el-descriptions-item>
|
||||
</el-descriptions>
|
||||
|
||||
<h3 class="section-title">商品信息</h3>
|
||||
<div class="order-items">
|
||||
<el-table :data="currentOrder.orderItems" style="width: 100%">
|
||||
<el-table-column prop="productImage" label="商品" width="300">
|
||||
<template #default="scope">
|
||||
<div class="product-info">
|
||||
<el-image
|
||||
:src="scope.row.productImage"
|
||||
class="product-image"
|
||||
fit="cover"
|
||||
/>
|
||||
<div class="product-details">
|
||||
<div class="product-name">
|
||||
{{ scope.row.productName }}
|
||||
</div>
|
||||
<div class="product-sku">{{ scope.row.skuName }}</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
</el-table-column>
|
||||
|
||||
<el-table-column prop="price" label="单价" width="168">
|
||||
<template #default="scope">
|
||||
¥{{ scope.row.price.toFixed(2) }}
|
||||
</template>
|
||||
</el-table-column>
|
||||
|
||||
<el-table-column prop="quantity" label="数量" width="200" />
|
||||
|
||||
<el-table-column prop="totalPrice" label="小计" width="200">
|
||||
<template #default="scope">
|
||||
¥{{ scope.row.totalPrice.toFixed(2) }}
|
||||
</template>
|
||||
</el-table-column>
|
||||
</el-table>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<template #footer>
|
||||
<span class="dialog-footer">
|
||||
<el-button @click="dialogVisible = false">关闭</el-button>
|
||||
</span>
|
||||
</template>
|
||||
</el-dialog>
|
||||
</MainLayout>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import MainLayout from "@/layouts/MainLayout.vue";
|
||||
import { ref, onMounted } from "vue";
|
||||
import { useUserStore } from "@/stores/UserStore";
|
||||
import { ElMessage } from "element-plus";
|
||||
import { getOrderList, getOrderDetail } from "@/apis/order";
|
||||
import type { OrderListItem, OrderDetail } from "@/types/order";
|
||||
|
||||
const userStore = useUserStore();
|
||||
|
||||
// 订单列表相关
|
||||
const orderList = ref<OrderListItem[]>([]);
|
||||
const currentPage = ref(1);
|
||||
const pageSize = ref(10);
|
||||
const total = ref(0);
|
||||
|
||||
// 订单详情相关
|
||||
const dialogVisible = ref(false);
|
||||
const currentOrder = ref<OrderDetail | null>(null);
|
||||
|
||||
// 获取订单列表
|
||||
const loadOrderList = async () => {
|
||||
if (!userStore.userInfo) {
|
||||
ElMessage.warning("请先登录");
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const res = await getOrderList({
|
||||
page: currentPage.value,
|
||||
size: pageSize.value,
|
||||
userId: userStore.userInfo.id,
|
||||
});
|
||||
|
||||
if (res.code === 200) {
|
||||
orderList.value = res.data.list;
|
||||
total.value = res.data.total;
|
||||
} else {
|
||||
ElMessage.error(res.message || "获取订单列表失败");
|
||||
}
|
||||
} catch (err) {
|
||||
console.error(err);
|
||||
ElMessage.error("获取订单列表失败");
|
||||
}
|
||||
};
|
||||
|
||||
// 处理页面大小变化
|
||||
const handleSizeChange = (val: number) => {
|
||||
pageSize.value = val;
|
||||
currentPage.value = 1;
|
||||
loadOrderList();
|
||||
};
|
||||
|
||||
// 处理页码变化
|
||||
const handleCurrentChange = (val: number) => {
|
||||
currentPage.value = val;
|
||||
loadOrderList();
|
||||
};
|
||||
|
||||
// 查看订单详情
|
||||
const viewOrderDetail = async (order: OrderListItem) => {
|
||||
if (!userStore.userInfo) {
|
||||
ElMessage.warning("请先登录");
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const res = await getOrderDetail(order.id, userStore.userInfo.id);
|
||||
if (res.code === 200) {
|
||||
currentOrder.value = res.data;
|
||||
dialogVisible.value = true;
|
||||
} else {
|
||||
ElMessage.error(res.message || "获取订单详情失败");
|
||||
}
|
||||
} catch (err) {
|
||||
console.error(err);
|
||||
ElMessage.error("获取订单详情失败");
|
||||
}
|
||||
};
|
||||
|
||||
// 格式化日期
|
||||
const formatDate = (dateString: string) => {
|
||||
const date = new Date(dateString);
|
||||
return date.toLocaleDateString() + " " + date.toLocaleTimeString();
|
||||
};
|
||||
|
||||
// 获取订单状态文本
|
||||
const getOrderStatusText = (status: number) => {
|
||||
const statusMap: Record<number, string> = {
|
||||
0: "待付款",
|
||||
1: "待发货",
|
||||
2: "待收货",
|
||||
3: "已完成",
|
||||
4: "已取消",
|
||||
};
|
||||
return statusMap[status] || "未知状态";
|
||||
};
|
||||
|
||||
// 获取支付方式文本
|
||||
const getPayTypeText = (payType: number) => {
|
||||
const payTypeMap: Record<number, string> = {
|
||||
0: "未支付",
|
||||
1: "微信",
|
||||
2: "支付宝",
|
||||
};
|
||||
return payTypeMap[payType] || "未知";
|
||||
};
|
||||
|
||||
onMounted(() => {
|
||||
loadOrderList();
|
||||
});
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.order-page {
|
||||
padding: 20px;
|
||||
max-width: 1200px;
|
||||
margin: 0 auto;
|
||||
}
|
||||
|
||||
.order-header {
|
||||
font-size: 18px;
|
||||
font-weight: bold;
|
||||
}
|
||||
|
||||
.empty-orders {
|
||||
text-align: center;
|
||||
padding: 40px 0;
|
||||
}
|
||||
|
||||
.pagination-container {
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
margin-top: 20px;
|
||||
}
|
||||
|
||||
.section-title {
|
||||
margin: 20px 0 10px 0;
|
||||
font-size: 16px;
|
||||
font-weight: bold;
|
||||
}
|
||||
|
||||
.dialog-footer {
|
||||
display: flex;
|
||||
justify-content: flex-end;
|
||||
margin-top: 20px;
|
||||
}
|
||||
|
||||
/* 商品信息样式仿照购物车 */
|
||||
.product-info {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 15px;
|
||||
}
|
||||
|
||||
.product-image {
|
||||
width: 80px;
|
||||
height: 80px;
|
||||
border-radius: 4px;
|
||||
}
|
||||
|
||||
.product-details {
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.product-name {
|
||||
font-weight: bold;
|
||||
margin-bottom: 5px;
|
||||
}
|
||||
|
||||
.product-sku {
|
||||
color: #999;
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.order-items {
|
||||
margin-top: 20px;
|
||||
}
|
||||
</style>
|
||||
383
src/pages/product/DetailPage.vue
Normal file
383
src/pages/product/DetailPage.vue
Normal file
@@ -0,0 +1,383 @@
|
||||
<script lang="ts" setup>
|
||||
import { ref, onMounted } from "vue";
|
||||
import { useRoute, useRouter } from "vue-router";
|
||||
import MainLayout from "@/layouts/MainLayout.vue";
|
||||
import { useUserStore } from "@/stores/UserStore.ts";
|
||||
import {
|
||||
ElCard,
|
||||
ElRow,
|
||||
ElCol,
|
||||
ElImage,
|
||||
ElButton,
|
||||
// ElBreadcrumb,
|
||||
// ElBreadcrumbItem,
|
||||
ElCarousel,
|
||||
ElCarouselItem,
|
||||
ElDivider,
|
||||
ElIcon,
|
||||
ElMessage,
|
||||
} from "element-plus";
|
||||
import { ArrowLeft, Plus, Minus } from "@element-plus/icons-vue";
|
||||
import { getProductDetail } from "@/apis/product";
|
||||
import type { ProductDetail, ProductSku } from "@/types/product";
|
||||
import { addCart } from "@/apis/shoppingcart.ts";
|
||||
|
||||
const route = useRoute();
|
||||
const router = useRouter();
|
||||
|
||||
// 商品详情数据
|
||||
const product = ref<ProductDetail | null>(null);
|
||||
const loading = ref(true);
|
||||
|
||||
// 购买相关
|
||||
const quantity = ref(1);
|
||||
const selectedSku = ref<ProductSku | null>(null);
|
||||
|
||||
//获取用户信息
|
||||
const userStore = useUserStore();
|
||||
|
||||
// 获取商品详情
|
||||
const fetchProductDetail = async () => {
|
||||
const productId = route.params.id as string;
|
||||
if (!productId) {
|
||||
ElMessage.error("商品ID无效");
|
||||
router.push("/product/product");
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const response = await getProductDetail(Number(productId));
|
||||
if (response.code === 200) {
|
||||
product.value = response.data;
|
||||
|
||||
// 默认选择第一个SKU
|
||||
if (response.data.skuList && response.data.skuList.length > 0) {
|
||||
selectedSku.value = response.data.skuList[0];
|
||||
}
|
||||
} else {
|
||||
ElMessage.error(response.message || "获取商品详情失败");
|
||||
router.push("/product");
|
||||
}
|
||||
} catch (error) {
|
||||
ElMessage.error("获取商品详情失败");
|
||||
console.error("获取商品详情失败:", error);
|
||||
router.push("/product");
|
||||
} finally {
|
||||
loading.value = false;
|
||||
}
|
||||
};
|
||||
|
||||
// 增加数量
|
||||
const increaseQuantity = () => {
|
||||
if (
|
||||
product.value &&
|
||||
quantity.value < (selectedSku.value?.stock || product.value.stock || 0)
|
||||
) {
|
||||
quantity.value++;
|
||||
}
|
||||
};
|
||||
|
||||
// 减少数量
|
||||
const decreaseQuantity = () => {
|
||||
if (quantity.value > 1) {
|
||||
quantity.value--;
|
||||
}
|
||||
};
|
||||
|
||||
// 添加到购物车
|
||||
const addToCart = async () => {
|
||||
if (!product.value) {
|
||||
ElMessage.warning("商品信息加载中...");
|
||||
return;
|
||||
}
|
||||
|
||||
if (!selectedSku.value) {
|
||||
ElMessage.warning("请选择商品规格");
|
||||
return;
|
||||
}
|
||||
|
||||
// 检查用户是否已登录
|
||||
if (!userStore.userInfo) {
|
||||
ElMessage.warning("请先登录");
|
||||
router.push("/user/login");
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const response = await addCart({
|
||||
userId: userStore.userInfo.id,
|
||||
productId: product.value.id,
|
||||
skuId: selectedSku.value.id,
|
||||
count: quantity.value,
|
||||
});
|
||||
|
||||
if (response.code === 200) {
|
||||
ElMessage.success(`已将 ${product.value.name} 添加到购物车`);
|
||||
} else {
|
||||
ElMessage.error(response.message || "添加到购物车失败");
|
||||
}
|
||||
} catch (error) {
|
||||
ElMessage.error("添加到购物车失败");
|
||||
console.error("添加到购物车失败:", error);
|
||||
}
|
||||
};
|
||||
|
||||
// 立即购买
|
||||
const buyNow = () => {
|
||||
if (!product.value) {
|
||||
ElMessage.warning("商品信息加载中...");
|
||||
return;
|
||||
}
|
||||
|
||||
if (!selectedSku.value) {
|
||||
ElMessage.warning("请选择商品规格");
|
||||
return;
|
||||
}
|
||||
|
||||
// 这里应该是跳转到订单确认页面
|
||||
// 暂时用提示代替
|
||||
ElMessage.success("立即购买功能待实现");
|
||||
console.log("立即购买:", {
|
||||
productId: product.value.id,
|
||||
skuId: selectedSku.value.id,
|
||||
quantity: quantity.value,
|
||||
});
|
||||
};
|
||||
|
||||
// 选择SKU
|
||||
const selectSku = (sku: ProductSku) => {
|
||||
selectedSku.value = sku;
|
||||
};
|
||||
|
||||
// 返回商品列表
|
||||
const goBack = () => {
|
||||
router.push("/product");
|
||||
};
|
||||
|
||||
onMounted(() => {
|
||||
fetchProductDetail();
|
||||
});
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<MainLayout>
|
||||
<div class="product-detail-page">
|
||||
<div class="container mx-auto px-4 py-8">
|
||||
<!-- 返回按钮 -->
|
||||
<div class="mb-6">
|
||||
<el-button @click="goBack" type="primary" link>
|
||||
<el-icon><ArrowLeft /></el-icon>
|
||||
返回商品列表
|
||||
</el-button>
|
||||
</div>
|
||||
|
||||
<el-card v-loading="loading" class="product-card">
|
||||
<div v-if="product">
|
||||
<el-row :gutter="30">
|
||||
<!-- 商品图片 -->
|
||||
<el-col :span="24" :md="12">
|
||||
<div class="product-images">
|
||||
<!-- 主图轮播 -->
|
||||
<el-carousel
|
||||
height="400px"
|
||||
indicator-position="outside"
|
||||
arrow="always"
|
||||
>
|
||||
<el-carousel-item v-if="product.mainImage">
|
||||
<el-image
|
||||
:src="product.mainImage"
|
||||
class="w-full h-full object-contain"
|
||||
fit="contain"
|
||||
/>
|
||||
</el-carousel-item>
|
||||
<el-carousel-item
|
||||
v-for="(image, index) in product.subImages?.split(',')"
|
||||
:key="index"
|
||||
v-show="image"
|
||||
>
|
||||
<el-image
|
||||
:src="image"
|
||||
class="w-full h-full object-contain"
|
||||
fit="contain"
|
||||
/>
|
||||
</el-carousel-item>
|
||||
</el-carousel>
|
||||
</div>
|
||||
</el-col>
|
||||
|
||||
<!-- 商品信息 -->
|
||||
<el-col :span="24" :md="12">
|
||||
<div class="product-info">
|
||||
<h1 class="product-title text-2xl font-bold mb-4">
|
||||
{{ product.name }}
|
||||
</h1>
|
||||
<p class="product-subtitle text-gray-600 mb-6">
|
||||
{{ product.subtitle }}
|
||||
</p>
|
||||
|
||||
<div class="price-section mb-6">
|
||||
<div class="text-3xl text-red-600 font-bold">
|
||||
¥{{ selectedSku?.price || product.price }}
|
||||
</div>
|
||||
<div class="text-gray-500 line-through mt-1">
|
||||
原价: ¥{{ product.price }}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<el-divider />
|
||||
|
||||
<!-- SKU选择 -->
|
||||
<div
|
||||
v-if="product.skuList && product.skuList.length > 0"
|
||||
class="mb-6"
|
||||
>
|
||||
<div class="mb-3">
|
||||
<span class="font-medium">规格:</span>
|
||||
</div>
|
||||
<div class="flex flex-wrap gap-3">
|
||||
<el-button
|
||||
v-for="sku in product.skuList"
|
||||
:key="sku.id"
|
||||
:type="selectedSku?.id === sku.id ? 'primary' : 'info'"
|
||||
plain
|
||||
@click="selectSku(sku)"
|
||||
>
|
||||
{{ sku.skuName }}
|
||||
</el-button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 数量选择 -->
|
||||
<div class="quantity-section mb-6">
|
||||
<div class="mb-3">
|
||||
<span class="font-medium">数量:</span>
|
||||
</div>
|
||||
<div class="flex items-center">
|
||||
<el-button
|
||||
:disabled="quantity <= 1"
|
||||
:icon="Minus"
|
||||
@click="decreaseQuantity"
|
||||
/>
|
||||
<div class="mx-3 w-12 text-center">{{ quantity }}</div>
|
||||
<el-button
|
||||
:disabled="
|
||||
quantity >= (selectedSku?.stock || product.stock || 0)
|
||||
"
|
||||
:icon="Plus"
|
||||
@click="increaseQuantity"
|
||||
/>
|
||||
<div class="ml-4 text-gray-500">
|
||||
剩余库存: {{ selectedSku?.stock || product.stock }}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 操作按钮 -->
|
||||
<div class="action-buttons flex flex-wrap gap-4">
|
||||
<el-button
|
||||
type="primary"
|
||||
size="large"
|
||||
@click="addToCart"
|
||||
class="flex-1 min-w-[150px]"
|
||||
>
|
||||
加入购物车
|
||||
</el-button>
|
||||
<el-button
|
||||
type="danger"
|
||||
size="large"
|
||||
@click="buyNow"
|
||||
class="flex-1 min-w-[150px]"
|
||||
>
|
||||
立即购买
|
||||
</el-button>
|
||||
</div>
|
||||
</div>
|
||||
</el-col>
|
||||
</el-row>
|
||||
|
||||
<!-- 商品详情 -->
|
||||
<el-divider class="my-8" />
|
||||
|
||||
<div class="product-description">
|
||||
<h2 class="text-xl font-bold mb-4">商品详情</h2>
|
||||
<div class="description-content p-4 bg-gray-50 rounded">
|
||||
<div
|
||||
v-html="product.description || '暂无商品详情'"
|
||||
class="prose max-w-none"
|
||||
></div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div v-else-if="!loading" class="text-center py-12">
|
||||
<div class="text-gray-500">商品不存在或加载失败</div>
|
||||
<el-button @click="goBack" type="primary" class="mt-4"
|
||||
>返回商品列表</el-button
|
||||
>
|
||||
</div>
|
||||
</el-card>
|
||||
</div>
|
||||
</div>
|
||||
</MainLayout>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.product-detail-page {
|
||||
min-height: 100vh;
|
||||
background-color: #f5f7fa;
|
||||
}
|
||||
|
||||
.product-card {
|
||||
border-radius: 12px;
|
||||
box-shadow: 0 2px 12px 0 rgba(0, 0, 0, 0.1);
|
||||
}
|
||||
|
||||
.product-title {
|
||||
color: #333;
|
||||
}
|
||||
|
||||
.product-subtitle {
|
||||
font-size: 16px;
|
||||
}
|
||||
|
||||
.price-section {
|
||||
padding: 15px;
|
||||
background: #fff8f8;
|
||||
border-radius: 8px;
|
||||
}
|
||||
|
||||
.quantity-section {
|
||||
padding: 15px;
|
||||
background: #f9f9f9;
|
||||
border-radius: 8px;
|
||||
}
|
||||
|
||||
.action-buttons {
|
||||
margin-top: 30px;
|
||||
}
|
||||
|
||||
:deep(.el-carousel__arrow) {
|
||||
background-color: rgba(255, 255, 255, 0.8);
|
||||
color: #333;
|
||||
}
|
||||
|
||||
:deep(.el-carousel__arrow:hover) {
|
||||
background-color: rgba(255, 255, 255, 0.9);
|
||||
}
|
||||
|
||||
:deep(.el-carousel__indicator:hover button) {
|
||||
background-color: #409eff;
|
||||
}
|
||||
|
||||
:deep(.el-carousel__indicator.is-active button) {
|
||||
background-color: #409eff;
|
||||
}
|
||||
|
||||
:deep(.el-divider--horizontal) {
|
||||
margin: 20px 0;
|
||||
}
|
||||
|
||||
.description-content {
|
||||
min-height: 200px;
|
||||
}
|
||||
</style>
|
||||
596
src/pages/product/ProductPage.vue
Normal file
596
src/pages/product/ProductPage.vue
Normal file
@@ -0,0 +1,596 @@
|
||||
<script lang="ts" setup>
|
||||
import { ref, onMounted } from "vue";
|
||||
import MainLayout from "@/layouts/MainLayout.vue";
|
||||
import {
|
||||
ElCard,
|
||||
ElRow,
|
||||
ElCol,
|
||||
ElPagination,
|
||||
ElImage,
|
||||
ElEmpty,
|
||||
ElSkeleton,
|
||||
ElBreadcrumb,
|
||||
ElBreadcrumbItem,
|
||||
} from "element-plus";
|
||||
import { Search } from "@element-plus/icons-vue";
|
||||
import {
|
||||
getProductList,
|
||||
getProductCategories,
|
||||
getProductSubCategories,
|
||||
} from "@/apis/product";
|
||||
import type { ProductListItem, ProductCategory } from "@/types/product";
|
||||
|
||||
// 商品列表相关
|
||||
const productList = ref<ProductListItem[]>([]);
|
||||
const loading = ref(false);
|
||||
const currentPage = ref(1);
|
||||
const pageSize = ref(12);
|
||||
const total = ref(0);
|
||||
|
||||
// 分类相关
|
||||
const categories = ref<ProductCategory[]>([]);
|
||||
const activeCategoryId = ref<number | null>(null);
|
||||
const expandedCategories = ref<number[]>([]); // 展开的分类ID列表
|
||||
const breadcrumbCategories = ref<ProductCategory[]>([]); // 面包屑分类路径
|
||||
const subCategoriesMap = ref<{ [key: number]: ProductCategory[] }>({}); // 子分类映射
|
||||
|
||||
// 搜索关键词
|
||||
const searchKeyword = ref("");
|
||||
|
||||
// 获取商品列表
|
||||
const fetchProductList = async () => {
|
||||
loading.value = true;
|
||||
try {
|
||||
const response = await getProductList({
|
||||
page: currentPage.value,
|
||||
size: pageSize.value,
|
||||
categoryId: activeCategoryId.value || undefined,
|
||||
name: searchKeyword.value || undefined,
|
||||
});
|
||||
|
||||
if (response.code === 200) {
|
||||
productList.value = response.data.list;
|
||||
total.value = response.data.total;
|
||||
} else {
|
||||
productList.value = [];
|
||||
total.value = 0;
|
||||
}
|
||||
} catch (err) {
|
||||
console.error("获取商品列表失败:", err);
|
||||
productList.value = [];
|
||||
total.value = 0;
|
||||
} finally {
|
||||
loading.value = false;
|
||||
}
|
||||
};
|
||||
|
||||
// 获取商品分类
|
||||
const fetchCategories = async () => {
|
||||
try {
|
||||
const response = await getProductCategories();
|
||||
if (response.code === 200) {
|
||||
categories.value = response.data;
|
||||
}
|
||||
} catch (err) {
|
||||
console.error("获取商品分类失败:", err);
|
||||
}
|
||||
};
|
||||
|
||||
// 获取子分类
|
||||
const fetchSubCategories = async (parentId: number) => {
|
||||
// 如果已经获取过,直接返回
|
||||
if (subCategoriesMap.value[parentId]) {
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const response = await getProductSubCategories(parentId);
|
||||
if (response.code === 200) {
|
||||
subCategoriesMap.value[parentId] = response.data;
|
||||
}
|
||||
} catch (err) {
|
||||
console.error("获取子分类失败:", err);
|
||||
}
|
||||
};
|
||||
|
||||
// 切换分类展开/收起状态
|
||||
const toggleCategory = async (category: ProductCategory) => {
|
||||
// 设置为当前激活的分类
|
||||
activeCategoryId.value = category.id;
|
||||
|
||||
// 更新面包屑路径
|
||||
await updateBreadcrumb(category);
|
||||
|
||||
// 检查并加载子分类
|
||||
const hasChildren = await checkAndLoadChildren(category.id);
|
||||
|
||||
// 如果是顶级分类(parentId为0)
|
||||
if (category.parentId === 0) {
|
||||
if (hasChildren) {
|
||||
// 如果有子分类
|
||||
if (expandedCategories.value.includes(category.id)) {
|
||||
// 如果已经展开,则收起
|
||||
expandedCategories.value = expandedCategories.value.filter(
|
||||
(id) => id !== category.id,
|
||||
);
|
||||
} else {
|
||||
// 收起所有顶级分类的展开状态
|
||||
const topLevelCategoryIds = categories.value.map((cat) => cat.id);
|
||||
expandedCategories.value = expandedCategories.value.filter(
|
||||
(id) => !topLevelCategoryIds.includes(id),
|
||||
);
|
||||
// 展开当前分类
|
||||
expandedCategories.value = [...expandedCategories.value, category.id];
|
||||
}
|
||||
} else {
|
||||
// 如果没有子分类,收起所有已展开的分类
|
||||
expandedCategories.value = [];
|
||||
}
|
||||
} else {
|
||||
// 如果是非顶级分类,保持原有逻辑
|
||||
if (hasChildren) {
|
||||
if (expandedCategories.value.includes(category.id)) {
|
||||
// 如果已经展开,则收起
|
||||
expandedCategories.value = expandedCategories.value.filter(
|
||||
(id) => id !== category.id,
|
||||
);
|
||||
} else {
|
||||
// 如果未展开,则展开
|
||||
expandedCategories.value = [...expandedCategories.value, category.id];
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 重新加载商品列表
|
||||
currentPage.value = 1;
|
||||
fetchProductList();
|
||||
};
|
||||
|
||||
// 检查并加载子分类
|
||||
const checkAndLoadChildren = async (categoryId: number): Promise<boolean> => {
|
||||
// 如果还没有加载过该分类的子分类,则加载
|
||||
if (!subCategoriesMap.value[categoryId]) {
|
||||
await fetchSubCategories(categoryId);
|
||||
}
|
||||
|
||||
// 返回是否有子分类
|
||||
return (
|
||||
subCategoriesMap.value[categoryId] &&
|
||||
subCategoriesMap.value[categoryId].length > 0
|
||||
);
|
||||
};
|
||||
|
||||
// 更新面包屑路径
|
||||
const updateBreadcrumb = async (category: ProductCategory) => {
|
||||
const path: ProductCategory[] = [category];
|
||||
let currentCategory = category;
|
||||
|
||||
// 向上查找父级分类
|
||||
while (currentCategory.parentId !== 0) {
|
||||
// 查找父级分类
|
||||
const parentCategory = findCategoryById(currentCategory.parentId);
|
||||
if (parentCategory) {
|
||||
path.unshift(parentCategory);
|
||||
currentCategory = parentCategory;
|
||||
} else {
|
||||
// 如果找不到父级分类,则尝试从服务器获取
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
breadcrumbCategories.value = path;
|
||||
};
|
||||
|
||||
// 根据ID查找分类
|
||||
const findCategoryById = (id: number): ProductCategory | null => {
|
||||
// 先在主分类中查找
|
||||
for (const category of categories.value) {
|
||||
if (category.id === id) {
|
||||
return category;
|
||||
}
|
||||
|
||||
// 在已加载的子分类中查找
|
||||
for (const subCategories of Object.values(subCategoriesMap.value)) {
|
||||
const found = subCategories.find((cat) => cat.id === id);
|
||||
if (found) {
|
||||
return found;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
};
|
||||
|
||||
// 获取指定分类的子分类
|
||||
const getSubCategories = (parentId: number): ProductCategory[] => {
|
||||
return subCategoriesMap.value[parentId] || [];
|
||||
};
|
||||
|
||||
// 处理面包屑点击
|
||||
const handleBreadcrumbClick = (category: ProductCategory | null) => {
|
||||
if (category) {
|
||||
toggleCategory(category);
|
||||
} else {
|
||||
// 点击"全部商品"
|
||||
activeCategoryId.value = null;
|
||||
expandedCategories.value = [];
|
||||
breadcrumbCategories.value = [];
|
||||
currentPage.value = 1;
|
||||
fetchProductList();
|
||||
}
|
||||
};
|
||||
|
||||
// 处理分页变化
|
||||
const handlePageChange = (page: number) => {
|
||||
currentPage.value = page;
|
||||
fetchProductList();
|
||||
};
|
||||
|
||||
// 处理页面大小变化
|
||||
const handleSizeChange = (size: number) => {
|
||||
pageSize.value = size;
|
||||
currentPage.value = 1;
|
||||
fetchProductList();
|
||||
};
|
||||
|
||||
// 处理搜索
|
||||
const handleSearch = () => {
|
||||
currentPage.value = 1;
|
||||
fetchProductList();
|
||||
};
|
||||
|
||||
onMounted(() => {
|
||||
fetchProductList();
|
||||
fetchCategories();
|
||||
});
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<MainLayout>
|
||||
<div class="product-page">
|
||||
<div class="container mx-auto px-4 py-8">
|
||||
<!-- 页面标题 -->
|
||||
<div class="page-header mb-8">
|
||||
<h1 class="text-3xl font-bold text-gray-800">商品列表</h1>
|
||||
<p class="text-gray-600 mt-2">发现你喜欢的商品</p>
|
||||
</div>
|
||||
|
||||
<!-- 面包屑导航 -->
|
||||
<div class="breadcrumb mb-6">
|
||||
<el-breadcrumb separator="/">
|
||||
<el-breadcrumb-item>
|
||||
<a
|
||||
@click="handleBreadcrumbClick(null)"
|
||||
class="cursor-pointer hover:text-blue-600"
|
||||
>
|
||||
全部商品
|
||||
</a>
|
||||
</el-breadcrumb-item>
|
||||
<el-breadcrumb-item
|
||||
v-for="category in breadcrumbCategories"
|
||||
:key="category.id"
|
||||
>
|
||||
<a
|
||||
@click="handleBreadcrumbClick(category)"
|
||||
class="cursor-pointer hover:text-blue-600"
|
||||
>
|
||||
{{ category.name }}
|
||||
</a>
|
||||
</el-breadcrumb-item>
|
||||
</el-breadcrumb>
|
||||
</div>
|
||||
|
||||
<!-- 搜索栏 -->
|
||||
<div class="search-bar mb-8">
|
||||
<div class="flex">
|
||||
<el-input
|
||||
v-model="searchKeyword"
|
||||
placeholder="搜索商品..."
|
||||
class="flex-1"
|
||||
@keyup.enter="handleSearch"
|
||||
>
|
||||
<template #append>
|
||||
<el-button :icon="Search" @click="handleSearch" />
|
||||
</template>
|
||||
</el-input>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 商品分类 -->
|
||||
<div class="category-section mb-8">
|
||||
<el-card class="category-card">
|
||||
<template #header>
|
||||
<div class="flex justify-between items-center">
|
||||
<span class="font-bold">商品分类</span>
|
||||
</div>
|
||||
</template>
|
||||
<div class="category-container">
|
||||
<!-- 一级分类 -->
|
||||
<div class="category-level">
|
||||
<div
|
||||
v-for="category in categories"
|
||||
:key="category.id"
|
||||
class="category-item"
|
||||
:class="{ active: activeCategoryId === category.id }"
|
||||
@click="toggleCategory(category)"
|
||||
>
|
||||
<span>{{ category.name }}</span>
|
||||
<i
|
||||
v-if="getSubCategories(category.id).length > 0"
|
||||
:class="[
|
||||
'el-icon-arrow-down',
|
||||
'transition-transform',
|
||||
{
|
||||
'rotate-180': expandedCategories.includes(category.id),
|
||||
},
|
||||
]"
|
||||
></i>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 二级分类 -->
|
||||
<template v-for="category in categories" :key="category.id">
|
||||
<div
|
||||
v-if="
|
||||
expandedCategories.includes(category.id) &&
|
||||
getSubCategories(category.id).length > 0
|
||||
"
|
||||
class="category-level ml-4"
|
||||
>
|
||||
<div
|
||||
v-for="subCategory in getSubCategories(category.id)"
|
||||
:key="subCategory.id"
|
||||
class="category-item"
|
||||
:class="{ active: activeCategoryId === subCategory.id }"
|
||||
@click="toggleCategory(subCategory)"
|
||||
>
|
||||
<span>{{ subCategory.name }}</span>
|
||||
<i
|
||||
v-if="getSubCategories(subCategory.id).length > 0"
|
||||
:class="[
|
||||
'el-icon-arrow-down',
|
||||
'transition-transform',
|
||||
{
|
||||
'rotate-180': expandedCategories.includes(
|
||||
subCategory.id,
|
||||
),
|
||||
},
|
||||
]"
|
||||
></i>
|
||||
</div>
|
||||
|
||||
<!-- 三级分类 -->
|
||||
<template
|
||||
v-for="subCategory in getSubCategories(category.id)"
|
||||
:key="subCategory.id"
|
||||
>
|
||||
<div
|
||||
v-if="
|
||||
expandedCategories.includes(subCategory.id) &&
|
||||
getSubCategories(subCategory.id).length > 0
|
||||
"
|
||||
class="category-level ml-4"
|
||||
>
|
||||
<div
|
||||
v-for="subSubCategory in getSubCategories(
|
||||
subCategory.id,
|
||||
)"
|
||||
:key="subSubCategory.id"
|
||||
class="category-item"
|
||||
:class="{
|
||||
active: activeCategoryId === subSubCategory.id,
|
||||
}"
|
||||
@click="toggleCategory(subSubCategory)"
|
||||
>
|
||||
<span>{{ subSubCategory.name }}</span>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
</div>
|
||||
</template>
|
||||
</div>
|
||||
</el-card>
|
||||
</div>
|
||||
|
||||
<el-row :gutter="20">
|
||||
<!-- 左侧占位(如果需要放置其他内容) -->
|
||||
<el-col :span="6" class="hidden md:block">
|
||||
<!-- 可以放置其他侧边栏内容 -->
|
||||
</el-col>
|
||||
|
||||
<!-- 右侧商品列表 -->
|
||||
<el-col :span="24" class="md:col-span-24">
|
||||
<!-- 商品列表 -->
|
||||
<div class="product-list">
|
||||
<el-row :gutter="20">
|
||||
<template v-if="loading">
|
||||
<el-col
|
||||
v-for="n in pageSize"
|
||||
:key="n"
|
||||
:span="24"
|
||||
class="sm:col-span-12 md:col-span-8 lg:col-span-6 mb-6"
|
||||
>
|
||||
<el-card class="product-card">
|
||||
<el-skeleton :rows="3" animated />
|
||||
</el-card>
|
||||
</el-col>
|
||||
</template>
|
||||
<template v-else-if="productList.length > 0">
|
||||
<el-col
|
||||
v-for="product in productList"
|
||||
:key="product.id"
|
||||
:span="24"
|
||||
class="sm:col-span-12 md:col-span-8 lg:col-span-6 mb-6"
|
||||
>
|
||||
<!-- 添加点击事件跳转到详情页 -->
|
||||
<el-card
|
||||
class="product-card h-full cursor-pointer"
|
||||
shadow="hover"
|
||||
@click="$router.push(`/product/detail/${product.id}`)"
|
||||
>
|
||||
<div class="product-image mb-3">
|
||||
<el-image
|
||||
:src="product.mainImage || '/placeholder.jpg'"
|
||||
class="w-full h-48 object-cover rounded"
|
||||
fit="cover"
|
||||
lazy
|
||||
>
|
||||
<template #error>
|
||||
<div class="image-slot">
|
||||
<i
|
||||
class="el-icon-picture-outline text-4xl text-gray-300"
|
||||
></i>
|
||||
</div>
|
||||
</template>
|
||||
</el-image>
|
||||
</div>
|
||||
<div class="product-info">
|
||||
<h3
|
||||
class="product-title text-sm font-medium mb-2 line-clamp-2 h-12"
|
||||
:title="product.name"
|
||||
>
|
||||
{{ product.name }}
|
||||
</h3>
|
||||
<div class="product-price text-red-600 font-bold mb-2">
|
||||
¥{{ product.price }}
|
||||
</div>
|
||||
<div class="product-sales text-gray-500 text-xs">
|
||||
销量: {{ product.sales }}
|
||||
</div>
|
||||
</div>
|
||||
</el-card>
|
||||
</el-col>
|
||||
</template>
|
||||
<template v-else>
|
||||
<el-col :span="24">
|
||||
<el-empty description="暂无商品数据" />
|
||||
</el-col>
|
||||
</template>
|
||||
</el-row>
|
||||
</div>
|
||||
|
||||
<!-- 分页 -->
|
||||
<div class="pagination-container mt-8 flex justify-center">
|
||||
<el-pagination
|
||||
v-model:current-page="currentPage"
|
||||
v-model:page-size="pageSize"
|
||||
:page-sizes="[12, 24, 36, 48]"
|
||||
:total="total"
|
||||
layout="total, sizes, prev, pager, next, jumper"
|
||||
@current-change="handlePageChange"
|
||||
@size-change="handleSizeChange"
|
||||
/>
|
||||
</div>
|
||||
</el-col>
|
||||
</el-row>
|
||||
</div>
|
||||
</div>
|
||||
</MainLayout>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.product-page {
|
||||
min-height: 100vh;
|
||||
background-color: #f5f7fa;
|
||||
}
|
||||
|
||||
.page-header {
|
||||
text-align: center;
|
||||
margin-bottom: 2rem;
|
||||
}
|
||||
|
||||
.category-card {
|
||||
border-radius: 12px;
|
||||
box-shadow: 0 2px 12px 0 rgba(0, 0, 0, 0.1);
|
||||
margin-bottom: 20px;
|
||||
}
|
||||
|
||||
.category-container {
|
||||
max-height: 500px;
|
||||
overflow-y: auto;
|
||||
}
|
||||
|
||||
.category-level {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 8px;
|
||||
margin-bottom: 12px;
|
||||
}
|
||||
|
||||
.category-item {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
padding: 6px 12px;
|
||||
background-color: #f5f7fa;
|
||||
border-radius: 16px;
|
||||
cursor: pointer;
|
||||
transition: all 0.3s;
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
.category-item:hover {
|
||||
background-color: #e1eaff;
|
||||
}
|
||||
|
||||
.category-item.active {
|
||||
background-color: #409eff;
|
||||
color: white;
|
||||
}
|
||||
|
||||
.category-item i {
|
||||
font-size: 12px;
|
||||
transition: transform 0.3s;
|
||||
}
|
||||
|
||||
.product-card {
|
||||
border-radius: 12px;
|
||||
overflow: hidden;
|
||||
transition: all 0.3s;
|
||||
}
|
||||
|
||||
.product-card:hover {
|
||||
transform: translateY(-4px);
|
||||
box-shadow: 0 10px 20px rgba(0, 0, 0, 0.1);
|
||||
}
|
||||
|
||||
.image-slot {
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
background: #f5f7fa;
|
||||
color: #909399;
|
||||
}
|
||||
|
||||
.line-clamp-2 {
|
||||
display: -webkit-box;
|
||||
-webkit-line-clamp: 2;
|
||||
-webkit-box-orient: vertical;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
:deep(.el-breadcrumb__inner a) {
|
||||
font-weight: normal;
|
||||
}
|
||||
|
||||
/* 滚动条样式 */
|
||||
.category-container::-webkit-scrollbar {
|
||||
width: 6px;
|
||||
}
|
||||
|
||||
.category-container::-webkit-scrollbar-track {
|
||||
background: #f1f1f1;
|
||||
border-radius: 10px;
|
||||
}
|
||||
|
||||
.category-container::-webkit-scrollbar-thumb {
|
||||
background: #c1c1c1;
|
||||
border-radius: 10px;
|
||||
}
|
||||
|
||||
.category-container::-webkit-scrollbar-thumb:hover {
|
||||
background: #a1a1a1;
|
||||
}
|
||||
</style>
|
||||
407
src/pages/shoppingcart/ContentPage.vue
Normal file
407
src/pages/shoppingcart/ContentPage.vue
Normal file
@@ -0,0 +1,407 @@
|
||||
<template>
|
||||
<MainLayout>
|
||||
<div class="shopping-cart-page">
|
||||
<el-card class="cart-card">
|
||||
<template #header>
|
||||
<div class="cart-header">
|
||||
<span>购物车</span>
|
||||
<el-button
|
||||
type="danger"
|
||||
:disabled="!hasSelectedItems"
|
||||
@click="handleClearSelected"
|
||||
>
|
||||
删除选中商品
|
||||
</el-button>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<div v-if="cartItems.length === 0" class="empty-cart">
|
||||
<el-empty description="购物车为空" />
|
||||
</div>
|
||||
|
||||
<div v-else>
|
||||
<!-- 全选栏 -->
|
||||
<div class="select-all">
|
||||
<el-checkbox v-model="selectAll" @change="handleSelectAllChange">
|
||||
全选
|
||||
</el-checkbox>
|
||||
<div class="cart-summary">
|
||||
<span>已选 {{ selectedCount }} 件商品</span>
|
||||
<span class="total-price"
|
||||
>合计:¥{{ totalPrice.toFixed(2) }}</span
|
||||
>
|
||||
<el-button type="primary" :disabled="selectedCount === 0">
|
||||
结算
|
||||
</el-button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 购物车商品列表 -->
|
||||
<div class="cart-items">
|
||||
<el-table :data="cartItems" style="width: 100%" row-key="id">
|
||||
<el-table-column width="50">
|
||||
<template #default="scope">
|
||||
<el-checkbox
|
||||
v-model="scope.row.checked"
|
||||
:true-value="1"
|
||||
:false-value="0"
|
||||
@change="
|
||||
(val: boolean | string | number) =>
|
||||
handleItemCheckChange(scope.row, val)
|
||||
"
|
||||
/>
|
||||
</template>
|
||||
</el-table-column>
|
||||
|
||||
<el-table-column prop="productImage" label="商品" width="300">
|
||||
<template #default="scope">
|
||||
<div class="product-info">
|
||||
<el-image
|
||||
:src="scope.row.productImage"
|
||||
class="product-image"
|
||||
fit="cover"
|
||||
/>
|
||||
<div class="product-details">
|
||||
<div class="product-name">
|
||||
{{ scope.row.productName }}
|
||||
</div>
|
||||
<div class="product-sku">{{ scope.row.skuName }}</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
</el-table-column>
|
||||
|
||||
<el-table-column prop="price" label="单价" width="168">
|
||||
<template #default="scope"> ¥{{ scope.row.price }} </template>
|
||||
</el-table-column>
|
||||
|
||||
<el-table-column prop="count" label="数量" width="200">
|
||||
<template #default="scope">
|
||||
<el-input-number
|
||||
v-model="scope.row.count"
|
||||
:min="1"
|
||||
:max="999"
|
||||
@change="
|
||||
(val: number | undefined) =>
|
||||
handleCountChange(scope.row, val)
|
||||
"
|
||||
/>
|
||||
</template>
|
||||
</el-table-column>
|
||||
|
||||
<el-table-column prop="subtotal" label="小计" width="200">
|
||||
<template #default="scope">
|
||||
¥{{ (scope.row.price * scope.row.count).toFixed(2) }}
|
||||
</template>
|
||||
</el-table-column>
|
||||
|
||||
<el-table-column label="操作" width="110">
|
||||
<template #default="scope">
|
||||
<el-button
|
||||
type="danger"
|
||||
link
|
||||
@click="handleDeleteItem(scope.row)"
|
||||
>
|
||||
删除
|
||||
</el-button>
|
||||
</template>
|
||||
</el-table-column>
|
||||
</el-table>
|
||||
</div>
|
||||
</div>
|
||||
</el-card>
|
||||
</div>
|
||||
</MainLayout>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import MainLayout from "@/layouts/MainLayout.vue";
|
||||
import { ref, computed, onMounted } from "vue";
|
||||
import { useUserStore } from "@/stores/UserStore";
|
||||
import {
|
||||
getUserCartList,
|
||||
updateCartCount,
|
||||
updateCartChecked,
|
||||
deleteCart,
|
||||
} from "@/apis/shoppingcart";
|
||||
import { ElMessage, ElMessageBox } from "element-plus";
|
||||
|
||||
interface CartItem {
|
||||
id: number;
|
||||
userId: number;
|
||||
productId: number;
|
||||
skuId: number;
|
||||
count: number;
|
||||
checked: number; // 0-未勾选 1-已勾选
|
||||
productName: string;
|
||||
productImage: string;
|
||||
skuName: string;
|
||||
price: number;
|
||||
specJson: string;
|
||||
}
|
||||
|
||||
const userStore = useUserStore();
|
||||
const cartItems = ref<CartItem[]>([]);
|
||||
|
||||
// 计算属性
|
||||
const selectAll = computed({
|
||||
get: () =>
|
||||
cartItems.value.length > 0 &&
|
||||
cartItems.value.every((item) => item.checked === 1),
|
||||
set: (val) => {
|
||||
cartItems.value.forEach((item) => {
|
||||
item.checked = val ? 1 : 0;
|
||||
});
|
||||
},
|
||||
});
|
||||
|
||||
const selectedItems = computed(() => {
|
||||
return cartItems.value.filter((item) => item.checked === 1);
|
||||
});
|
||||
|
||||
const hasSelectedItems = computed(() => {
|
||||
return selectedItems.value.length > 0;
|
||||
});
|
||||
|
||||
const selectedCount = computed(() => {
|
||||
return selectedItems.value.reduce((total, item) => total + item.count, 0);
|
||||
});
|
||||
|
||||
const totalPrice = computed(() => {
|
||||
return selectedItems.value.reduce(
|
||||
(total, item) => total + item.price * item.count,
|
||||
0,
|
||||
);
|
||||
});
|
||||
|
||||
// 获取购物车列表
|
||||
const loadCartItems = async () => {
|
||||
if (!userStore.userInfo) {
|
||||
ElMessage.warning("请先登录");
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const res = await getUserCartList(userStore.userInfo.id);
|
||||
if (res.code === 200) {
|
||||
cartItems.value = res.data.map((item) => ({
|
||||
...item,
|
||||
checked: 0, // 默认不选中
|
||||
}));
|
||||
} else {
|
||||
ElMessage.error(res.message || "获取购物车列表失败");
|
||||
}
|
||||
} catch (err) {
|
||||
console.error(err);
|
||||
ElMessage.error("获取购物车列表失败");
|
||||
}
|
||||
};
|
||||
|
||||
// 处理全选变化
|
||||
const handleSelectAllChange = async (val: boolean) => {
|
||||
if (!userStore.userInfo) return;
|
||||
|
||||
// 更新所有项目的选中状态
|
||||
for (const item of cartItems.value) {
|
||||
try {
|
||||
await updateCartChecked({
|
||||
id: item.id,
|
||||
userId: userStore.userInfo.id,
|
||||
productId: item.productId,
|
||||
skuId: item.skuId,
|
||||
checked: val ? 1 : 0,
|
||||
});
|
||||
item.checked = val ? 1 : 0;
|
||||
} catch (err) {
|
||||
console.error(err);
|
||||
ElMessage.error("更新选中状态失败");
|
||||
}
|
||||
}
|
||||
|
||||
ElMessage.success(val ? "已全选" : "已取消全选");
|
||||
};
|
||||
|
||||
// 处理单项选中变化
|
||||
const handleItemCheckChange = async (
|
||||
item: CartItem,
|
||||
val: boolean | string | number,
|
||||
) => {
|
||||
if (!userStore.userInfo) return;
|
||||
|
||||
try {
|
||||
await updateCartChecked({
|
||||
id: item.id,
|
||||
userId: userStore.userInfo.id,
|
||||
productId: item.productId,
|
||||
skuId: item.skuId,
|
||||
checked: val ? 1 : 0,
|
||||
});
|
||||
|
||||
item.checked = val ? 1 : 0;
|
||||
} catch (err) {
|
||||
console.error(err);
|
||||
ElMessage.error("更新选中状态失败");
|
||||
// 恢复原状态
|
||||
item.checked = !val ? 1 : 0;
|
||||
}
|
||||
};
|
||||
|
||||
// 处理数量变化
|
||||
const handleCountChange = async (item: CartItem, val: number | undefined) => {
|
||||
if (!userStore.userInfo || val === undefined) return;
|
||||
|
||||
const oldCount = item.count; // 保存原始数量
|
||||
try {
|
||||
await updateCartCount({
|
||||
id: item.id,
|
||||
userId: userStore.userInfo.id,
|
||||
productId: item.productId,
|
||||
skuId: item.skuId,
|
||||
count: val,
|
||||
});
|
||||
|
||||
item.count = val;
|
||||
ElMessage.success("数量更新成功");
|
||||
} catch (err) {
|
||||
console.error(err);
|
||||
ElMessage.error("更新数量失败");
|
||||
// 恢复原数量
|
||||
item.count = oldCount;
|
||||
}
|
||||
};
|
||||
|
||||
// 处理删除单个项目
|
||||
const handleDeleteItem = async (item: CartItem) => {
|
||||
if (!userStore.userInfo) return;
|
||||
|
||||
ElMessageBox.confirm("确定要删除该商品吗?", "提示", {
|
||||
type: "warning",
|
||||
})
|
||||
.then(async () => {
|
||||
try {
|
||||
const res = await deleteCart(item.id, userStore.userInfo!.id);
|
||||
if (res.code === 200) {
|
||||
ElMessage.success("删除成功");
|
||||
// 从列表中移除
|
||||
cartItems.value = cartItems.value.filter(
|
||||
(cartItem) => cartItem.id !== item.id,
|
||||
);
|
||||
} else {
|
||||
ElMessage.error(res.message || "删除失败");
|
||||
}
|
||||
} catch (err) {
|
||||
console.error(err);
|
||||
ElMessage.error("删除失败");
|
||||
}
|
||||
})
|
||||
.catch(() => {
|
||||
// 取消删除
|
||||
});
|
||||
};
|
||||
|
||||
// 处理清空选中项
|
||||
const handleClearSelected = async () => {
|
||||
if (!userStore.userInfo) return;
|
||||
|
||||
ElMessageBox.confirm(
|
||||
`确定要删除选中的 ${selectedCount.value} 件商品吗?`,
|
||||
"提示",
|
||||
{
|
||||
type: "warning",
|
||||
},
|
||||
)
|
||||
.then(async () => {
|
||||
try {
|
||||
// 删除所有选中项
|
||||
const deletePromises = selectedItems.value.map((item) =>
|
||||
deleteCart(item.id, userStore.userInfo!.id),
|
||||
);
|
||||
|
||||
await Promise.all(deletePromises);
|
||||
|
||||
ElMessage.success("删除成功");
|
||||
// 从列表中移除
|
||||
cartItems.value = cartItems.value.filter((item) => item.checked !== 1);
|
||||
} catch (err) {
|
||||
console.error(err);
|
||||
ElMessage.error("删除失败");
|
||||
}
|
||||
})
|
||||
.catch(() => {
|
||||
// 取消删除
|
||||
});
|
||||
};
|
||||
|
||||
onMounted(() => {
|
||||
loadCartItems();
|
||||
});
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.shopping-cart-page {
|
||||
padding: 20px;
|
||||
max-width: 1200px;
|
||||
margin: 0 auto;
|
||||
}
|
||||
|
||||
.cart-header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.empty-cart {
|
||||
text-align: center;
|
||||
padding: 40px 0;
|
||||
}
|
||||
|
||||
.select-all {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
padding: 15px 0;
|
||||
border-bottom: 1px solid #eee;
|
||||
}
|
||||
|
||||
.cart-summary {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 20px;
|
||||
}
|
||||
|
||||
.total-price {
|
||||
font-size: 18px;
|
||||
font-weight: bold;
|
||||
color: #ff4444;
|
||||
}
|
||||
|
||||
.product-info {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 15px;
|
||||
}
|
||||
|
||||
.product-image {
|
||||
width: 80px;
|
||||
height: 80px;
|
||||
border-radius: 4px;
|
||||
}
|
||||
|
||||
.product-details {
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.product-name {
|
||||
font-weight: bold;
|
||||
margin-bottom: 5px;
|
||||
}
|
||||
|
||||
.product-sku {
|
||||
color: #999;
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.cart-items {
|
||||
margin-top: 20px;
|
||||
}
|
||||
</style>
|
||||
@@ -30,10 +30,10 @@ function formatPathFromComponentName(name: string): string {
|
||||
* @param filePath Path to the component file
|
||||
* @returns Component name without extension
|
||||
*/
|
||||
function getComponentNameFromPath(filePath: string): string {
|
||||
// Get the file name from the path
|
||||
return filePath.split("/").pop() || "";
|
||||
}
|
||||
// function getComponentNameFromPath(filePath: string): string {
|
||||
// // Get the file name from the path
|
||||
// return filePath.split("/").pop() || "";
|
||||
// }
|
||||
|
||||
/**
|
||||
* Generate route path from full file path, preserving directory structure
|
||||
@@ -71,15 +71,17 @@ function generateRoutesFromPages(): RouteRecordRaw[] {
|
||||
const pages = import.meta.glob("@/pages/**/*.vue");
|
||||
|
||||
for (const path in pages) {
|
||||
const componentName = getComponentNameFromPath(path);
|
||||
// const componentName = getComponentNameFromPath(path);
|
||||
const routePath = generateRoutePathFromFilePath(path);
|
||||
|
||||
// Special case for home page
|
||||
const finalPath = routePath.toLowerCase() === "/home" ? "/" : routePath;
|
||||
const finalPath = routePath.toLowerCase().endsWith("/home")
|
||||
? routePath.slice(0, -5)
|
||||
: routePath;
|
||||
|
||||
routes.push({
|
||||
path: finalPath,
|
||||
name: componentName,
|
||||
name: routePath,
|
||||
component: pages[path],
|
||||
});
|
||||
}
|
||||
@@ -97,6 +99,11 @@ const extraRoutes: RouteRecordRaw[] = [
|
||||
name: "UserProfile",
|
||||
component: () => import("@/pages/user/ProfilePage.vue"),
|
||||
},
|
||||
{
|
||||
path: "/product/detail/:id",
|
||||
name: "ProductDetail",
|
||||
component: () => import("@/pages/product/DetailPage.vue"),
|
||||
},
|
||||
// 可以在这里添加其他需要特殊配置的路由
|
||||
];
|
||||
|
||||
|
||||
64
src/types/order.ts
Normal file
64
src/types/order.ts
Normal file
@@ -0,0 +1,64 @@
|
||||
// 订单列表项
|
||||
export interface OrderListItem {
|
||||
id: number;
|
||||
orderNo: string;
|
||||
totalAmount: number;
|
||||
payAmount: number;
|
||||
status: number;
|
||||
statusText: string;
|
||||
createTime: string;
|
||||
productCount: number;
|
||||
}
|
||||
|
||||
// 订单详情
|
||||
export interface OrderDetail {
|
||||
id: number;
|
||||
orderNo: string;
|
||||
userId: number;
|
||||
totalAmount: number;
|
||||
payAmount: number;
|
||||
freightAmount: number;
|
||||
payType: number;
|
||||
status: number;
|
||||
receiverName: string;
|
||||
receiverPhone: string;
|
||||
receiverAddress: string;
|
||||
payTime: string;
|
||||
deliveryTime: string;
|
||||
receiveTime: string;
|
||||
createTime: string;
|
||||
orderItems: OrderItem[];
|
||||
}
|
||||
|
||||
// 订单项
|
||||
export interface OrderItem {
|
||||
id: number;
|
||||
orderId: number;
|
||||
orderNo: string;
|
||||
productId: number;
|
||||
skuId: number;
|
||||
productName: string;
|
||||
skuName: string;
|
||||
productImage: string;
|
||||
price: number;
|
||||
quantity: number;
|
||||
totalPrice: number;
|
||||
createTime: string;
|
||||
updateTime: string;
|
||||
}
|
||||
|
||||
// 订单分页请求参数
|
||||
export interface OrderPageParams {
|
||||
page: number;
|
||||
size: number;
|
||||
userId: number;
|
||||
status?: number;
|
||||
}
|
||||
|
||||
// 订单分页响应数据
|
||||
export interface OrderPageResponse {
|
||||
list: OrderListItem[];
|
||||
total: number;
|
||||
page: number;
|
||||
size: number;
|
||||
}
|
||||
70
src/types/product.ts
Normal file
70
src/types/product.ts
Normal file
@@ -0,0 +1,70 @@
|
||||
// 商品列表项
|
||||
export interface ProductListItem {
|
||||
id: number;
|
||||
name: string;
|
||||
mainImage: string;
|
||||
price: number;
|
||||
sales: number;
|
||||
status: number;
|
||||
categoryName: string;
|
||||
}
|
||||
|
||||
// 商品详情
|
||||
export interface ProductDetail {
|
||||
id: number;
|
||||
categoryId: number;
|
||||
categoryName: string;
|
||||
name: string;
|
||||
subtitle: string;
|
||||
mainImage: string;
|
||||
subImages: string;
|
||||
description: string;
|
||||
price: number;
|
||||
stock: number;
|
||||
sales: number;
|
||||
status: number;
|
||||
sort: number;
|
||||
createTime: string;
|
||||
updateTime: string;
|
||||
skuList: ProductSku[];
|
||||
}
|
||||
|
||||
// 商品SKU
|
||||
export interface ProductSku {
|
||||
id: number;
|
||||
productId: number;
|
||||
skuName: string;
|
||||
skuCode: string;
|
||||
price: number;
|
||||
stock: number;
|
||||
specJson: string;
|
||||
}
|
||||
|
||||
// 商品分类
|
||||
export interface ProductCategory {
|
||||
id: number;
|
||||
parentId: number;
|
||||
name: string;
|
||||
sort: number;
|
||||
icon: string;
|
||||
level: number;
|
||||
createTime: string;
|
||||
updateTime: string;
|
||||
}
|
||||
|
||||
// 商品分页请求参数
|
||||
export interface ProductPageParams {
|
||||
page: number;
|
||||
size: number;
|
||||
categoryId?: number;
|
||||
name?: string;
|
||||
status?: number;
|
||||
}
|
||||
|
||||
// 商品分页响应数据
|
||||
export interface ProductPageResponse {
|
||||
list: ProductListItem[];
|
||||
total: number;
|
||||
page: number;
|
||||
size: number;
|
||||
}
|
||||
24
src/types/shoppingcart.ts
Normal file
24
src/types/shoppingcart.ts
Normal file
@@ -0,0 +1,24 @@
|
||||
// 购物车项目
|
||||
export interface ShoppingCartItem {
|
||||
id: number;
|
||||
userId: number;
|
||||
productId: number;
|
||||
skuId: number;
|
||||
count: number;
|
||||
checked: number; // 0-未勾选 1-已勾选
|
||||
productName: string;
|
||||
productImage: string;
|
||||
skuName: string;
|
||||
price: number;
|
||||
specJson: string;
|
||||
}
|
||||
|
||||
// 购物车DTO
|
||||
export interface ShoppingCartDTO {
|
||||
id?: number;
|
||||
userId: number;
|
||||
productId: number;
|
||||
skuId: number;
|
||||
count?: number;
|
||||
checked?: number;
|
||||
}
|
||||
Reference in New Issue
Block a user