419 lines
10 KiB
Vue
419 lines
10 KiB
Vue
<script setup lang="ts">
|
|
import { nextTick, onBeforeUnmount, onMounted, ref, watch } from 'vue'
|
|
import { useRoute, useRouter } from 'vue-router'
|
|
import { showToast } from 'vant'
|
|
import MobileBottomNav from '@/components/MobileBottomNav.vue'
|
|
import {
|
|
fetchMohongCategories,
|
|
fetchMohongProducts,
|
|
type MohongCategory,
|
|
type MohongProduct,
|
|
} from '@/features/mohong/api/mohong'
|
|
import { formatCent } from '@/shared/utils/money'
|
|
import { readError } from '@/shared/utils/error'
|
|
|
|
const router = useRouter()
|
|
const route = useRoute()
|
|
const loading = ref(false)
|
|
const products = ref<MohongProduct[]>([])
|
|
const categories = ref<MohongCategory[]>([])
|
|
const total = ref(0)
|
|
const page = ref(1)
|
|
const hasMore = ref(true)
|
|
const loadingMore = ref(false)
|
|
const keyword = ref('')
|
|
const activeCategoryId = ref<number | null>(null)
|
|
const failedCover = ref<Record<number, boolean>>({})
|
|
const listPanel = ref<HTMLElement | null>(null)
|
|
const loadMoreSentinel = ref<HTMLElement | null>(null)
|
|
let searchTimer: ReturnType<typeof setTimeout> | null = null
|
|
let loadMoreObserver: IntersectionObserver | null = null
|
|
|
|
onMounted(async () => {
|
|
await loadCategories()
|
|
applyRouteQuery()
|
|
await loadProducts(true)
|
|
setupLoadMoreObserver()
|
|
})
|
|
|
|
onBeforeUnmount(() => {
|
|
loadMoreObserver?.disconnect()
|
|
loadMoreObserver = null
|
|
if (searchTimer) clearTimeout(searchTimer)
|
|
})
|
|
|
|
watch(
|
|
() => route.query,
|
|
() => {
|
|
applyRouteQuery()
|
|
loadProducts(true)
|
|
}
|
|
)
|
|
|
|
function applyRouteQuery() {
|
|
const q = route.query
|
|
keyword.value = typeof q.keyword === 'string' ? q.keyword : ''
|
|
const cat = Number(q.category_id || 0)
|
|
activeCategoryId.value = cat > 0 ? cat : null
|
|
}
|
|
|
|
async function loadCategories() {
|
|
try {
|
|
categories.value = await fetchMohongCategories()
|
|
if (!activeCategoryId.value && categories.value.length) {
|
|
const dahong = categories.value.find(c => c.name === '大红')
|
|
const first = categories.value.find(c => c.product_count > 0) || categories.value[0]
|
|
if (dahong || first) {
|
|
activeCategoryId.value = (dahong || first)!.id
|
|
syncQuery()
|
|
}
|
|
}
|
|
} catch (error) {
|
|
showToast({ message: readError(error, '加载分类失败'), icon: 'cross' })
|
|
}
|
|
}
|
|
|
|
async function loadProducts(reset = true) {
|
|
if (reset) {
|
|
if (loading.value) return
|
|
loading.value = true
|
|
page.value = 1
|
|
hasMore.value = true
|
|
} else {
|
|
if (loading.value || loadingMore.value || !hasMore.value) return
|
|
loadingMore.value = true
|
|
}
|
|
try {
|
|
const result = await fetchMohongProducts({
|
|
page: page.value,
|
|
page_size: 20,
|
|
keyword: keyword.value.trim() || undefined,
|
|
category_id: activeCategoryId.value || undefined,
|
|
})
|
|
products.value = reset ? result.items || [] : [...products.value, ...(result.items || [])]
|
|
total.value = result.total || 0
|
|
hasMore.value = products.value.length < total.value
|
|
page.value += 1
|
|
} catch (error) {
|
|
showToast({ message: readError(error, '加载商品失败'), icon: 'cross' })
|
|
} finally {
|
|
loading.value = false
|
|
loadingMore.value = false
|
|
}
|
|
}
|
|
|
|
function setupLoadMoreObserver() {
|
|
loadMoreObserver?.disconnect()
|
|
loadMoreObserver = new IntersectionObserver(
|
|
entries => {
|
|
if (!entries.some(item => item.isIntersecting)) return
|
|
if (loading.value || loadingMore.value || !hasMore.value) return
|
|
loadProducts(false)
|
|
},
|
|
{ root: listPanel.value, rootMargin: '160px 0px', threshold: 0 }
|
|
)
|
|
nextTick(() => {
|
|
if (loadMoreSentinel.value) loadMoreObserver?.observe(loadMoreSentinel.value)
|
|
})
|
|
}
|
|
|
|
watch([listPanel, loadMoreSentinel], () => {
|
|
setupLoadMoreObserver()
|
|
})
|
|
|
|
function selectCategory(id: number | null) {
|
|
activeCategoryId.value = id
|
|
syncQuery()
|
|
loadProducts(true)
|
|
}
|
|
|
|
function onSearchInput() {
|
|
if (searchTimer) clearTimeout(searchTimer)
|
|
searchTimer = setTimeout(() => {
|
|
syncQuery()
|
|
loadProducts(true)
|
|
}, 280)
|
|
}
|
|
|
|
function syncQuery() {
|
|
const query: Record<string, string> = {}
|
|
if (keyword.value.trim()) query.keyword = keyword.value.trim()
|
|
if (activeCategoryId.value) query.category_id = String(activeCategoryId.value)
|
|
router.replace({ path: '/mohong', query })
|
|
}
|
|
|
|
function goDetail(id: number) {
|
|
router.push(`/mohong/${id}`)
|
|
}
|
|
|
|
function markCoverFailed(id: number) {
|
|
failedCover.value = { ...failedCover.value, [id]: true }
|
|
}
|
|
|
|
function showCover(item: MohongProduct) {
|
|
return Boolean(item.cover_url) && !failedCover.value[item.id]
|
|
}
|
|
</script>
|
|
|
|
<template>
|
|
<main class="mohong-shell">
|
|
<header class="page-header">
|
|
<button type="button" class="back-btn" @click="router.push('/')">
|
|
<van-icon name="arrow-left" :size="20" />
|
|
</button>
|
|
<div class="search-wrap">
|
|
<van-icon name="search" :size="16" />
|
|
<input
|
|
v-model="keyword"
|
|
type="search"
|
|
placeholder="搜索商品"
|
|
@input="onSearchInput"
|
|
/>
|
|
</div>
|
|
<button type="button" class="orders-btn" @click="router.push('/mohong/orders')">订单</button>
|
|
</header>
|
|
|
|
<div class="shop-body">
|
|
<aside class="cat-sidebar">
|
|
<button
|
|
type="button"
|
|
class="cat-item"
|
|
:class="{ active: !activeCategoryId }"
|
|
@click="selectCategory(null)"
|
|
>
|
|
全部
|
|
</button>
|
|
<button
|
|
v-for="cat in categories"
|
|
:key="cat.id"
|
|
type="button"
|
|
class="cat-item"
|
|
:class="{ active: activeCategoryId === cat.id }"
|
|
@click="selectCategory(cat.id)"
|
|
>
|
|
{{ cat.name }}
|
|
</button>
|
|
</aside>
|
|
|
|
<section ref="listPanel" class="list-panel">
|
|
<van-loading v-if="loading" class="state-loading" size="24px" vertical>加载中...</van-loading>
|
|
<van-empty v-else-if="products.length === 0" description="暂无商品" />
|
|
<div v-else class="product-list">
|
|
<button
|
|
v-for="item in products"
|
|
:key="item.id"
|
|
type="button"
|
|
class="product-row"
|
|
@click="goDetail(item.id)"
|
|
>
|
|
<div class="cover">
|
|
<img
|
|
v-if="showCover(item)"
|
|
:src="item.cover_url"
|
|
:alt="item.title"
|
|
@error="markCoverFailed(item.id)"
|
|
/>
|
|
<span v-else class="cover-fallback">{{ item.title.slice(0, 1) || '红' }}</span>
|
|
</div>
|
|
<div class="info">
|
|
<h2>{{ item.title }}</h2>
|
|
<div class="price-row">
|
|
<strong>¥{{ item.price || formatCent(item.price_cent) }}</strong>
|
|
<small v-if="item.original_price">¥{{ item.original_price }}</small>
|
|
</div>
|
|
</div>
|
|
<span class="buy-icon">购</span>
|
|
</button>
|
|
</div>
|
|
<div
|
|
v-if="products.length"
|
|
ref="loadMoreSentinel"
|
|
class="load-sentinel"
|
|
aria-hidden="true"
|
|
/>
|
|
<div v-if="loadingMore" class="load-tip">加载中...</div>
|
|
<div v-else-if="!loading && products.length && !hasMore" class="load-tip">已经到底了</div>
|
|
</section>
|
|
</div>
|
|
<MobileBottomNav />
|
|
</main>
|
|
</template>
|
|
|
|
<style scoped>
|
|
.mohong-shell {
|
|
min-height: 100vh;
|
|
background: #f5f7fb;
|
|
padding-bottom: calc(64px + env(safe-area-inset-bottom));
|
|
display: flex;
|
|
flex-direction: column;
|
|
}
|
|
.page-header {
|
|
position: sticky;
|
|
top: 0;
|
|
z-index: 10;
|
|
display: flex;
|
|
align-items: center;
|
|
gap: 8px;
|
|
padding: 10px 12px;
|
|
background: #fff;
|
|
border-bottom: 1px solid #eef1f5;
|
|
}
|
|
.back-btn,
|
|
.orders-btn {
|
|
border: 0;
|
|
background: transparent;
|
|
color: #17233d;
|
|
flex-shrink: 0;
|
|
}
|
|
.orders-btn {
|
|
color: #ff6a00;
|
|
font-size: 14px;
|
|
font-weight: 600;
|
|
}
|
|
.search-wrap {
|
|
flex: 1;
|
|
min-width: 0;
|
|
display: flex;
|
|
align-items: center;
|
|
gap: 6px;
|
|
height: 34px;
|
|
padding: 0 12px;
|
|
border-radius: 999px;
|
|
background: #f3f5f9;
|
|
color: #94a3b8;
|
|
}
|
|
.search-wrap input {
|
|
flex: 1;
|
|
min-width: 0;
|
|
border: 0;
|
|
outline: none;
|
|
background: transparent;
|
|
font-size: 14px;
|
|
color: #17233d;
|
|
}
|
|
.shop-body {
|
|
flex: 1;
|
|
display: grid;
|
|
grid-template-columns: 88px minmax(0, 1fr);
|
|
min-height: 0;
|
|
}
|
|
.cat-sidebar {
|
|
background: #f7f8fa;
|
|
overflow-y: auto;
|
|
padding: 8px 0 16px;
|
|
}
|
|
.cat-item {
|
|
display: block;
|
|
width: 100%;
|
|
padding: 14px 8px;
|
|
border: 0;
|
|
border-left: 3px solid transparent;
|
|
background: transparent;
|
|
color: #64748b;
|
|
font-size: 13px;
|
|
text-align: center;
|
|
cursor: pointer;
|
|
}
|
|
.cat-item.active {
|
|
background: #fff;
|
|
border-left-color: #ff4d4f;
|
|
color: #ff4d4f;
|
|
font-weight: 700;
|
|
}
|
|
.list-panel {
|
|
background: #fff;
|
|
overflow-y: auto;
|
|
padding: 0 0 12px;
|
|
min-height: 0;
|
|
}
|
|
.state-loading {
|
|
padding: 40px 0;
|
|
}
|
|
.product-list {
|
|
display: flex;
|
|
flex-direction: column;
|
|
}
|
|
.product-row {
|
|
display: grid;
|
|
grid-template-columns: 88px minmax(0, 1fr) 36px;
|
|
gap: 12px;
|
|
align-items: center;
|
|
width: 100%;
|
|
padding: 12px;
|
|
border: 0;
|
|
border-bottom: 1px solid #f1f5f9;
|
|
background: #fff;
|
|
text-align: left;
|
|
}
|
|
.cover {
|
|
width: 88px;
|
|
height: 88px;
|
|
border-radius: 10px;
|
|
overflow: hidden;
|
|
background: #111;
|
|
}
|
|
.cover img {
|
|
width: 100%;
|
|
height: 100%;
|
|
object-fit: cover;
|
|
display: block;
|
|
}
|
|
.cover-fallback {
|
|
display: flex;
|
|
width: 100%;
|
|
height: 100%;
|
|
align-items: center;
|
|
justify-content: center;
|
|
background: linear-gradient(145deg, #fff7ed, #ffe4cc);
|
|
color: #ff6a00;
|
|
font-size: 22px;
|
|
font-weight: 900;
|
|
}
|
|
.info h2 {
|
|
margin: 0 0 8px;
|
|
font-size: 15px;
|
|
color: #17233d;
|
|
font-weight: 700;
|
|
overflow: hidden;
|
|
text-overflow: ellipsis;
|
|
white-space: nowrap;
|
|
}
|
|
.price-row {
|
|
display: flex;
|
|
align-items: baseline;
|
|
gap: 6px;
|
|
}
|
|
.price-row strong {
|
|
color: #ff4d4f;
|
|
font-size: 18px;
|
|
font-weight: 800;
|
|
}
|
|
.price-row small {
|
|
color: #c0c8d4;
|
|
text-decoration: line-through;
|
|
font-size: 12px;
|
|
}
|
|
.buy-icon {
|
|
width: 28px;
|
|
height: 28px;
|
|
border-radius: 50%;
|
|
border: 1px solid #ffb4b4;
|
|
color: #ff4d4f;
|
|
display: inline-flex;
|
|
align-items: center;
|
|
justify-content: center;
|
|
font-size: 12px;
|
|
font-weight: 700;
|
|
}
|
|
.load-sentinel {
|
|
width: 100%;
|
|
height: 1px;
|
|
}
|
|
.load-tip {
|
|
padding: 12px;
|
|
text-align: center;
|
|
color: #b0b8c4;
|
|
font-size: 12px;
|
|
}
|
|
</style>
|