Files
sale/frontend/src/views/user/Products.vue
T
admin 3a898e8aa0 fix: 全面修复项目问题
后端修复:
- 验证码存储与校验机制
- 订单创建事务+库存扣减
- GetStats字段名错误
- VerifyEmail改为POST
- 供应商更新字段白名单
- 文件删除安全检查
- 抽奖安全随机数
- 用户管理CRUD
- 订单取消/确认收货
- 工单回复
- Toggle返回新数据
- 移除死代码

前端修复:
- 404兜底路由
- 401软跳转
- API层统一
- 面包屑补充banners
- 国际化完善
- 购物车并行删除
- 退出清理购物车
- 供应商Dashboard数据
- 工单详情页
- 订单取消/确认收货
2026-05-06 09:55:01 +08:00

286 lines
7.0 KiB
Vue

<template>
<div class="products-page">
<div class="page-header">
<BackNav />
<div class="filter-bar">
<el-select v-model="categoryId" :placeholder="$t('product.filterByCategory')" popper-class="dark-select-dropdown" @change="fetchProducts">
<el-option label="全部分类" :value="undefined" />
<el-option v-for="c in categories" :key="c.id" :label="c.name" :value="c.id" />
</el-select>
<el-select v-model="brandId" placeholder="筛选品牌" popper-class="dark-select-dropdown" @change="fetchProducts">
<el-option label="全部品牌" :value="undefined" />
<el-option v-for="b in brands" :key="b.id" :label="b.name" :value="b.id" />
</el-select>
</div>
</div>
<div class="product-grid">
<div v-for="p in products" :key="p.id" class="product-card" @click="$router.push(`/products/${p.id}`)">
<div class="product-image">
<el-image v-if="getFirstImage(p.images)" :src="getFirstImage(p.images)" fit="contain" lazy>
<template #error>
<div class="image-placeholder">
<el-icon :size="40"><Picture /></el-icon>
</div>
</template>
</el-image>
<div v-else class="image-placeholder">
<el-icon :size="40"><Picture /></el-icon>
</div>
</div>
<div class="product-info">
<h3 class="product-name">{{ p.name }}</h3>
<div class="product-meta">
<span class="price">¥{{ p.price }}</span>
<el-tag v-if="p.brand" size="small" type="info">{{ p.brand?.name }}</el-tag>
</div>
<div class="product-tags">
<el-tag v-if="p.require_credit" size="small" type="warning">需要资格</el-tag>
<el-tag v-if="p.credit_reward > 0" size="small" type="success">奖励 +{{ p.credit_reward }}</el-tag>
<el-tag v-if="p.stock !== undefined && p.stock <= 10" size="small" type="danger">仅剩{{ p.stock }}</el-tag>
</div>
</div>
</div>
</div>
<el-empty v-if="!products.length" :description="$t('common.noData')" />
<div class="pagination-wrap" v-if="total > pageSize">
<el-pagination v-model:current-page="page" :page-size="pageSize" :total="total" layout="prev, pager, next" @current-change="fetchProducts" />
</div>
</div>
</template>
<script setup lang="ts">
import { ref, onMounted } from 'vue'
import { useRoute } from 'vue-router'
import { Picture } from '@element-plus/icons-vue'
import { productApi, categoryApi, brandApi } from '../../api'
import { getFirstImage } from '../../utils/image'
import BackNav from '../../components/BackNav.vue'
const route = useRoute()
const products = ref<any[]>([])
const categories = ref<any[]>([])
const brands = ref<any[]>([])
const categoryId = ref<number | undefined>(undefined)
const brandId = ref<number | undefined>(undefined)
const page = ref(1)
const pageSize = 20
const total = ref(0)
async function fetchProducts() {
const res: any = await productApi.list({
page: page.value,
page_size: pageSize,
category_id: categoryId.value,
brand_id: brandId.value,
})
products.value = res.data || []
total.value = res.pagination?.total || 0
}
onMounted(async () => {
if (route.query.category_id) categoryId.value = Number(route.query.category_id)
const [catRes, brandRes]: any[] = await Promise.all([
categoryApi.list(),
brandApi.list()
])
categories.value = catRes.data || []
brands.value = brandRes.data || []
await fetchProducts()
})
</script>
<style scoped lang="scss">
.products-page {
padding: 0;
}
.page-header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 24px;
:deep(.breadcrumb-nav) {
margin-bottom: 0;
}
}
.filter-bar {
display: flex;
gap: 12px;
}
.filter-bar :deep(.el-select) {
width: 160px;
}
.product-grid {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(220px, 1fr));
gap: 20px;
}
.product-card {
background: #2d2d44;
border-radius: 12px;
overflow: hidden;
cursor: pointer;
transition: all 0.25s ease;
border: 1px solid rgba(255, 255, 255, 0.06);
}
.product-card:hover {
transform: translateY(-4px);
background: #35355a;
border-color: rgba(78, 110, 242, 0.3);
box-shadow: 0 12px 32px rgba(0, 0, 0, 0.4);
}
.product-image {
aspect-ratio: 1;
background: rgba(255, 255, 255, 0.04);
display: flex;
align-items: center;
justify-content: center;
overflow: hidden;
position: relative;
.el-image {
width: 100%;
height: 100%;
img {
object-fit: contain;
background: rgba(255, 255, 255, 0.02);
}
}
.image-placeholder {
width: 100%;
height: 100%;
display: flex;
align-items: center;
justify-content: center;
background: rgba(255, 255, 255, 0.03);
color: rgba(255, 255, 255, 0.15);
}
}
.product-info {
padding: 16px;
display: flex;
flex-direction: column;
gap: 10px;
}
.product-name {
font-size: 14px;
font-weight: 500;
color: #fff;
margin: 0;
line-height: 1.4;
display: -webkit-box;
-webkit-line-clamp: 2;
-webkit-box-orient: vertical;
overflow: hidden;
min-height: 40px;
}
.product-meta {
display: flex;
justify-content: space-between;
align-items: center;
}
.price {
font-size: 18px;
font-weight: 700;
color: #4e6ef2;
}
.product-tags {
display: flex;
flex-wrap: wrap;
gap: 6px;
}
.pagination-wrap {
margin-top: 32px;
display: flex;
justify-content: center;
}
.pagination-wrap :deep(.el-pagination) {
--el-pagination-bg-color: rgba(255, 255, 255, 0.06);
--el-pagination-text-color: rgba(255, 255, 255, 0.6);
--el-pagination-button-disabled-bg-color: rgba(255, 255, 255, 0.04);
--el-pagination-button-bg-color: rgba(255, 255, 255, 0.06);
--el-pagination-hover-color: #4e6ef2;
}
.pagination-wrap :deep(.el-pagination .el-pager li) {
background: rgba(255, 255, 255, 0.06);
color: rgba(255, 255, 255, 0.6);
border-radius: 6px;
margin: 0 4px;
}
.pagination-wrap :deep(.el-pagination .el-pager li:hover),
.pagination-wrap :deep(.el-pagination .el-pager li.is-active) {
background: #4e6ef2;
color: #fff;
}
.pagination-wrap :deep(.el-pagination .btn-prev),
.pagination-wrap :deep(.el-pagination .btn-next) {
background: rgba(255, 255, 255, 0.06);
color: rgba(255, 255, 255, 0.6);
border-radius: 6px;
}
.pagination-wrap :deep(.el-pagination .btn-prev:hover),
.pagination-wrap :deep(.el-pagination .btn-next:hover) {
background: rgba(78, 110, 242, 0.2);
color: #4e6ef2;
}
@media (max-width: 768px) {
.page-header {
flex-direction: column;
align-items: flex-start;
gap: 12px;
}
.filter-bar {
width: 100%;
flex-wrap: wrap;
}
.filter-bar :deep(.el-select) {
flex: 1;
min-width: 140px;
}
.product-grid {
grid-template-columns: repeat(2, 1fr);
gap: 12px;
}
.product-info {
padding: 12px;
}
.product-name {
font-size: 13px;
min-height: 36px;
}
.price {
font-size: 16px;
}
}
</style>