feat: 支持商品管理
This commit is contained in:
212
packages/app/src/pages/shop/detail.vue
Normal file
212
packages/app/src/pages/shop/detail.vue
Normal file
@@ -0,0 +1,212 @@
|
||||
<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>
|
||||
Reference in New Issue
Block a user