Files
hfb_sys/frontend/src/features/mohong/views/MobileMohongListView.vue
T
yml2213 094a91c985 优化摸大红前台交互与主题按钮样式
列表改为滚动触底自动分页,详情与订单页铺满主内容区,按钮统一为橙色主题描边/实心样式。
2026-07-16 19:40:37 +08:00

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>