Files
mengyastore/mengyastore-frontend/src/modules/store/StorePage.vue
2026-03-20 20:58:24 +08:00

412 lines
9.0 KiB
Vue
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<template>
<section class="page-card">
<div class="hero">
<div>
<h2>所有商品</h2>
<div class="filter-search-row">
<div class="filters">
<button
class="filter-btn"
:class="{ active: filter === 'all' }"
type="button"
@click="setFilter('all')"
>
全部
</button>
<button
class="filter-btn"
:class="{ active: filter === 'free' }"
type="button"
@click="setFilter('free')"
>
免费
</button>
</div>
<div class="search-row">
<input
v-model="searchQuery"
class="search-input"
type="text"
placeholder="搜索商品/标签"
/>
</div>
</div>
</div>
</div>
<div v-if="loading" class="status">加载中...</div>
<div v-else>
<div class="grid">
<div
v-for="item in pagedProducts"
:key="item.id"
:class="['product-link', { 'is-disabled': isSoldOut(item) }]"
@click="handleCardClick(item)"
>
<article class="product-card">
<div class="cover-wrap">
<img :src="item.coverUrl" :alt="item.name" />
<div v-if="isSoldOut(item)" class="soldout-badge">已售空</div>
</div>
<div class="card-top">
<h3 class="card-name">{{ item.name }}</h3>
<div class="card-price">
<span v-if="isFree(item)" class="product-price free-price">免费</span>
<span
v-else-if="item.discountPrice > 0 && item.discountPrice < item.price"
class="product-price"
>
<span class="price-original">¥ {{ item.price.toFixed(2) }}</span>
<span class="price-discount">¥ {{ item.discountPrice.toFixed(2) }}</span>
</span>
<span v-else class="product-price">¥ {{ item.price.toFixed(2) }}</span>
</div>
</div>
<div class="card-mid">
<div class="markdown" v-html="renderMarkdown(item.description)"></div>
</div>
<div class="card-bottom">
<div class="meta-row">
<div class="tag">库存{{ item.quantity }}</div>
<div class="tag">浏览量{{ item.viewCount || 0 }}</div>
</div>
<div v-if="item.tags && item.tags.length" class="tag-row">
<span v-for="tag in item.tags" :key="tag" class="tag-chip">
{{ tag }}
</span>
</div>
</div>
</article>
</div>
</div>
<div class="pagination" v-if="totalPages > 1">
<button class="ghost" :disabled="page === 1" @click="page--">上一页</button>
<span class="tag"> {{ page }} / {{ totalPages }} </span>
<button class="ghost" :disabled="page === totalPages" @click="page++">下一页</button>
</div>
</div>
</section>
</template>
<script setup>
import { computed, onMounted, onUnmounted, ref, watch } from 'vue'
import { useRouter } from 'vue-router'
import MarkdownIt from 'markdown-it'
import { fetchProducts } from '../shared/api'
const router = useRouter()
const products = ref([])
const loading = ref(true)
const md = new MarkdownIt()
const page = ref(1)
const perPage = ref(20)
const filter = ref('all')
const searchQuery = ref('')
const renderMarkdown = (content) => md.render(content || '')
const updatePerPage = () => {
perPage.value = window.innerWidth <= 900 ? 6 : 20
page.value = 1
}
const getPayPrice = (item) => {
if (!item) return 0
// 折扣规则:
// - 未填/无效折扣discountPrice <= 0 或 >= price => 不启用折扣
// - 只有当 discountPrice > 0 且小于原价,才用折扣价
// - 只有 price = 0实付价为 0才显示“免费”
if (item.price === 0) return 0
if (item.discountPrice > 0 && item.discountPrice < item.price) return item.discountPrice
return item.price
}
const isFree = (item) => getPayPrice(item) === 0
const matchesSearch = (item) => {
const q = (searchQuery.value || '').trim().toLowerCase()
if (!q) return true
const name = (item?.name || '').toLowerCase()
const tags = (item?.tags || []).join(',').toLowerCase()
return name.includes(q) || tags.includes(q)
}
const filteredProducts = computed(() => {
let list = products.value
if (filter.value === 'free') list = list.filter((p) => isFree(p))
if ((searchQuery.value || '').trim()) list = list.filter((p) => matchesSearch(p))
return list
})
const totalPages = computed(() =>
Math.max(1, Math.ceil(filteredProducts.value.length / perPage.value))
)
const pagedProducts = computed(() => {
const start = (page.value - 1) * perPage.value
return filteredProducts.value.slice(start, start + perPage.value)
})
const isSoldOut = (item) => item && item.quantity === 0
const handleCardClick = (item) => {
if (!item || isSoldOut(item)) {
return
}
router.push(`/product/${item.id}`)
}
const setFilter = (next) => {
filter.value = next
page.value = 1
}
onMounted(async () => {
updatePerPage()
window.addEventListener('resize', updatePerPage)
try {
products.value = await fetchProducts()
} finally {
loading.value = false
}
})
watch(filter, () => {
page.value = 1
})
watch(searchQuery, () => {
page.value = 1
})
onUnmounted(() => {
window.removeEventListener('resize', updatePerPage)
})
</script>
<style scoped>
.hero {
display: flex;
align-items: center;
justify-content: space-between;
margin-bottom: 20px;
}
.filters {
display: flex;
gap: 12px;
margin-top: 0;
flex-wrap: wrap;
}
.filter-search-row {
display: flex;
align-items: center;
justify-content: flex-start;
gap: 16px;
flex-wrap: wrap;
margin-top: 12px;
}
.search-row {
margin-top: 0;
}
.search-input {
width: 320px;
max-width: 100%;
border-radius: 999px;
border: 1px solid var(--line);
background: rgba(255, 255, 255, 0.7);
padding: 10px 14px;
font-family: 'Source Sans 3', sans-serif;
font-size: 14px;
outline: none;
}
.search-input:focus {
border-color: var(--accent);
box-shadow: 0 0 0 3px rgba(180, 154, 203, 0.18);
}
.tag-row {
display: flex;
flex-wrap: wrap;
gap: 8px;
margin-top: 8px;
}
.tag-chip {
padding: 4px 10px;
border-radius: 999px;
font-size: 12px;
font-weight: 700;
color: var(--accent-2);
background: rgba(145, 168, 208, 0.08);
border: 1px solid rgba(145, 168, 208, 0.18);
}
.filter-btn {
padding: 8px 14px;
border-radius: 999px;
border: 1px solid var(--line);
background: rgba(255, 255, 255, 0.6);
color: var(--text);
font-size: 13px;
font-weight: 700;
transition: background 0.2s ease, color 0.2s ease, transform 0.15s ease;
}
.filter-btn:hover {
transform: translateY(-1px);
}
.filter-btn.active {
background: linear-gradient(135deg, var(--accent), var(--accent-2));
border-color: transparent;
color: #fff;
box-shadow: 0 10px 30px rgba(145, 168, 208, 0.35);
}
.free-price {
color: #3a9a68;
font-weight: 900;
}
.product-link {
display: block;
text-decoration: none;
color: inherit;
height: 100%;
}
.product-link.is-disabled {
opacity: 0.6;
cursor: not-allowed;
}
.product-card {
display: flex;
flex-direction: column;
flex: 1;
gap: 12px;
height: 100%;
}
.card-top {
display: flex;
align-items: baseline;
justify-content: space-between;
gap: 12px;
}
.card-name {
font-size: 16px;
font-weight: 700;
color: var(--text);
line-height: 1.2;
}
.card-price {
display: flex;
align-items: center;
justify-content: flex-end;
}
.card-mid {
flex: 1;
display: flex;
align-items: center;
justify-content: center;
text-align: center;
}
.card-mid .markdown {
text-align: center;
}
.card-bottom {
display: flex;
flex-direction: column;
gap: 6px;
align-items: flex-start;
}
.meta-row {
display: flex;
gap: 10px;
align-items: center;
flex-wrap: wrap;
}
.cover-wrap {
position: relative;
border-radius: 12px;
overflow: hidden;
}
.cover-wrap img {
width: 100%;
height: 140px;
object-fit: cover;
display: block;
}
.soldout-badge {
position: absolute;
inset: 0;
display: flex;
align-items: center;
justify-content: center;
background: rgba(20, 18, 22, 0.52);
backdrop-filter: blur(3px);
color: #fff;
font-family: 'Playfair Display', serif;
font-size: 22px;
font-weight: 600;
letter-spacing: 4px;
pointer-events: none;
}
.price-original {
text-decoration: line-through;
color: var(--muted);
margin-right: 6px;
font-size: 13px;
}
.price-discount {
color: var(--accent-2);
font-weight: 600;
}
.status {
padding: 24px 0;
color: var(--muted);
}
.pagination {
display: flex;
align-items: center;
justify-content: center;
gap: 12px;
margin-top: 22px;
}
@media (max-width: 900px) {
.filter-search-row {
flex-direction: column;
align-items: stretch;
gap: 10px;
}
.search-input {
width: 100%;
}
}
</style>