Files
hfb_sys/frontend/src/features/mohong/components/MohongCartPanel.vue
T
yml2213 183351a297 摸大红支持购物车多选一起结账
下单支持多商品行快照与一次支付;支付后整单复制文案,前台提供购物车加购与结账。
2026-07-16 20:58:39 +08:00

318 lines
7.9 KiB
Vue

<script setup lang="ts">
import { ref } from 'vue'
import { useRouter } from 'vue-router'
import { ElMessage } from 'element-plus'
import { Delete, ShoppingCart } from '@element-plus/icons-vue'
import { createMohongOrder, startMohongPayment } from '@/features/mohong/api/mohong'
import { useMohongCart } from '@/features/mohong/composables/useMohongCart'
import type { PaymentPayWay } from '@/features/orders/api/orders'
import { useSessionStore } from '@/stores/session'
import { formatCent } from '@/shared/utils/money'
import { readError } from '@/shared/utils/error'
const props = withDefaults(
defineProps<{
mobile?: boolean
selectPayWay: () => Promise<PaymentPayWay | null>
startCashier: (
payment: Awaited<ReturnType<typeof startMohongPayment>>,
onPaid: () => void | Promise<void>
) => Promise<void>
}>(),
{ mobile: false }
)
const router = useRouter()
const session = useSessionStore()
const {
lines,
drawerOpen,
totalCount,
totalAmountCent,
isEmpty,
closeCart,
setQuantity,
removeLine,
clearCart,
checkoutItems,
} = useMohongCart()
const checkingOut = ref(false)
async function ensureAuthReady() {
const loginPath = props.mobile ? '/m/login' : '/login'
const realnamePath = props.mobile ? '/m/realname' : '/realname'
if (!session.isLoggedIn) {
router.push({ path: loginPath, query: { redirect: router.currentRoute.value.fullPath } })
return false
}
try {
await session.loadMe()
} catch {
// ignore
}
if (session.realnameStatus !== 'verified') {
router.push({ path: realnamePath, query: { redirect: router.currentRoute.value.fullPath } })
return false
}
return true
}
async function handleCheckout() {
if (isEmpty.value || checkingOut.value) return
if (!(await ensureAuthReady())) return
const payWay = await props.selectPayWay()
if (!payWay) return
checkingOut.value = true
try {
const order = await createMohongOrder(checkoutItems())
clearCart()
closeCart()
const payment = await startMohongPayment(order.id, payWay)
const orderPath = props.mobile ? `/m/mohong/orders/${order.id}` : `/mohong/orders/${order.id}`
if (payment.paid || payment.status === 'paid') {
ElMessage.success('支付成功')
await router.push(orderPath)
return
}
await props.startCashier(payment, async () => {
await router.push(orderPath)
})
} catch (error) {
ElMessage.error(readError(error, '结账失败'))
} finally {
checkingOut.value = false
}
}
</script>
<template>
<el-drawer
v-if="!mobile"
v-model="drawerOpen"
title="购物车"
size="420px"
append-to-body
>
<div v-if="isEmpty" class="empty">
<el-icon :size="36"><ShoppingCart /></el-icon>
<p>还没有选购商品</p>
<el-button type="primary" color="#ff6a00" @click="closeCart">去选购</el-button>
</div>
<template v-else>
<div class="cart-list">
<div v-for="line in lines" :key="line.product_id" class="cart-line">
<div class="cover">
<img v-if="line.cover_url" :src="line.cover_url" :alt="line.title" />
<span v-else>{{ line.title.slice(0, 1) }}</span>
</div>
<div class="info">
<strong>{{ line.title }}</strong>
<p>¥{{ line.price || formatCent(line.price_cent) }} / {{ line.unit }}</p>
<div class="line-actions">
<el-input-number
:model-value="line.quantity"
:min="1"
:max="line.stock > 0 ? line.stock : 99"
size="small"
@update:model-value="(v: number | undefined) => setQuantity(line.product_id, Number(v || 1))"
/>
<el-button text type="danger" :icon="Delete" @click="removeLine(line.product_id)" />
</div>
</div>
<div class="line-amount">¥{{ formatCent(line.price_cent * line.quantity) }}</div>
</div>
</div>
<div class="cart-footer">
<div>
共 {{ totalCount }} 件
<strong>¥{{ formatCent(totalAmountCent) }}</strong>
</div>
<el-button type="primary" color="#ff6a00" :loading="checkingOut" @click="handleCheckout">
一起结账
</el-button>
</div>
</template>
</el-drawer>
<van-popup
v-else
v-model:show="drawerOpen"
position="bottom"
round
:style="{ maxHeight: '78vh' }"
>
<div class="m-cart">
<header class="m-head">
<h3>购物车</h3>
<button type="button" @click="closeCart">关闭</button>
</header>
<div v-if="isEmpty" class="empty">
<p>还没有选购商品</p>
</div>
<div v-else class="m-list">
<div v-for="line in lines" :key="line.product_id" class="m-line">
<div class="cover">
<img v-if="line.cover_url" :src="line.cover_url" :alt="line.title" />
<span v-else>{{ line.title.slice(0, 1) }}</span>
</div>
<div class="info">
<strong>{{ line.title }}</strong>
<p>¥{{ line.price || formatCent(line.price_cent) }}</p>
<div class="line-actions">
<van-stepper
:model-value="line.quantity"
:min="1"
:max="line.stock > 0 ? line.stock : 99"
@change="(v: number) => setQuantity(line.product_id, Number(v || 1))"
/>
<button type="button" class="rm" @click="removeLine(line.product_id)">删除</button>
</div>
</div>
</div>
</div>
<footer v-if="!isEmpty" class="m-footer">
<div>
共{{ totalCount }}件
<strong>¥{{ formatCent(totalAmountCent) }}</strong>
</div>
<van-button
type="primary"
color="#ff6a00"
round
:loading="checkingOut"
@click="handleCheckout"
>
一起结账
</van-button>
</footer>
</div>
</van-popup>
</template>
<style scoped>
.empty {
display: grid;
gap: 12px;
place-items: center;
padding: 48px 16px;
color: #94a3b8;
}
.cart-list {
display: grid;
gap: 12px;
padding-bottom: 80px;
}
.cart-line {
display: grid;
grid-template-columns: 64px minmax(0, 1fr) auto;
gap: 10px;
align-items: start;
}
.cover {
width: 64px;
height: 64px;
border-radius: 10px;
overflow: hidden;
background: #f1f5f9;
display: grid;
place-items: center;
}
.cover img {
width: 100%;
height: 100%;
object-fit: cover;
}
.info strong {
display: block;
font-size: 14px;
color: #0f172a;
}
.info p {
margin: 4px 0 8px;
color: #94a3b8;
font-size: 12px;
}
.line-actions {
display: flex;
align-items: center;
gap: 4px;
}
.line-amount {
color: #ff6a00;
font-weight: 700;
font-size: 14px;
white-space: nowrap;
}
.cart-footer {
position: absolute;
left: 0;
right: 0;
bottom: 0;
display: flex;
justify-content: space-between;
align-items: center;
gap: 12px;
padding: 14px 16px;
border-top: 1px solid #eef1f5;
background: #fff;
}
.cart-footer strong {
margin-left: 8px;
color: #ff6a00;
font-size: 18px;
}
.m-cart {
display: flex;
flex-direction: column;
max-height: 78vh;
}
.m-head {
display: flex;
justify-content: space-between;
align-items: center;
padding: 14px 16px;
border-bottom: 1px solid #eef1f5;
}
.m-head h3 {
margin: 0;
font-size: 16px;
}
.m-head button {
border: 0;
background: none;
color: #64748b;
}
.m-list {
overflow-y: auto;
padding: 12px 16px;
flex: 1;
}
.m-line {
display: grid;
grid-template-columns: 64px minmax(0, 1fr);
gap: 10px;
padding: 10px 0;
border-bottom: 1px solid #f1f5f9;
}
.rm {
border: 0;
background: none;
color: #ef4444;
font-size: 12px;
}
.m-footer {
display: flex;
justify-content: space-between;
align-items: center;
gap: 12px;
padding: 12px 16px calc(12px + env(safe-area-inset-bottom));
border-top: 1px solid #eef1f5;
}
.m-footer strong {
margin-left: 6px;
color: #ff6a00;
font-size: 18px;
}
</style>