Files
mp-pilates/packages/app/src/pages/shop/detail.vue
richarjiang 21e3ece21c feat: 去掉商品取货码,管理员可直接发货完成
已付款订单不再生成或核验取货码,管理员确认后即可完成订单。

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-09-25 11:22:15 +08:00

213 lines
6.8 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.
<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>