Files
hfb_sys/frontend/src/features/mohong/views/MohongListView.vue
T
yml2213 d93795a4b4 统一订单列表混排租赁与撞车,后台撞车订单展示真实手机号
PC/移动端「我的订单」并行拉取两类订单并按时间混排展示;后台撞车订单买家手机号不再脱敏,便于履约联系。
2026-07-19 16:01:45 +08:00

571 lines
13 KiB
Vue

<script setup lang="ts">
import { nextTick, onBeforeUnmount, onMounted, ref, watch } from 'vue'
import { useRoute, useRouter } from 'vue-router'
import { ElMessage } from 'element-plus'
import { Search, Service, ShoppingCart, Tickets } from '@element-plus/icons-vue'
import MohongCartPanel from '@/features/mohong/components/MohongCartPanel.vue'
import MohongProductCard from '@/features/mohong/components/MohongProductCard.vue'
import { useMohongCart } from '@/features/mohong/composables/useMohongCart'
import { ensureSupportChat } from '@/features/chats/api/chats'
import PayWaySelectDialog from '@/features/orders/components/PayWaySelectDialog.vue'
import type { PaymentPayWay } from '@/features/orders/api/orders'
import { useOrderPaymentCashier } from '@/features/orders/composables/useOrderPaymentCashier'
import { queryMohongPayment } from '@/features/mohong/api/mohong'
import {
fetchMohongCategories,
fetchMohongProducts,
type MohongCategory,
type MohongProduct,
} from '@/features/mohong/api/mohong'
import { useSessionStore } from '@/stores/session'
import { readError } from '@/shared/utils/error'
const router = useRouter()
const route = useRoute()
const session = useSessionStore()
const { totalCount: cartCount, openCart } = useMohongCart()
const supportLoading = ref(false)
const loading = ref(false)
const payWayDialogVisible = ref(false)
let payWayResolver: ((value: PaymentPayWay | null) => void) | null = null
const cashier = useOrderPaymentCashier({
notifySuccess: msg => ElMessage.success(msg),
notifyError: msg => ElMessage.error(msg),
notifyInfo: msg => ElMessage.info(msg),
notifyFallback: msg => ElMessage.warning(msg),
paidMessage: '支付成功',
pollIntervalMs: 3000,
qrWidth: 240,
queryPayment: queryMohongPayment,
})
function selectPayWay(): Promise<PaymentPayWay | null> {
payWayDialogVisible.value = true
return new Promise(resolve => {
payWayResolver = resolve
})
}
function choosePayWay(payWay: PaymentPayWay) {
payWayResolver?.(payWay)
payWayResolver = null
payWayDialogVisible.value = false
}
function handlePayWayDialogClosed() {
payWayResolver?.(null)
payWayResolver = null
}
const loadingMore = ref(false)
const products = ref<MohongProduct[]>([])
const categories = ref<MohongCategory[]>([])
const total = ref(0)
const page = ref(1)
const hasMore = ref(true)
const keyword = ref('')
const activeCategoryId = ref<number | 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) {
ElMessage.error(readError(error, '加载分类失败'))
}
}
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: 24,
keyword: keyword.value.trim() || undefined,
category_id: activeCategoryId.value || undefined,
})
const items = result.items || []
products.value = reset ? items : [...products.value, ...items]
total.value = result.total || 0
hasMore.value = products.value.length < total.value
page.value += 1
} catch (error) {
ElMessage.error(readError(error, '加载商品失败'))
} 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: null, rootMargin: '200px 0px', threshold: 0 }
)
nextTick(() => {
if (loadMoreSentinel.value) loadMoreObserver?.observe(loadMoreSentinel.value)
})
}
watch(loadMoreSentinel, el => {
loadMoreObserver?.disconnect()
if (el) loadMoreObserver?.observe(el)
})
function selectCategory(id: number | null) {
activeCategoryId.value = id
syncQuery()
loadProducts(true)
}
function onKeywordInput() {
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: '/crash', query })
}
const activeCategoryName = () => {
if (!activeCategoryId.value) return '全部'
return categories.value.find(c => c.id === activeCategoryId.value)?.name || '全部'
}
async function handleSupportClick() {
if (!session.isLoggedIn) {
router.push({ path: '/login', query: { redirect: route.fullPath } })
return
}
if (supportLoading.value) return
supportLoading.value = true
try {
const chat = await ensureSupportChat('crash')
router.push(`/messages/${chat.id}`)
} catch {
ElMessage.error('联系客服失败,请稍后重试')
} finally {
supportLoading.value = false
}
}
</script>
<template>
<section class="mohong-pc-page">
<header class="toolbar">
<div class="title-block">
<h1>撞车</h1>
<span class="count">{{ total }} </span>
<span class="tip">{{ activeCategoryName() }} · 支付后扫码联系客服</span>
</div>
<div class="actions">
<div class="search-box">
<el-icon><Search /></el-icon>
<input
v-model="keyword"
type="search"
placeholder="搜索商品名称"
@input="onKeywordInput"
/>
</div>
<button type="button" class="toolbar-btn outline" @click="openCart">
<el-icon><ShoppingCart /></el-icon>
<span>购物车{{ cartCount > 0 ? `(${cartCount})` : '' }}</span>
</button>
<button type="button" class="toolbar-btn outline" @click="router.push('/orders')">
<el-icon><Tickets /></el-icon>
<span>我的订单</span>
</button>
<button
type="button"
class="toolbar-btn outline"
:disabled="supportLoading"
@click="handleSupportClick"
>
<el-icon><Service /></el-icon>
<span>{{ supportLoading ? '接入中' : '联系客服' }}</span>
</button>
<button type="button" class="toolbar-btn outline" @click="router.push('/')">
租号大厅
</button>
</div>
</header>
<div class="shop-layout">
<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)"
>
<span>{{ cat.name }}</span>
<em v-if="cat.product_count">{{ cat.product_count }}</em>
</button>
</aside>
<div class="shop-main">
<div v-if="!loading && products.length === 0" class="empty-state">
<strong>暂无商品</strong>
<span>试试切换分类或清空搜索关键词</span>
</div>
<div v-else v-loading="loading" class="product-grid">
<MohongProductCard v-for="item in products" :key="item.id" :product="item" />
</div>
<div
v-if="products.length"
ref="loadMoreSentinel"
class="load-sentinel"
aria-hidden="true"
/>
<div v-if="loadingMore" class="load-state">
<span class="loading-more">加载中...</span>
</div>
<div v-else-if="!loading && products.length && !hasMore" class="load-state">
<span class="end">已经到底了</span>
</div>
</div>
</div>
<MohongCartPanel
:select-pay-way="selectPayWay"
:start-cashier="(payment, onPaid) => cashier.openPaymentCashier(payment, onPaid)"
/>
<PayWaySelectDialog
v-model="payWayDialogVisible"
@choose="choosePayWay"
@closed="handlePayWayDialogClosed"
/>
</section>
</template>
<style scoped>
.mohong-pc-page {
display: grid;
gap: 14px;
width: 100%;
}
.toolbar {
display: flex;
align-items: center;
justify-content: space-between;
gap: 16px;
width: 100%;
padding: 8px 0;
}
.title-block {
display: flex;
align-items: baseline;
flex-wrap: wrap;
gap: 8px 12px;
min-width: 0;
}
.title-block h1 {
margin: 0;
color: #17233d;
font-size: 20px;
font-weight: 800;
}
.count {
padding: 1px 8px;
border-radius: 999px;
background: #fff4ea;
color: #ff6a00;
font-size: 12px;
font-weight: 700;
}
.tip {
color: #8a94a6;
font-size: 12px;
}
.actions {
display: flex;
align-items: center;
gap: 8px;
flex-shrink: 0;
}
.search-box {
display: flex;
align-items: center;
gap: 6px;
width: 220px;
height: 36px;
padding: 0 12px;
border: 1px solid #eef1f5;
border-radius: 10px;
background: #fff;
color: #94a3b8;
transition:
border-color 0.2s,
box-shadow 0.2s;
}
.search-box:focus-within {
border-color: rgba(255, 106, 0, 0.45);
box-shadow: 0 0 0 3px rgba(255, 106, 0, 0.08);
}
.search-box input {
flex: 1;
min-width: 0;
border: 0;
outline: none;
background: transparent;
color: #17233d;
font-size: 13px;
}
/* 对齐顶栏客服/发布账号的主题按钮风格 */
.toolbar-btn {
display: inline-flex;
align-items: center;
justify-content: center;
gap: 6px;
height: 36px;
padding: 0 14px;
border-radius: 10px;
border: 1px solid transparent;
font-size: 13px;
font-weight: 700;
cursor: pointer;
white-space: nowrap;
transition: all 0.2s;
}
.toolbar-btn.outline {
border-color: rgba(255, 106, 0, 0.32);
background: #fff8f2;
color: #ff6a00;
}
.toolbar-btn.outline:hover {
border-color: #ff6a00;
background: #fff3e8;
box-shadow: 0 4px 12px rgba(255, 106, 0, 0.1);
transform: translateY(-1px);
}
.shop-layout {
display: grid;
grid-template-columns: 140px minmax(0, 1fr);
gap: 16px;
align-items: start;
min-height: 480px;
}
.cat-sidebar {
position: sticky;
top: 12px;
display: flex;
flex-direction: column;
gap: 2px;
padding: 8px 0;
border-radius: 12px;
background: #fff;
border: 1px solid #eef1f5;
overflow: hidden;
}
.cat-item {
display: flex;
align-items: center;
justify-content: space-between;
gap: 8px;
width: 100%;
padding: 12px 14px;
border: 0;
border-left: 3px solid transparent;
background: transparent;
color: #52616f;
font-size: 14px;
text-align: left;
cursor: pointer;
transition:
background 0.15s,
color 0.15s,
border-color 0.15s;
}
.cat-item:hover {
background: #f8fafc;
color: #17233d;
}
.cat-item.active {
background: #fff7ed;
border-left-color: #ff6a00;
color: #ff6a00;
font-weight: 700;
}
.cat-item em {
font-style: normal;
color: #b0b8c4;
font-size: 12px;
font-weight: 600;
}
.cat-item.active em {
color: #ff9a4d;
}
.shop-main {
min-width: 0;
}
.product-grid {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(200px, 1fr));
gap: 14px;
width: 100%;
}
.product-grid :deep(.mohong-card) {
justify-self: center;
}
.empty-state {
display: grid;
gap: 6px;
justify-items: center;
width: 100%;
padding: 64px 20px;
border-radius: 14px;
border: 1px dashed #dbe3ee;
background: #fff;
color: #8a94a6;
}
.empty-state strong {
color: #17233d;
}
.load-sentinel {
width: 100%;
height: 1px;
}
.load-state {
display: flex;
justify-content: center;
width: 100%;
padding: 12px 0 8px;
}
.loading-more,
.end {
color: #b0b8c4;
font-size: 12px;
}
@media (max-width: 900px) {
.shop-layout {
grid-template-columns: 1fr;
}
.cat-sidebar {
position: static;
flex-direction: row;
overflow-x: auto;
padding: 6px;
gap: 4px;
}
.cat-item {
flex: 0 0 auto;
border-left: 0;
border-radius: 8px;
padding: 8px 12px;
white-space: nowrap;
}
.cat-item.active {
border-left-color: transparent;
}
.toolbar {
flex-direction: column;
align-items: stretch;
}
.search-box {
width: 100%;
}
}
</style>