feat: 支持商品管理

This commit is contained in:
richarjiang
2026-09-16 22:49:47 +08:00
parent 58c6d2092a
commit 95b9f6511c
30 changed files with 2800 additions and 5 deletions

View 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>