Files
hfb_sys/frontend/src/features/mohong/views/MohongDetailView.vue
T

588 lines
14 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 { ElMessage } from 'element-plus'
import { ArrowLeft } from '@element-plus/icons-vue'
import { goBackOrHome } from '@/shared/utils/routerBack'
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 PayWaySelectDialog from '@/features/orders/components/PayWaySelectDialog.vue'
import type { PaymentPayWay } from '@/features/orders/api/orders'
import { useOrderPaymentCashier } from '@/features/orders/composables/useOrderPaymentCashier'
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 activeImage = ref('')
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,
})
const images = computed(() => {
if (!product.value) return [] as string[]
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.0'
return formatCent(product.value.price_cent * quantity.value)
})
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
})
const activePayWayLabel = computed(() => {
const map: Record<string, string> = { WXZF: '微信', ZFBZF: '支付宝' }
return map[cashier.activePayment.value?.pay_way || ''] || '微信/支付宝'
})
onMounted(loadProduct)
function goBack() {
goBackOrHome(router)
}
async function loadProduct() {
loading.value = true
try {
product.value = await fetchMohongProduct(String(route.params.id))
activeImage.value = images.value[0] || ''
} catch (error) {
ElMessage.error(readError(error, '商品不存在'))
} finally {
loading.value = false
}
}
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
}
async function ensureAuthReady() {
if (!session.isLoggedIn) {
router.push({ path: '/login', query: { redirect: route.fullPath } })
return false
}
try {
await session.loadMe()
} catch {
// ignore
}
if (session.realnameStatus !== 'verified') {
router.push({ path: '/realname', query: { redirect: route.fullPath } })
return false
}
return true
}
function handleAddCart() {
if (!product.value) return
if (!canBuy.value) {
ElMessage.warning('库存不足')
return
}
const result = addProduct(product.value, quantity.value)
if (result.ok) {
ElMessage.success(result.message)
} else {
ElMessage.warning(result.message)
}
}
async function handleBuy() {
if (!product.value || submitting.value) return
if (!(await ensureAuthReady())) return
if (!canBuy.value) {
ElMessage.warning('库存不足')
return
}
const payWay = await selectPayWay()
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') {
ElMessage.success('支付成功')
await router.push(`/crash/orders/${order.id}`)
return
}
await cashier.openPaymentCashier(payment, async () => {
await router.push(`/crash/orders/${order.id}`)
})
} catch (error) {
ElMessage.error(readError(error, '下单失败'))
} finally {
submitting.value = false
}
}
</script>
<template>
<section v-loading="loading" class="mohong-detail-page">
<div class="detail-back-bar">
<button class="detail-back-btn" type="button" @click="goBack">
<el-icon><ArrowLeft /></el-icon>
<span>返回列表</span>
</button>
</div>
<template v-if="product">
<div class="detail-layout">
<div class="gallery-panel">
<div class="main-image">
<img v-if="activeImage" :src="activeImage" :alt="product.title" />
<div v-else class="empty-image">暂无图片</div>
</div>
<div v-if="images.length > 1" class="thumbs">
<button
v-for="(url, idx) in images"
:key="idx"
type="button"
class="thumb"
:class="{ active: url === activeImage }"
@click="activeImage = url"
>
<img :src="url" :alt="`${product.title}-${idx + 1}`" />
</button>
</div>
</div>
<div class="info-panel">
<p class="eyebrow">撞车商品</p>
<h1>{{ product.title }}</h1>
<p class="desc">{{ product.description || '暂无说明' }}</p>
<div class="price-box">
<div>
<small>售价</small>
<strong>¥{{ product.price || formatCent(product.price_cent) }}</strong>
<em>/ {{ product.unit || '份' }}</em>
</div>
<span v-if="product.original_price" class="origin">
原价 ¥{{ product.original_price }}
</span>
</div>
<div class="meta-rows">
<div class="meta-row">
<span>库存</span>
<strong>
<template v-if="product.stock < 0">充足</template>
<template v-else>{{ product.stock }}</template>
</strong>
</div>
<div class="meta-row">
<span>数量</span>
<el-input-number
v-model="quantity"
:min="1"
:max="product.stock > 0 ? product.stock : 99"
/>
</div>
<div class="meta-row">
<span>合计</span>
<strong class="total">¥{{ totalPrice }}</strong>
</div>
</div>
<div class="actions">
<el-button size="large" class="btn-outline" :disabled="!canBuy" @click="handleAddCart">
加入购物车
</el-button>
<el-button
type="primary"
size="large"
color="#ff6a00"
:loading="submitting"
:disabled="!canBuy"
@click="handleBuy"
>
{{ canBuy ? '立即购买' : '暂时缺货' }}
</el-button>
<el-button size="large" class="btn-outline" @click="openCart">
购物车{{ cartCount > 0 ? `(${cartCount})` : '' }}
</el-button>
<el-button size="large" class="btn-outline" @click="router.push('/crash')">
返回列表
</el-button>
</div>
<div class="tips">
<p>购买须知</p>
<ul>
<li>下单需登录并完成实名认证</li>
<li>可加入购物车多选一起结账支付后一键复制整单信息</li>
<li>支付成功后可在订单详情扫码联系客服</li>
</ul>
</div>
</div>
</div>
</template>
<el-empty v-else-if="!loading" description="商品不存在" />
<MohongCartPanel
:select-pay-way="selectPayWay"
:start-cashier="(payment, onPaid) => cashier.openPaymentCashier(payment, onPaid)"
/>
<PayWaySelectDialog
v-model="payWayDialogVisible"
@choose="choosePayWay"
@closed="handlePayWayDialogClosed"
/>
<el-dialog
v-model="cashier.paymentPopupVisible.value"
title="订单支付"
width="520px"
append-to-body
@closed="cashier.stopPaymentPolling"
>
<div v-if="cashier.activePayment.value" class="pay-dialog-body">
<div class="pay-amount">
支付金额
<strong>¥{{ formatCent(cashier.activePayment.value.amount_cent) }}</strong>
</div>
<div v-if="cashier.payURL.value" class="pay-qr">
<img
v-if="cashier.paymentQRCodeURL.value"
:src="cashier.paymentQRCodeURL.value"
alt="支付二维码"
/>
<p>请使用{{ activePayWayLabel }}扫码支付</p>
</div>
<p v-else class="pay-hint">支付单已创建,请完成付款后刷新状态。</p>
</div>
<template #footer>
<el-button
type="primary"
:loading="cashier.checkingPayment.value"
@click="cashier.refreshPaymentStatus(false)"
>
我已支付刷新状态
</el-button>
</template>
</el-dialog>
</section>
</template>
<style scoped>
.mohong-detail-page {
width: 100%;
}
.detail-back-bar {
/* 吸顶固定:始终显示在顶部导航栏(64px)下方,滚动时不跟随页面移走 */
position: sticky;
top: 78px;
z-index: 20;
display: flex;
align-items: center;
margin-bottom: 14px;
}
.detail-back-btn {
display: inline-flex;
align-items: center;
gap: 6px;
height: 36px;
padding: 0 14px;
border: 1px solid #eef1f5;
border-radius: 10px;
background: #ffffff;
color: #475569;
font-size: 13px;
font-weight: 700;
cursor: pointer;
box-shadow: 0 2px 8px rgba(23, 35, 61, 0.08);
transition:
border-color 0.2s ease,
color 0.2s ease,
background 0.2s ease,
box-shadow 0.2s ease;
}
.detail-back-btn:hover {
border-color: #ff6a00;
background: #fff7ed;
color: #ff6a00;
box-shadow: 0 4px 12px rgba(255, 106, 0, 0.12);
}
.detail-layout {
display: grid;
/* 左图固定合理宽度,右侧信息铺满剩余空间,避免整页右侧大片空白 */
grid-template-columns: minmax(320px, 420px) minmax(0, 1fr);
gap: 24px;
align-items: start;
width: 100%;
}
.gallery-panel,
.info-panel {
background: #fff;
border: 1px solid #eef1f5;
border-radius: 16px;
padding: 18px;
min-width: 0;
}
.main-image {
width: 100%;
aspect-ratio: 1;
border-radius: 12px;
overflow: hidden;
background: #111;
}
.main-image img {
width: 100%;
height: 100%;
object-fit: cover;
}
.empty-image {
display: flex;
height: 100%;
align-items: center;
justify-content: center;
color: #a0aec0;
}
.thumbs {
display: flex;
gap: 8px;
margin-top: 12px;
flex-wrap: wrap;
}
.thumb {
width: 64px;
height: 64px;
padding: 0;
border: 2px solid transparent;
border-radius: 10px;
overflow: hidden;
background: #f3f6fb;
cursor: pointer;
}
.thumb.active {
border-color: #ff6a00;
}
.thumb img {
width: 100%;
height: 100%;
object-fit: cover;
}
.eyebrow {
margin: 0 0 8px;
color: #ff6a00;
font-size: 13px;
font-weight: 700;
}
.info-panel h1 {
margin: 0 0 12px;
font-size: 28px;
color: #17233d;
line-height: 1.25;
}
.desc {
margin: 0 0 18px;
color: #6b7a90;
font-size: 14px;
line-height: 1.7;
white-space: pre-wrap;
}
.price-box {
display: flex;
align-items: baseline;
justify-content: space-between;
gap: 12px;
margin-bottom: 18px;
padding: 16px;
border-radius: 12px;
background: #fff7ed;
}
.price-box small {
display: block;
color: #9a3412;
font-size: 12px;
margin-bottom: 4px;
}
.price-box strong {
color: #ff6a00;
font-size: 32px;
font-weight: 900;
}
.price-box em {
margin-left: 6px;
color: #9a3412;
font-style: normal;
font-size: 14px;
}
.origin {
color: #b0b8c4;
text-decoration: line-through;
font-size: 13px;
}
.meta-rows {
display: grid;
gap: 14px;
margin-bottom: 20px;
}
.meta-row {
display: flex;
align-items: center;
justify-content: space-between;
gap: 12px;
color: #6b7a90;
font-size: 14px;
}
.meta-row .total {
color: #ff6a00;
font-size: 22px;
}
.actions {
display: flex;
flex-wrap: wrap;
gap: 12px;
margin-bottom: 20px;
}
.actions :deep(.el-button--primary) {
--el-button-bg-color: #ff6a00;
--el-button-border-color: #ff6a00;
--el-button-hover-bg-color: #e55d00;
--el-button-hover-border-color: #e55d00;
}
.actions :deep(.btn-outline) {
--el-button-bg-color: #fff8f2;
--el-button-border-color: rgba(255, 106, 0, 0.32);
--el-button-text-color: #ff6a00;
--el-button-hover-bg-color: #fff3e8;
--el-button-hover-border-color: #ff6a00;
--el-button-hover-text-color: #ff6a00;
}
.tips {
padding-top: 16px;
border-top: 1px solid #eef1f5;
color: #6b7a90;
font-size: 13px;
}
.tips p {
margin: 0 0 8px;
color: #17233d;
font-weight: 700;
}
.tips ul {
margin: 0;
padding-left: 18px;
line-height: 1.7;
}
.pay-dialog-body {
display: grid;
gap: 16px;
justify-items: center;
text-align: center;
}
.pay-amount {
color: #6b7a90;
}
.pay-amount strong {
display: block;
margin-top: 6px;
color: #ff6a00;
font-size: 28px;
}
.pay-qr img {
width: 220px;
height: 220px;
border-radius: 12px;
background: #f7f9fc;
}
.pay-hint {
color: #6b7a90;
}
@media (max-width: 960px) {
.detail-layout {
grid-template-columns: 1fr;
}
.gallery-panel {
max-width: 420px;
}
}
</style>