213 lines
6.8 KiB
Vue
213 lines
6.8 KiB
Vue
<template>
|
||
<view class="shop-page" :style="{ paddingTop: navBarHeight }">
|
||
<CustomNavBar title="好物详情" show-back />
|
||
<view v-if="error" class="state" @tap="load">{{ error }} · 点击重试</view>
|
||
<view v-else-if="!product" class="state">加载中…</view>
|
||
<template v-else>
|
||
<image class="hero" :src="product.coverUrl" mode="aspectFill" @tap="preview" />
|
||
<view class="content">
|
||
<text class="eyebrow">SELECTED FOR YOUR PRACTICE</text>
|
||
<view class="row">
|
||
<text class="headline">{{ product.title }}</text>
|
||
</view>
|
||
<view class="row pricing">
|
||
<text class="price">¥{{ shopMoney(product.price) }}</text>
|
||
<text class="muted">可售 {{ product.stock }} 件 · 已售 {{ product.sold }} 件</text>
|
||
</view>
|
||
<view class="notice">
|
||
到馆自提 · 付款后由工作人员确认交付
|
||
<text></text>
|
||
{{ studio.studioInfo?.address || '到馆领取前可联系场馆确认营业时间' }}
|
||
<text v-if="studio.studioInfo?.phone" @tap="call">· 联系场馆 ›</text>
|
||
</view>
|
||
<view class="panel">
|
||
<text class="label">关于这件好物</text>
|
||
<text class="description">{{ product.description || '详情请咨询馆内工作人员。' }}</text>
|
||
</view>
|
||
<view class="panel">
|
||
<view class="row">
|
||
<text class="label">购买数量</text>
|
||
<view class="stepper">
|
||
<button :disabled="quantity <= 1 || busy" @tap="quantity--">−</button>
|
||
<text>{{ quantity }}</text>
|
||
<button :disabled="quantity >= Math.min(product.stock, 99) || busy" @tap="quantity++">
|
||
+
|
||
</button>
|
||
</view>
|
||
</view>
|
||
<view class="field">
|
||
<text class="label">取货人</text>
|
||
<input
|
||
v-model="name"
|
||
class="input"
|
||
maxlength="40"
|
||
placeholder="填写取货人姓名"
|
||
:disabled="busy"
|
||
/>
|
||
</view>
|
||
<view class="field">
|
||
<text class="label">联系电话</text>
|
||
<input
|
||
v-model="phone"
|
||
class="input"
|
||
type="number"
|
||
maxlength="11"
|
||
placeholder="用于取货联系"
|
||
:disabled="busy"
|
||
/>
|
||
</view>
|
||
<text class="muted">下单后保留库存 15 分钟;未付款超时自动取消。</text>
|
||
</view>
|
||
<view class="row">
|
||
<text class="muted" @tap="orders">查看我的商品订单 ›</text>
|
||
<button open-type="share" class="secondary">分享好物</button>
|
||
</view>
|
||
</view>
|
||
<view class="buy-bar">
|
||
<view>
|
||
<text class="muted">合计 · {{ quantity }} 件</text>
|
||
<text class="price">¥{{ shopMoney(product.price * quantity) }}</text>
|
||
</view>
|
||
<button class="primary" :disabled="busy || !product.stock" :loading="busy" @tap="buy">
|
||
{{ !product.stock ? '暂时售罄' : user.loggedIn ? '立即购买' : '登录并购买' }}
|
||
</button>
|
||
</view>
|
||
</template>
|
||
</view>
|
||
</template>
|
||
<script setup lang="ts">
|
||
import { ref } from 'vue'
|
||
import { onLoad, onShow, onShareAppMessage } from '@dcloudio/uni-app'
|
||
import type { ShopProduct, ShopOrder } from '@mp-pilates/shared'
|
||
import CustomNavBar from '../../components/CustomNavBar.vue'
|
||
import { getSystemLayout } from '../../utils/system'
|
||
import { get, post } from '../../utils/request'
|
||
import { shopMoney, shopError, payShopOrder } from '../../utils/shop'
|
||
import { useUserStore } from '../../stores/user'
|
||
import { useStudioStore } from '../../stores/studio'
|
||
const navBarHeight = `${getSystemLayout().navBarHeight}px`,
|
||
user = useUserStore(),
|
||
studio = useStudioStore()
|
||
const id = ref(''),
|
||
product = ref<ShopProduct | null>(null),
|
||
quantity = ref(1),
|
||
name = ref(user.user?.nickname || ''),
|
||
phone = ref(user.user?.phone || ''),
|
||
busy = ref(false),
|
||
error = ref('')
|
||
let attempt: { fingerprint: string; requestId: string } | null = null
|
||
async function load() {
|
||
if (!id.value) return
|
||
error.value = ''
|
||
try {
|
||
product.value = await get<ShopProduct>(`/shop/products/${id.value}`)
|
||
quantity.value = Math.max(1, Math.min(quantity.value, product.value.stock))
|
||
} catch (e) {
|
||
error.value = e instanceof Error ? e.message : '加载失败'
|
||
}
|
||
}
|
||
async function buy() {
|
||
if (busy.value || !product.value) return
|
||
busy.value = true
|
||
let order: ShopOrder | undefined
|
||
try {
|
||
if (!user.loggedIn) {
|
||
const result = await user.loginWithSetup()
|
||
if (result.isNewUser) return
|
||
}
|
||
if (!name.value.trim() || !/^1[3-9]\d{9}$/.test(phone.value))
|
||
throw new Error('请填写取货人和有效的手机号码')
|
||
const data = {
|
||
productId: id.value,
|
||
expectedPrice: product.value.price,
|
||
quantity: quantity.value,
|
||
contactName: name.value.trim(),
|
||
contactPhone: phone.value,
|
||
}
|
||
const fingerprint = JSON.stringify(data)
|
||
if (!attempt || attempt.fingerprint !== fingerprint)
|
||
attempt = {
|
||
fingerprint,
|
||
requestId: `buy_${Date.now()}_${Math.random().toString(36).slice(2)}`,
|
||
}
|
||
order = await post<ShopOrder>('/shop/orders', { ...data, requestId: attempt.requestId })
|
||
attempt = null
|
||
await payShopOrder(order.id)
|
||
} catch (e) {
|
||
shopError(e)
|
||
} finally {
|
||
busy.value = false
|
||
if (!order) await load()
|
||
if (order) uni.navigateTo({ url: `/pages/shop/orders?id=${order.id}` })
|
||
}
|
||
}
|
||
const orders = () => uni.navigateTo({ url: '/pages/shop/orders' })
|
||
const preview = () => product.value && uni.previewImage({ urls: [product.value.coverUrl] })
|
||
const call = () =>
|
||
studio.studioInfo?.phone && uni.makePhoneCall({ phoneNumber: studio.studioInfo.phone })
|
||
onLoad((q) => {
|
||
id.value = q?.id || ''
|
||
})
|
||
onShow(() => {
|
||
load()
|
||
studio.fetchStudioInfo()
|
||
})
|
||
onShareAppMessage(() => ({
|
||
title: product.value?.title || '馆内好物',
|
||
path: `/pages/shop/detail?id=${id.value}`,
|
||
imageUrl: product.value?.coverUrl,
|
||
}))
|
||
</script>
|
||
<style scoped src="./shop.css"></style>
|
||
<style scoped lang="scss">
|
||
.shop-page {
|
||
padding-bottom: calc(180rpx + env(safe-area-inset-bottom));
|
||
}
|
||
.hero {
|
||
width: 100%;
|
||
height: 750rpx;
|
||
background: #eae5db;
|
||
}
|
||
.pricing {
|
||
margin: 24rpx 0;
|
||
}
|
||
.headline {
|
||
font-size: 42rpx;
|
||
}
|
||
.description {
|
||
white-space: pre-wrap;
|
||
font-size: 28rpx;
|
||
line-height: 1.9;
|
||
color: #89796b;
|
||
}
|
||
.stepper {
|
||
display: flex;
|
||
align-items: center;
|
||
gap: 24rpx;
|
||
}
|
||
.stepper button {
|
||
width: 64rpx;
|
||
height: 64rpx;
|
||
line-height: 60rpx;
|
||
margin: 0;
|
||
padding: 0;
|
||
background: #f1eee8;
|
||
}
|
||
.buy-bar {
|
||
position: fixed;
|
||
bottom: 0;
|
||
left: 0;
|
||
right: 0;
|
||
padding: 24rpx 32rpx calc(24rpx + env(safe-area-inset-bottom));
|
||
background: #fff;
|
||
box-shadow: 0 -4rpx 30rpx #5b48300b;
|
||
display: flex;
|
||
justify-content: space-between;
|
||
align-items: center;
|
||
z-index: 10;
|
||
}
|
||
.buy-bar .primary {
|
||
min-width: 280rpx;
|
||
}
|
||
</style>
|