摸大红支持购物车多选一起结账
下单支持多商品行快照与一次支付;支付后整单复制文案,前台提供购物车加购与结账。
This commit is contained in:
@@ -90,8 +90,9 @@ async function uploadQrcode(file: File) {
|
||||
<el-input v-model="form.order_copy_template" type="textarea" :rows="8" />
|
||||
<div class="hint">
|
||||
可用变量:{{order_no}} {{created_at}}
|
||||
{{product_title}} {{quantity}} {{amount}}
|
||||
{{buyer_name}} {{buyer_phone}} {{unit}}
|
||||
{{items}}(多商品明细) {{product_title}}
|
||||
{{quantity}} {{amount}} {{buyer_name}}
|
||||
{{buyer_phone}} {{unit}}
|
||||
</div>
|
||||
</el-form-item>
|
||||
</el-form>
|
||||
|
||||
@@ -175,6 +175,13 @@ async function copyText(text: string) {
|
||||
{{ detail.buyer_nickname }} {{ detail.buyer_phone }}
|
||||
</el-descriptions-item>
|
||||
</el-descriptions>
|
||||
<div v-if="detail.items?.length" class="items-box">
|
||||
<strong>商品明细</strong>
|
||||
<div v-for="item in detail.items" :key="item.product_id" class="item-line">
|
||||
<span>{{ item.title }} × {{ item.quantity }}</span>
|
||||
<em>¥{{ item.amount || formatCent(item.amount_cent) }}</em>
|
||||
</div>
|
||||
</div>
|
||||
<div v-if="detail.copy_text" class="copy-box">
|
||||
<div class="copy-head">
|
||||
<strong>复制文案</strong>
|
||||
@@ -215,6 +222,23 @@ async function copyText(text: string) {
|
||||
gap: 10px;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
.items-box {
|
||||
margin-top: 14px;
|
||||
display: grid;
|
||||
gap: 8px;
|
||||
}
|
||||
.item-line {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
gap: 12px;
|
||||
font-size: 13px;
|
||||
color: #334155;
|
||||
}
|
||||
.item-line em {
|
||||
font-style: normal;
|
||||
color: #ff6a00;
|
||||
font-weight: 600;
|
||||
}
|
||||
.copy-box,
|
||||
.qr-box {
|
||||
margin-top: 16px;
|
||||
|
||||
@@ -34,6 +34,18 @@ export interface MohongProduct {
|
||||
updated_at: string
|
||||
}
|
||||
|
||||
export interface MohongOrderItem {
|
||||
product_id: number
|
||||
title: string
|
||||
cover_url: string
|
||||
unit: string
|
||||
quantity: number
|
||||
unit_price_cent: number
|
||||
unit_price: string
|
||||
amount_cent: number
|
||||
amount: string
|
||||
}
|
||||
|
||||
export interface MohongOrder {
|
||||
id: number
|
||||
order_no: string
|
||||
@@ -48,6 +60,7 @@ export interface MohongOrder {
|
||||
product_title: string
|
||||
product_cover_url: string
|
||||
product_unit: string
|
||||
items?: MohongOrderItem[]
|
||||
qrcode_url_snapshot: string
|
||||
copy_text: string
|
||||
buyer_nickname?: string
|
||||
@@ -61,6 +74,11 @@ export interface MohongOrder {
|
||||
updated_at: string
|
||||
}
|
||||
|
||||
export interface MohongCheckoutItem {
|
||||
product_id: number
|
||||
quantity: number
|
||||
}
|
||||
|
||||
export interface MohongConfig {
|
||||
default_qrcode_url: string
|
||||
order_copy_template: string
|
||||
@@ -95,11 +113,15 @@ export async function fetchMohongProduct(id: number | string) {
|
||||
return data.data
|
||||
}
|
||||
|
||||
export async function createMohongOrder(productId: number, quantity: number) {
|
||||
const { data } = await apiClient.post<ApiResponse<MohongOrder>>('/mohong/orders', {
|
||||
product_id: productId,
|
||||
quantity,
|
||||
})
|
||||
/** 单商品或购物车多商品结账 */
|
||||
export async function createMohongOrder(
|
||||
productIdOrItems: number | MohongCheckoutItem[],
|
||||
quantity = 1
|
||||
) {
|
||||
const payload = Array.isArray(productIdOrItems)
|
||||
? { items: productIdOrItems }
|
||||
: { product_id: productIdOrItems, quantity }
|
||||
const { data } = await apiClient.post<ApiResponse<MohongOrder>>('/mohong/orders', payload)
|
||||
return data.data
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,317 @@
|
||||
<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>
|
||||
@@ -0,0 +1,165 @@
|
||||
import { computed, ref, watch } from 'vue'
|
||||
import type { MohongProduct } from '@/features/mohong/api/mohong'
|
||||
|
||||
const STORAGE_KEY = 'mohong_cart_v1'
|
||||
|
||||
export interface MohongCartLine {
|
||||
product_id: number
|
||||
title: string
|
||||
cover_url: string
|
||||
unit: string
|
||||
price_cent: number
|
||||
price: string
|
||||
stock: number
|
||||
quantity: number
|
||||
}
|
||||
|
||||
function loadFromStorage(): MohongCartLine[] {
|
||||
try {
|
||||
const raw = localStorage.getItem(STORAGE_KEY)
|
||||
if (!raw) return []
|
||||
const parsed = JSON.parse(raw)
|
||||
if (!Array.isArray(parsed)) return []
|
||||
return parsed
|
||||
.filter(
|
||||
(item): item is MohongCartLine =>
|
||||
item &&
|
||||
typeof item.product_id === 'number' &&
|
||||
item.product_id > 0 &&
|
||||
typeof item.quantity === 'number' &&
|
||||
item.quantity > 0
|
||||
)
|
||||
.map(item => ({
|
||||
product_id: item.product_id,
|
||||
title: String(item.title || '商品'),
|
||||
cover_url: String(item.cover_url || ''),
|
||||
unit: String(item.unit || '份'),
|
||||
price_cent: Number(item.price_cent) || 0,
|
||||
price: String(item.price || ''),
|
||||
stock: typeof item.stock === 'number' ? item.stock : -1,
|
||||
quantity: Math.min(99, Math.max(1, Math.floor(item.quantity))),
|
||||
}))
|
||||
} catch {
|
||||
return []
|
||||
}
|
||||
}
|
||||
|
||||
const lines = ref<MohongCartLine[]>(loadFromStorage())
|
||||
const drawerOpen = ref(false)
|
||||
|
||||
watch(
|
||||
lines,
|
||||
value => {
|
||||
try {
|
||||
localStorage.setItem(STORAGE_KEY, JSON.stringify(value))
|
||||
} catch {
|
||||
// ignore quota
|
||||
}
|
||||
},
|
||||
{ deep: true }
|
||||
)
|
||||
|
||||
function maxQtyFor(stock: number) {
|
||||
if (stock < 0) return 99
|
||||
return Math.max(0, Math.min(99, stock))
|
||||
}
|
||||
|
||||
function productToLine(product: MohongProduct, quantity = 1): MohongCartLine | null {
|
||||
const max = maxQtyFor(product.stock)
|
||||
if (max <= 0) return null
|
||||
return {
|
||||
product_id: product.id,
|
||||
title: product.title,
|
||||
cover_url: product.cover_url || product.image_urls?.[0] || '',
|
||||
unit: product.unit || '份',
|
||||
price_cent: product.price_cent,
|
||||
price: product.price || '',
|
||||
stock: product.stock,
|
||||
quantity: Math.min(max, Math.max(1, quantity)),
|
||||
}
|
||||
}
|
||||
|
||||
export function useMohongCart() {
|
||||
const totalCount = computed(() => lines.value.reduce((sum, item) => sum + item.quantity, 0))
|
||||
const totalAmountCent = computed(() =>
|
||||
lines.value.reduce((sum, item) => sum + item.price_cent * item.quantity, 0)
|
||||
)
|
||||
const isEmpty = computed(() => lines.value.length === 0)
|
||||
|
||||
function openCart() {
|
||||
drawerOpen.value = true
|
||||
}
|
||||
function closeCart() {
|
||||
drawerOpen.value = false
|
||||
}
|
||||
function toggleCart() {
|
||||
drawerOpen.value = !drawerOpen.value
|
||||
}
|
||||
|
||||
function addProduct(product: MohongProduct, quantity = 1): { ok: boolean; message: string } {
|
||||
const next = productToLine(product, quantity)
|
||||
if (!next) {
|
||||
return { ok: false, message: '暂时缺货' }
|
||||
}
|
||||
const existing = lines.value.find(item => item.product_id === product.id)
|
||||
if (existing) {
|
||||
const max = maxQtyFor(product.stock)
|
||||
const qty = Math.min(max, existing.quantity + quantity)
|
||||
if (qty <= existing.quantity) {
|
||||
return { ok: false, message: '已达库存上限' }
|
||||
}
|
||||
existing.quantity = qty
|
||||
existing.title = next.title
|
||||
existing.cover_url = next.cover_url
|
||||
existing.price_cent = next.price_cent
|
||||
existing.price = next.price
|
||||
existing.stock = next.stock
|
||||
existing.unit = next.unit
|
||||
return { ok: true, message: '已更新购物车数量' }
|
||||
}
|
||||
lines.value = [...lines.value, next]
|
||||
return { ok: true, message: '已加入购物车' }
|
||||
}
|
||||
|
||||
function setQuantity(productId: number, quantity: number) {
|
||||
const line = lines.value.find(item => item.product_id === productId)
|
||||
if (!line) return
|
||||
const max = maxQtyFor(line.stock)
|
||||
if (quantity < 1 || max <= 0) {
|
||||
removeLine(productId)
|
||||
return
|
||||
}
|
||||
line.quantity = Math.min(max, Math.floor(quantity))
|
||||
}
|
||||
|
||||
function removeLine(productId: number) {
|
||||
lines.value = lines.value.filter(item => item.product_id !== productId)
|
||||
}
|
||||
|
||||
function clearCart() {
|
||||
lines.value = []
|
||||
}
|
||||
|
||||
function checkoutItems() {
|
||||
return lines.value.map(item => ({
|
||||
product_id: item.product_id,
|
||||
quantity: item.quantity,
|
||||
}))
|
||||
}
|
||||
|
||||
return {
|
||||
lines,
|
||||
drawerOpen,
|
||||
totalCount,
|
||||
totalAmountCent,
|
||||
isEmpty,
|
||||
openCart,
|
||||
closeCart,
|
||||
toggleCart,
|
||||
addProduct,
|
||||
setQuantity,
|
||||
removeLine,
|
||||
clearCart,
|
||||
checkoutItems,
|
||||
}
|
||||
}
|
||||
@@ -13,6 +13,8 @@ import {
|
||||
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'
|
||||
@@ -20,6 +22,7 @@ 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)
|
||||
@@ -91,6 +94,19 @@ async function ensureAuthReady() {
|
||||
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
|
||||
@@ -107,11 +123,11 @@ async function handleBuy() {
|
||||
const payment = await startMohongPayment(order.id, payWay)
|
||||
if (payment.paid || payment.status === 'paid') {
|
||||
showToast({ message: '支付成功', icon: 'passed' })
|
||||
await router.push(`/mohong/orders/${order.id}`)
|
||||
await router.push(`/m/mohong/orders/${order.id}`)
|
||||
return
|
||||
}
|
||||
await openMobilePaymentCashier(payment, async () => {
|
||||
await router.push(`/mohong/orders/${order.id}`)
|
||||
await router.push(`/m/mohong/orders/${order.id}`)
|
||||
})
|
||||
} catch (error) {
|
||||
showToast({ message: readError(error, '下单失败'), icon: 'cross' })
|
||||
@@ -164,9 +180,16 @@ async function handleBuy() {
|
||||
</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"
|
||||
@@ -175,12 +198,17 @@ async function handleBuy() {
|
||||
:disabled="!canBuy"
|
||||
@click="handleBuy"
|
||||
>
|
||||
{{ canBuy ? '立即购买' : '暂时缺货' }}
|
||||
{{ 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"
|
||||
@@ -297,22 +325,51 @@ async function handleBuy() {
|
||||
bottom: 0;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 12px;
|
||||
padding: 10px 16px calc(10px + env(safe-area-inset-bottom));
|
||||
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: 20px;
|
||||
font-size: 18px;
|
||||
margin-left: 4px;
|
||||
}
|
||||
.buy-bar :deep(.van-button) {
|
||||
min-width: 128px;
|
||||
min-width: 72px;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -7,9 +7,16 @@ import { ensureSupportChat } from '@/features/chats/api/chats'
|
||||
import {
|
||||
fetchMohongCategories,
|
||||
fetchMohongProducts,
|
||||
queryMohongPayment,
|
||||
type MohongCategory,
|
||||
type MohongProduct,
|
||||
} from '@/features/mohong/api/mohong'
|
||||
import MohongCartPanel from '@/features/mohong/components/MohongCartPanel.vue'
|
||||
import { useMohongCart } from '@/features/mohong/composables/useMohongCart'
|
||||
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 { useSessionStore } from '@/stores/session'
|
||||
import { formatCent } from '@/shared/utils/money'
|
||||
import { readError } from '@/shared/utils/error'
|
||||
@@ -17,6 +24,21 @@ import { readError } from '@/shared/utils/error'
|
||||
const router = useRouter()
|
||||
const route = useRoute()
|
||||
const session = useSessionStore()
|
||||
const { totalCount: cartCount, openCart } = useMohongCart()
|
||||
const payWaySelect = useMobilePayWaySelect()
|
||||
const {
|
||||
paymentPopupVisible,
|
||||
activePayment,
|
||||
payURL,
|
||||
paymentQRCodeURL,
|
||||
qrGenerating,
|
||||
checkingPayment,
|
||||
openMobilePaymentCashier,
|
||||
refreshPaymentStatus,
|
||||
} = useMobilePaymentCashier({
|
||||
queryPayment: queryMohongPayment,
|
||||
paidMessage: '支付成功',
|
||||
})
|
||||
const supportLoading = ref(false)
|
||||
const loading = ref(false)
|
||||
const products = ref<MohongProduct[]>([])
|
||||
@@ -191,6 +213,9 @@ async function handleSupportClick() {
|
||||
@input="onSearchInput"
|
||||
/>
|
||||
</div>
|
||||
<button type="button" class="orders-btn" @click="openCart">
|
||||
购物车{{ cartCount > 0 ? `(${cartCount})` : '' }}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
class="orders-btn"
|
||||
@@ -264,6 +289,25 @@ async function handleSupportClick() {
|
||||
<div v-else-if="!loading && products.length && !hasMore" class="load-tip">已经到底了</div>
|
||||
</section>
|
||||
</div>
|
||||
<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)"
|
||||
/>
|
||||
<MobileBottomNav />
|
||||
</main>
|
||||
</template>
|
||||
|
||||
@@ -127,14 +127,25 @@ async function copyText() {
|
||||
</div>
|
||||
<div>
|
||||
<h2>{{ order.product_title }}</h2>
|
||||
<p>
|
||||
<p v-if="!order.items?.length">
|
||||
¥{{ order.unit_price || formatCent(order.unit_price_cent) }} × {{ order.quantity }}
|
||||
{{ order.product_unit }}
|
||||
</p>
|
||||
<p v-else>共 {{ order.items.length }} 种 · {{ order.quantity }} 件</p>
|
||||
<strong>合计 ¥{{ order.amount || formatCent(order.amount_cent) }}</strong>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section v-if="order.items?.length" class="panel">
|
||||
<h3>商品明细</h3>
|
||||
<div v-for="item in order.items" :key="item.product_id" class="item-row">
|
||||
<span>{{ item.title }}</span>
|
||||
<em
|
||||
>×{{ item.quantity }} · ¥{{ item.amount || formatCent(item.amount_cent) }}</em
|
||||
>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section v-if="order.copy_text" class="panel">
|
||||
<div class="copy-head">
|
||||
<h3>订单信息(发给客服)</h3>
|
||||
@@ -272,6 +283,23 @@ async function copyText() {
|
||||
.product-panel strong {
|
||||
color: #ff6a00;
|
||||
}
|
||||
.item-row {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
gap: 10px;
|
||||
padding: 8px 0;
|
||||
border-bottom: 1px solid #f1f5f9;
|
||||
font-size: 13px;
|
||||
color: #0f172a;
|
||||
}
|
||||
.item-row:last-child {
|
||||
border-bottom: 0;
|
||||
}
|
||||
.item-row em {
|
||||
font-style: normal;
|
||||
color: #64748b;
|
||||
white-space: nowrap;
|
||||
}
|
||||
.copy-head {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
|
||||
@@ -9,6 +9,8 @@ import {
|
||||
startMohongPayment,
|
||||
type MohongProduct,
|
||||
} from '@/features/mohong/api/mohong'
|
||||
import MohongCartPanel from '@/features/mohong/components/MohongCartPanel.vue'
|
||||
import { useMohongCart } from '@/features/mohong/composables/useMohongCart'
|
||||
import type { PaymentPayWay } from '@/features/orders/api/orders'
|
||||
import { useOrderPaymentCashier } from '@/features/orders/composables/useOrderPaymentCashier'
|
||||
import { useSessionStore } from '@/stores/session'
|
||||
@@ -18,6 +20,7 @@ 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)
|
||||
@@ -112,6 +115,20 @@ async function ensureAuthReady() {
|
||||
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
|
||||
@@ -204,6 +221,9 @@ async function handleBuy() {
|
||||
</div>
|
||||
|
||||
<div class="actions">
|
||||
<el-button size="large" class="btn-outline" :disabled="!canBuy" @click="handleAddCart">
|
||||
加入购物车
|
||||
</el-button>
|
||||
<el-button
|
||||
type="primary"
|
||||
size="large"
|
||||
@@ -214,6 +234,9 @@ async function 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('/mohong')">
|
||||
返回列表
|
||||
</el-button>
|
||||
@@ -223,8 +246,8 @@ async function handleBuy() {
|
||||
<p>购买须知</p>
|
||||
<ul>
|
||||
<li>下单需登录并完成实名认证</li>
|
||||
<li>可加入购物车多选,一起结账;支付后一键复制整单信息</li>
|
||||
<li>支付成功后可在订单详情扫码联系客服</li>
|
||||
<li>订单详情支持一键复制订单信息发给客服</li>
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
@@ -232,6 +255,11 @@ async function handleBuy() {
|
||||
</template>
|
||||
<el-empty v-else-if="!loading" description="商品不存在" />
|
||||
|
||||
<MohongCartPanel
|
||||
:select-pay-way="selectPayWay"
|
||||
:start-cashier="(payment, onPaid) => cashier.openPaymentCashier(payment, onPaid)"
|
||||
/>
|
||||
|
||||
<el-dialog
|
||||
v-model="payWayDialogVisible"
|
||||
title="选择支付方式"
|
||||
|
||||
@@ -2,9 +2,14 @@
|
||||
import { nextTick, onBeforeUnmount, onMounted, ref, watch } from 'vue'
|
||||
import { useRoute, useRouter } from 'vue-router'
|
||||
import { ElMessage } from 'element-plus'
|
||||
import { Search, Service, Tickets } from '@element-plus/icons-vue'
|
||||
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 type { PaymentPayWay } from '@/features/orders/api/orders'
|
||||
import { useOrderPaymentCashier } from '@/features/orders/composables/useOrderPaymentCashier'
|
||||
import { queryMohongPayment, startMohongPayment } from '@/features/mohong/api/mohong'
|
||||
import {
|
||||
fetchMohongCategories,
|
||||
fetchMohongProducts,
|
||||
@@ -17,8 +22,38 @@ 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[]>([])
|
||||
@@ -188,6 +223,10 @@ async function handleSupportClick() {
|
||||
@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('/mohong/orders')">
|
||||
<el-icon><Tickets /></el-icon>
|
||||
<span>我的订单</span>
|
||||
@@ -252,6 +291,29 @@ async function handleSupportClick() {
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<MohongCartPanel
|
||||
:select-pay-way="selectPayWay"
|
||||
:start-cashier="(payment, onPaid) => cashier.openPaymentCashier(payment, onPaid)"
|
||||
/>
|
||||
<el-dialog
|
||||
v-model="payWayDialogVisible"
|
||||
title="选择支付方式"
|
||||
width="420px"
|
||||
append-to-body
|
||||
@closed="handlePayWayDialogClosed"
|
||||
>
|
||||
<div class="pay-way-options">
|
||||
<button type="button" class="pay-way-option" @click="choosePayWay('WXZF')">
|
||||
<strong>微信支付</strong>
|
||||
<small>使用微信扫码完成支付</small>
|
||||
</button>
|
||||
<button type="button" class="pay-way-option" @click="choosePayWay('ZFBZF')">
|
||||
<strong>支付宝支付</strong>
|
||||
<small>使用支付宝扫码完成支付</small>
|
||||
</button>
|
||||
</div>
|
||||
</el-dialog>
|
||||
</section>
|
||||
</template>
|
||||
|
||||
@@ -481,6 +543,32 @@ async function handleSupportClick() {
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.pay-way-options {
|
||||
display: grid;
|
||||
gap: 10px;
|
||||
}
|
||||
.pay-way-option {
|
||||
display: grid;
|
||||
gap: 4px;
|
||||
padding: 14px 16px;
|
||||
border: 1px solid #e5e7eb;
|
||||
border-radius: 12px;
|
||||
background: #fff;
|
||||
text-align: left;
|
||||
cursor: pointer;
|
||||
}
|
||||
.pay-way-option:hover {
|
||||
border-color: #ff6a00;
|
||||
}
|
||||
.pay-way-option strong {
|
||||
color: #0f172a;
|
||||
font-size: 15px;
|
||||
}
|
||||
.pay-way-option small {
|
||||
color: #94a3b8;
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
@media (max-width: 900px) {
|
||||
.shop-layout {
|
||||
grid-template-columns: 1fr;
|
||||
|
||||
@@ -148,14 +148,27 @@ async function copyText() {
|
||||
</div>
|
||||
<div>
|
||||
<h2>{{ order.product_title }}</h2>
|
||||
<p>
|
||||
<p v-if="!order.items?.length">
|
||||
¥{{ order.unit_price || formatCent(order.unit_price_cent) }} × {{ order.quantity }}
|
||||
{{ order.product_unit }}
|
||||
</p>
|
||||
<p v-else>共 {{ order.items.length }} 种 · {{ order.quantity }} 件</p>
|
||||
<strong>合计 ¥{{ order.amount || formatCent(order.amount_cent) }}</strong>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div v-if="order.items?.length" class="items-box">
|
||||
<h3>商品明细</h3>
|
||||
<div v-for="item in order.items" :key="item.product_id" class="item-row">
|
||||
<span class="item-title">{{ item.title }}</span>
|
||||
<span
|
||||
>¥{{ item.unit_price || formatCent(item.unit_price_cent) }} × {{ item.quantity }}
|
||||
{{ item.unit }}</span
|
||||
>
|
||||
<strong>¥{{ item.amount || formatCent(item.amount_cent) }}</strong>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div v-if="order.copy_text" class="copy-box">
|
||||
<div class="copy-head">
|
||||
<h3>订单信息(发给客服)</h3>
|
||||
@@ -396,6 +409,36 @@ async function copyText() {
|
||||
font-size: 18px;
|
||||
}
|
||||
|
||||
.items-box {
|
||||
margin-top: 4px;
|
||||
padding: 12px 0 4px;
|
||||
border-top: 1px solid #eef1f5;
|
||||
}
|
||||
.items-box h3 {
|
||||
margin: 0 0 10px;
|
||||
font-size: 14px;
|
||||
color: #17233d;
|
||||
}
|
||||
.item-row {
|
||||
display: grid;
|
||||
grid-template-columns: minmax(0, 1fr) auto auto;
|
||||
gap: 10px;
|
||||
padding: 8px 0;
|
||||
border-bottom: 1px solid #f1f5f9;
|
||||
font-size: 13px;
|
||||
color: #475569;
|
||||
}
|
||||
.item-row:last-child {
|
||||
border-bottom: 0;
|
||||
}
|
||||
.item-title {
|
||||
color: #0f172a;
|
||||
font-weight: 600;
|
||||
}
|
||||
.item-row strong {
|
||||
color: #ff6a00;
|
||||
}
|
||||
|
||||
.copy-box,
|
||||
.qr-box {
|
||||
margin-top: 16px;
|
||||
|
||||
Reference in New Issue
Block a user