Files
hfb_sys/frontend/src/features/mohong/views/MobileMohongDetailView.vue
T
yml2213 a4189db4ca 业务更名为撞车并切换crash路由
用户可见文案与页面/API路径改为撞车与crash,保留mohong表与权限码兼容;移动端入口去掉租/撞图标。
2026-07-16 21:19:35 +08:00

376 lines
9.3 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.
<script setup lang="ts">
import { computed, onMounted, ref } from 'vue'
import { useRoute, useRouter } from 'vue-router'
import { showToast } from 'vant'
import MobilePayWaySelectPopup from '@/features/orders/components/MobilePayWaySelectPopup.vue'
import MobilePaymentCashierPopup from '@/features/orders/components/MobilePaymentCashierPopup.vue'
import { useMobilePayWaySelect } from '@/features/orders/composables/useMobilePayWaySelect'
import { useMobilePaymentCashier } from '@/features/orders/composables/useMobilePaymentCashier'
import {
createMohongOrder,
fetchMohongProduct,
queryMohongPayment,
startMohongPayment,
type MohongProduct,
} from '@/features/mohong/api/mohong'
import MohongCartPanel from '@/features/mohong/components/MohongCartPanel.vue'
import { useMohongCart } from '@/features/mohong/composables/useMohongCart'
import { useSessionStore } from '@/stores/session'
import { formatCent } from '@/shared/utils/money'
import { readError } from '@/shared/utils/error'
const route = useRoute()
const router = useRouter()
const session = useSessionStore()
const { addProduct, openCart, totalCount: cartCount } = useMohongCart()
const product = ref<MohongProduct | null>(null)
const loading = ref(true)
const quantity = ref(1)
const submitting = ref(false)
const payWaySelect = useMobilePayWaySelect()
const {
paymentPopupVisible,
activePayment,
payURL,
paymentQRCodeURL,
qrGenerating,
checkingPayment,
openMobilePaymentCashier,
refreshPaymentStatus,
} = useMobilePaymentCashier({
queryPayment: queryMohongPayment,
paidMessage: '支付成功',
})
const images = computed(() => {
if (!product.value) return []
const list = [...(product.value.image_urls || [])]
if (product.value.cover_url && !list.includes(product.value.cover_url)) {
list.unshift(product.value.cover_url)
}
return list
})
const totalPrice = computed(() => {
if (!product.value) return '0'
const cent = product.value.price_cent * quantity.value
return formatCent(cent)
})
const canBuy = computed(() => {
if (!product.value) return false
if (product.value.stock === 0) return false
if (product.value.stock > 0 && quantity.value > product.value.stock) return false
return true
})
onMounted(loadProduct)
async function loadProduct() {
loading.value = true
try {
product.value = await fetchMohongProduct(String(route.params.id))
} catch (error) {
showToast({ message: readError(error, '商品不存在'), icon: 'cross' })
} finally {
loading.value = false
}
}
async function ensureAuthReady() {
if (!session.isLoggedIn) {
router.push({ path: '/m/login', query: { redirect: route.fullPath } })
return false
}
try {
await session.loadMe()
} catch {
// ignore
}
if (session.realnameStatus !== 'verified') {
router.push({ path: '/m/realname', query: { redirect: route.fullPath } })
return false
}
return true
}
function handleAddCart() {
if (!product.value) return
if (!canBuy.value) {
showToast({ message: '库存不足', icon: 'cross' })
return
}
const result = addProduct(product.value, quantity.value)
showToast({
message: result.message,
icon: result.ok ? 'passed' : 'cross',
})
}
async function handleBuy() {
if (!product.value || submitting.value) return
if (!(await ensureAuthReady())) return
if (!canBuy.value) {
showToast({ message: '库存不足', icon: 'cross' })
return
}
const payWay = await payWaySelect.select()
if (!payWay) return
submitting.value = true
try {
const order = await createMohongOrder(product.value.id, quantity.value)
const payment = await startMohongPayment(order.id, payWay)
if (payment.paid || payment.status === 'paid') {
showToast({ message: '支付成功', icon: 'passed' })
await router.push(`/m/crash/orders/${order.id}`)
return
}
await openMobilePaymentCashier(payment, async () => {
await router.push(`/m/crash/orders/${order.id}`)
})
} catch (error) {
showToast({ message: readError(error, '下单失败'), icon: 'cross' })
} finally {
submitting.value = false
}
}
</script>
<template>
<main class="detail-shell">
<header class="page-header">
<button type="button" class="back-btn" @click="router.back()">
<van-icon name="arrow-left" :size="20" />
</button>
<h1>商品详情</h1>
</header>
<van-loading v-if="loading" class="state-loading" size="24px" vertical>加载中...</van-loading>
<template v-else-if="product">
<van-swipe v-if="images.length" class="gallery" :autoplay="4000">
<van-swipe-item v-for="(url, idx) in images" :key="idx">
<img :src="url" :alt="product.title" />
</van-swipe-item>
</van-swipe>
<div v-else class="gallery empty">暂无图片</div>
<section class="panel">
<div class="price-row">
<strong>¥{{ product.price || formatCent(product.price_cent) }}</strong>
<span v-if="product.original_price" class="origin">¥{{ product.original_price }}</span>
<em>/ {{ product.unit || '份' }}</em>
</div>
<h2>{{ product.title }}</h2>
<p class="stock">
库存
<template v-if="product.stock < 0">充足</template>
<template v-else>{{ product.stock }}</template>
</p>
</section>
<section class="panel">
<h3>商品说明</h3>
<p class="desc">{{ product.description || '暂无说明' }}</p>
</section>
<section class="panel qty-panel">
<span>购买数量</span>
<van-stepper v-model="quantity" :min="1" :max="product.stock > 0 ? product.stock : 99" />
</section>
<div class="buy-bar">
<button type="button" class="cart-entry" @click="openCart">
<van-icon name="shopping-cart-o" :size="20" />
<em v-if="cartCount > 0">{{ cartCount }}</em>
</button>
<div class="sum">
合计 <strong>¥{{ totalPrice }}</strong>
</div>
<van-button plain round color="#ff6a00" :disabled="!canBuy" @click="handleAddCart">
加购
</van-button>
<van-button
type="primary"
color="#ff6a00"
round
:loading="submitting"
:disabled="!canBuy"
@click="handleBuy"
>
{{ canBuy ? '购买' : '缺货' }}
</van-button>
</div>
</template>
<van-empty v-else description="商品不存在" />
<MohongCartPanel
mobile
:select-pay-way="() => payWaySelect.select()"
:start-cashier="(payment, onPaid) => openMobilePaymentCashier(payment, onPaid)"
/>
<MobilePayWaySelectPopup
v-model:show="payWaySelect.visible.value"
@choose="payWaySelect.choose"
@closed="payWaySelect.handleClosed"
/>
<MobilePaymentCashierPopup
v-model:show="paymentPopupVisible"
:payment="activePayment"
:pay-url="payURL"
:qr-code-url="paymentQRCodeURL"
:qr-generating="qrGenerating"
:checking="checkingPayment"
@refresh="refreshPaymentStatus(false)"
/>
</main>
</template>
<style scoped>
.detail-shell {
min-height: 100vh;
background: #f5f7fb;
padding-bottom: 88px;
}
.page-header {
position: sticky;
top: 0;
z-index: 10;
display: flex;
align-items: center;
gap: 8px;
padding: 12px 14px;
background: #fff;
border-bottom: 1px solid #eef1f5;
}
.back-btn {
border: 0;
background: transparent;
}
.page-header h1 {
margin: 0;
font-size: 17px;
}
.state-loading {
padding: 48px 0;
}
.gallery {
height: 280px;
background: #fff;
}
.gallery img {
width: 100%;
height: 280px;
object-fit: cover;
}
.gallery.empty {
display: flex;
align-items: center;
justify-content: center;
color: #a0aec0;
}
.panel {
margin: 10px 12px;
padding: 14px;
border-radius: 14px;
background: #fff;
}
.price-row {
display: flex;
align-items: baseline;
gap: 8px;
margin-bottom: 8px;
}
.price-row strong {
color: #ff6a00;
font-size: 24px;
}
.price-row .origin {
color: #b0b8c4;
text-decoration: line-through;
font-size: 13px;
}
.price-row em {
font-style: normal;
color: #8a94a6;
font-size: 13px;
}
.panel h2 {
margin: 0 0 8px;
font-size: 17px;
color: #17233d;
}
.stock,
.desc {
margin: 0;
color: #6b7a90;
font-size: 13px;
line-height: 1.6;
white-space: pre-wrap;
}
.panel h3 {
margin: 0 0 8px;
font-size: 14px;
color: #17233d;
}
.qty-panel {
display: flex;
align-items: center;
justify-content: space-between;
}
.buy-bar {
position: fixed;
left: 0;
right: 0;
bottom: 0;
display: flex;
align-items: center;
gap: 8px;
padding: 10px 12px calc(10px + env(safe-area-inset-bottom));
background: #fff;
border-top: 1px solid #eef1f5;
}
.cart-entry {
position: relative;
width: 40px;
height: 40px;
border: 0;
border-radius: 50%;
background: #fff7ed;
color: #ff6a00;
display: grid;
place-items: center;
flex-shrink: 0;
}
.cart-entry em {
position: absolute;
top: -2px;
right: -2px;
min-width: 16px;
height: 16px;
padding: 0 4px;
border-radius: 999px;
background: #ef4444;
color: #fff;
font-size: 10px;
font-style: normal;
line-height: 16px;
text-align: center;
}
.sum {
flex: 1;
color: #6b7a90;
font-size: 13px;
min-width: 0;
}
.sum strong {
color: #ff6a00;
font-size: 18px;
margin-left: 4px;
}
.buy-bar :deep(.van-button) {
min-width: 72px;
flex-shrink: 0;
}
</style>