基本实现了订单、购物车、商品等模块,商城v1.0

This commit is contained in:
puzvv
2025-12-20 20:41:30 +08:00
parent 57fe499080
commit bde3870243
12 changed files with 2008 additions and 19 deletions

25
src/apis/order.ts Normal file
View 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 },
});
};

View File

@@ -1,14 +1,41 @@
import http from "../utils/http"; import http from "../utils/http";
import type {
// ProductListItem,
ProductCategory,
ProductDetail,
ProductPageParams,
ProductPageResponse,
} from "@/types/product";
export const addProduct = (data: { // 获取商品列表
name: string; export const getProductList = (params: ProductPageParams) => {
price: number; return http<ProductPageResponse>({
description: string; url: "/product/page",
image: string; method: "get",
}) => { params,
return http({ });
url: "/product", };
method: "post",
data, // 获取商品分类
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
View 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 },
});
};

View File

@@ -66,16 +66,19 @@ const goToProfile = () => {
<a <a
href="#" href="#"
class="text-gray-600 hover:text-blue-600 transition-colors" class="text-gray-600 hover:text-blue-600 transition-colors"
>商品分类</a @click.prevent="$router.push('/product/product')"
>购买商品</a
> >
<a <a
href="#" href="#"
class="text-gray-600 hover:text-blue-600 transition-colors" class="text-gray-600 hover:text-blue-600 transition-colors"
@click.prevent="$router.push('/shoppingcart/content')"
>购物车</a >购物车</a
> >
<a <a
href="#" href="#"
class="text-gray-600 hover:text-blue-600 transition-colors" class="text-gray-600 hover:text-blue-600 transition-colors"
@click.prevent="$router.push('/order/content')"
>我的订单</a >我的订单</a
> >
<a <a
@@ -149,11 +152,13 @@ const goToProfile = () => {
<a <a
href="#" href="#"
class="py-2 text-gray-600 hover:text-blue-600 transition-colors" class="py-2 text-gray-600 hover:text-blue-600 transition-colors"
>商品分类</a @click.prevent="$router.push('/product/product')"
>购买商品</a
> >
<a <a
href="#" href="#"
class="py-2 text-gray-600 hover:text-blue-600 transition-colors" class="py-2 text-gray-600 hover:text-blue-600 transition-colors"
@click.prevent="$router.push('/shoppingcart/content')"
>购物车</a >购物车</a
> >
<a <a

View 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>

View 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>

View 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>

View 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>

View File

@@ -30,10 +30,10 @@ function formatPathFromComponentName(name: string): string {
* @param filePath Path to the component file * @param filePath Path to the component file
* @returns Component name without extension * @returns Component name without extension
*/ */
function getComponentNameFromPath(filePath: string): string { // function getComponentNameFromPath(filePath: string): string {
// Get the file name from the path // // Get the file name from the path
return filePath.split("/").pop() || ""; // return filePath.split("/").pop() || "";
} // }
/** /**
* Generate route path from full file path, preserving directory structure * Generate route path from full file path, preserving directory structure
@@ -71,15 +71,17 @@ function generateRoutesFromPages(): RouteRecordRaw[] {
const pages = import.meta.glob("@/pages/**/*.vue"); const pages = import.meta.glob("@/pages/**/*.vue");
for (const path in pages) { for (const path in pages) {
const componentName = getComponentNameFromPath(path); // const componentName = getComponentNameFromPath(path);
const routePath = generateRoutePathFromFilePath(path); const routePath = generateRoutePathFromFilePath(path);
// Special case for home page // Special case for home page
const finalPath = routePath.toLowerCase() === "/home" ? "/" : routePath; const finalPath = routePath.toLowerCase().endsWith("/home")
? routePath.slice(0, -5)
: routePath;
routes.push({ routes.push({
path: finalPath, path: finalPath,
name: componentName, name: routePath,
component: pages[path], component: pages[path],
}); });
} }
@@ -97,6 +99,11 @@ const extraRoutes: RouteRecordRaw[] = [
name: "UserProfile", name: "UserProfile",
component: () => import("@/pages/user/ProfilePage.vue"), 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
View 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
View 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
View 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;
}