feat: 支持商品管理
This commit is contained in:
150
packages/app/src/components/ProductShop.vue
Normal file
150
packages/app/src/components/ProductShop.vue
Normal file
@@ -0,0 +1,150 @@
|
||||
<template>
|
||||
<view class="shop-section">
|
||||
<view class="heading" @tap="openShop">
|
||||
<view>
|
||||
<text class="kicker">THE STUDIO EDIT</text>
|
||||
<text class="title">练习之外,好物相伴</text>
|
||||
</view>
|
||||
<text class="more">全部好物 ↗</text>
|
||||
</view>
|
||||
<text class="intro">馆内精选 · 在线购买 · 到馆自提</text>
|
||||
<view v-if="loading" class="state">正在准备好物…</view>
|
||||
<view v-else-if="failed" class="state" @tap="load">好物暂未加载,点击重试 ↻</view>
|
||||
<view v-else-if="!products.length" class="state">好物正在筹备,期待下次相遇</view>
|
||||
<scroll-view v-else scroll-x class="rail">
|
||||
<view class="rail-inner">
|
||||
<view v-for="p in products" :key="p.id" class="product" @tap="detail(p.id)">
|
||||
<view class="cover">
|
||||
<image :src="p.coverUrl" mode="aspectFill" lazy-load />
|
||||
<text class="tag">{{ p.stock ? '到馆自提' : '暂时售罄' }}</text>
|
||||
</view>
|
||||
<text class="name">{{ p.title }}</text>
|
||||
<view class="price-line">
|
||||
<text class="price">¥{{ shopMoney(p.price) }}</text>
|
||||
<text class="arrow">↗</text>
|
||||
</view>
|
||||
</view>
|
||||
</view>
|
||||
</scroll-view>
|
||||
</view>
|
||||
</template>
|
||||
<script setup lang="ts">
|
||||
import { ref } from 'vue'
|
||||
import type { ShopProduct, ShopPage } from '@mp-pilates/shared'
|
||||
import { get } from '../utils/request'
|
||||
import { shopMoney } from '../utils/shop'
|
||||
const products = ref<ShopProduct[]>([]),
|
||||
loading = ref(true),
|
||||
failed = ref(false)
|
||||
async function load() {
|
||||
loading.value = true
|
||||
failed.value = false
|
||||
try {
|
||||
products.value = (await get<ShopPage<ShopProduct>>('/shop/products', { limit: 6 })).items
|
||||
} catch {
|
||||
failed.value = true
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
const openShop = () => uni.navigateTo({ url: '/pages/shop/index' })
|
||||
const detail = (id: string) => uni.navigateTo({ url: `/pages/shop/detail?id=${id}` })
|
||||
defineExpose({ load })
|
||||
</script>
|
||||
<style scoped lang="scss">
|
||||
.shop-section {
|
||||
margin: 40rpx 32rpx;
|
||||
padding: 32rpx 0;
|
||||
border-top: 1rpx solid #e5ded3;
|
||||
}
|
||||
.heading {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
}
|
||||
.kicker {
|
||||
display: block;
|
||||
color: #8b9687;
|
||||
letter-spacing: 4rpx;
|
||||
font-size: 18rpx;
|
||||
}
|
||||
.title {
|
||||
display: block;
|
||||
font-size: 36rpx;
|
||||
color: #493f35;
|
||||
margin-top: 12rpx;
|
||||
font-family: serif;
|
||||
}
|
||||
.more {
|
||||
font-size: 22rpx;
|
||||
color: #617d73;
|
||||
}
|
||||
.intro {
|
||||
display: block;
|
||||
font-size: 23rpx;
|
||||
color: #978b80;
|
||||
margin: 18rpx 0 28rpx;
|
||||
}
|
||||
.rail {
|
||||
width: 100%;
|
||||
}
|
||||
.rail-inner {
|
||||
display: flex;
|
||||
gap: 22rpx;
|
||||
}
|
||||
.product {
|
||||
flex-shrink: 0;
|
||||
width: 270rpx;
|
||||
}
|
||||
.cover {
|
||||
position: relative;
|
||||
height: 320rpx;
|
||||
background: #e9e6df;
|
||||
border-radius: 16rpx;
|
||||
overflow: hidden;
|
||||
}
|
||||
.cover image {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
}
|
||||
.tag {
|
||||
position: absolute;
|
||||
bottom: 16rpx;
|
||||
left: 16rpx;
|
||||
background: #fbf9f6eb;
|
||||
color: #617064;
|
||||
padding: 8rpx 14rpx;
|
||||
border-radius: 8rpx;
|
||||
font-size: 19rpx;
|
||||
}
|
||||
.name {
|
||||
display: block;
|
||||
overflow: hidden;
|
||||
white-space: nowrap;
|
||||
text-overflow: ellipsis;
|
||||
color: #4a4035;
|
||||
font-size: 27rpx;
|
||||
margin-top: 18rpx;
|
||||
}
|
||||
.price-line {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
margin-top: 10rpx;
|
||||
align-items: center;
|
||||
}
|
||||
.price {
|
||||
font-size: 29rpx;
|
||||
color: #6e7e68;
|
||||
}
|
||||
.arrow {
|
||||
color: #8b9687;
|
||||
}
|
||||
.state {
|
||||
padding: 56rpx 24rpx;
|
||||
text-align: center;
|
||||
color: #978b80;
|
||||
background: #f1eee7;
|
||||
border-radius: 20rpx;
|
||||
font-size: 25rpx;
|
||||
}
|
||||
</style>
|
||||
@@ -41,6 +41,7 @@ const emit = defineEmits<{
|
||||
|
||||
const menuItems = computed<MenuItem[]>(() => {
|
||||
const items: MenuItem[] = [
|
||||
{ key: 'shop-orders', type: 'item', title: '我的商品订单', path: '/pages/shop/orders', requireAuth: true },
|
||||
{ key: 'progress', type: 'item', title: '我的成长档案', path: '/pages/profile/progress', requireAuth: true },
|
||||
{ key: 'portrait', type: 'item', title: '身体状态评估', path: '/pages/portrait/index' },
|
||||
{ key: 'plan', type: 'item', title: '我的改善计划', path: '/pages/portrait/plan', requireAuth: true },
|
||||
|
||||
238
packages/app/src/components/ShopOrders.vue
Normal file
238
packages/app/src/components/ShopOrders.vue
Normal file
@@ -0,0 +1,238 @@
|
||||
<template>
|
||||
<view>
|
||||
<input
|
||||
v-if="admin"
|
||||
v-model="search"
|
||||
class="input search"
|
||||
placeholder="搜索商品、订单号或手机号"
|
||||
confirm-type="search"
|
||||
@confirm="load(true)"
|
||||
/>
|
||||
<view class="tabs">
|
||||
<text
|
||||
v-for="tab in tabs"
|
||||
:key="tab.value"
|
||||
class="tab"
|
||||
:class="{ active: status === tab.value }"
|
||||
@tap="filter(tab.value)"
|
||||
>
|
||||
{{ tab.label }}
|
||||
</text>
|
||||
</view>
|
||||
<view class="row">
|
||||
<text class="muted">共 {{ total }} 笔订单</text>
|
||||
<text class="badge" @tap="load(true)">刷新 ↻</text>
|
||||
</view>
|
||||
<view style="height: 24rpx" />
|
||||
<view v-if="error" class="error" @tap="load(true)">{{ error }} · 点击重试</view>
|
||||
<view v-if="!items.length && !error" class="state">
|
||||
{{ loading ? '加载中…' : '这里还没有订单' }}
|
||||
</view>
|
||||
<view v-for="order in items" :key="order.id" class="panel">
|
||||
<view class="row">
|
||||
<text class="muted">{{ date(order.createdAt) }}</text>
|
||||
<text class="badge">{{ shopStatus[order.status] }}</text>
|
||||
</view>
|
||||
<view class="divider" />
|
||||
<view class="row">
|
||||
<image class="thumb" :src="order.coverUrl" mode="aspectFill" />
|
||||
<view style="flex: 1">
|
||||
<text class="item-title">{{ order.title }}</text>
|
||||
<text class="muted">¥{{ shopMoney(order.unitPrice) }} × {{ order.quantity }}</text>
|
||||
<text class="price">¥{{ shopMoney(order.amount) }}</text>
|
||||
</view>
|
||||
</view>
|
||||
<view class="divider" />
|
||||
<text class="muted">{{ order.contactName }} · {{ order.contactPhone }}</text>
|
||||
<text class="order-no" @tap="copy(order.orderNo)">订单号 {{ order.orderNo }} ⧉</text>
|
||||
<template v-if="order.status === 'PAID'">
|
||||
<view v-if="!admin" class="notice">
|
||||
到馆出示提货码
|
||||
<text class="code" @tap="copy(order.pickupCode)">{{ order.pickupCode }}</text>
|
||||
请在领取商品时向工作人员出示。
|
||||
</view>
|
||||
<view v-else class="field">
|
||||
<input
|
||||
v-model="codes[order.id]"
|
||||
class="input"
|
||||
maxlength="12"
|
||||
placeholder="输入顾客的 12 位提货码"
|
||||
/>
|
||||
</view>
|
||||
</template>
|
||||
<text v-if="order.status === 'PENDING'" class="muted">
|
||||
付款截止 {{ date(order.expiresAt) }},超时后自动取消。
|
||||
</text>
|
||||
<view class="actions">
|
||||
<template v-if="order.status === 'PENDING' && !admin">
|
||||
<button class="secondary" :disabled="!!busy" @tap="cancel(order)">取消订单</button>
|
||||
<button class="secondary" :disabled="!!busy" @tap="sync(order)">刷新支付状态</button>
|
||||
<button class="primary" :disabled="!!busy || expired(order)" @tap="pay(order)">
|
||||
继续付款
|
||||
</button>
|
||||
</template>
|
||||
<button
|
||||
v-if="admin && order.status === 'PAID'"
|
||||
class="primary"
|
||||
:disabled="!!busy"
|
||||
@tap="complete(order)"
|
||||
>
|
||||
确认已交付商品
|
||||
</button>
|
||||
</view>
|
||||
</view>
|
||||
<button
|
||||
v-if="items.length < total"
|
||||
class="secondary load-more"
|
||||
:disabled="loading"
|
||||
@tap="load(false)"
|
||||
>
|
||||
加载更多
|
||||
</button>
|
||||
</view>
|
||||
</template>
|
||||
<script setup lang="ts">
|
||||
import { ref } from 'vue'
|
||||
import type { ShopOrder, ShopPage } from '@mp-pilates/shared'
|
||||
import { get, post } from '../utils/request'
|
||||
import { shopMoney, shopStatus, shopError, payShopOrder } from '../utils/shop'
|
||||
const props = defineProps<{ admin?: boolean }>(),
|
||||
emit = defineEmits<{ (e: 'changed'): void }>()
|
||||
const items = ref<ShopOrder[]>([]),
|
||||
status = ref(''),
|
||||
search = ref(''),
|
||||
page = ref(0),
|
||||
total = ref(0),
|
||||
loading = ref(false),
|
||||
error = ref(''),
|
||||
busy = ref(''),
|
||||
codes = ref<Record<string, string>>({})
|
||||
const tabs = [
|
||||
{ value: '', label: '全部' },
|
||||
...Object.entries(shopStatus).map(([value, label]) => ({ value, label })),
|
||||
]
|
||||
const date = (value: string) => {
|
||||
const date = new Date(value)
|
||||
const pad = (n: number) => String(n).padStart(2, '0')
|
||||
return `${date.getFullYear()}-${pad(date.getMonth() + 1)}-${pad(date.getDate())} ${pad(date.getHours())}:${pad(date.getMinutes())}`
|
||||
}
|
||||
const expired = (o: ShopOrder) => new Date(o.expiresAt).getTime() <= Date.now()
|
||||
const copy = (data: string) => uni.setClipboardData({ data })
|
||||
async function load(reset = true) {
|
||||
if (loading.value) return
|
||||
loading.value = true
|
||||
error.value = ''
|
||||
try {
|
||||
const data = await get<ShopPage<ShopOrder>>(
|
||||
props.admin ? '/admin/shop/orders' : '/shop/orders',
|
||||
{
|
||||
page: reset ? 1 : page.value + 1,
|
||||
...(status.value ? { status: status.value } : {}),
|
||||
search: search.value,
|
||||
},
|
||||
)
|
||||
items.value = reset ? data.items : [...items.value, ...data.items]
|
||||
total.value = data.total
|
||||
page.value = data.page
|
||||
} catch (e) {
|
||||
error.value = e instanceof Error ? e.message : '加载失败'
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
function filter(value: string) {
|
||||
if (loading.value) return
|
||||
status.value = value
|
||||
load(true)
|
||||
}
|
||||
async function sync(order: ShopOrder) {
|
||||
if (busy.value) return
|
||||
busy.value = order.id
|
||||
try {
|
||||
const updated = await post<ShopOrder>(`/shop/orders/${order.id}/sync`)
|
||||
items.value = items.value.map((o) => (o.id === updated.id ? updated : o))
|
||||
uni.showToast({
|
||||
title:
|
||||
updated.status === 'PENDING' ? '仍在等待付款确认,请稍后刷新' : shopStatus[updated.status],
|
||||
icon: 'none',
|
||||
})
|
||||
} catch (e) {
|
||||
shopError(e)
|
||||
} finally {
|
||||
busy.value = ''
|
||||
}
|
||||
}
|
||||
async function syncId(id: string) {
|
||||
try {
|
||||
await post(`/shop/orders/${id}/sync`)
|
||||
} catch (e) {
|
||||
shopError(e)
|
||||
}
|
||||
await load(true)
|
||||
}
|
||||
async function pay(order: ShopOrder) {
|
||||
if (busy.value) return
|
||||
busy.value = order.id
|
||||
try {
|
||||
await payShopOrder(order.id)
|
||||
} catch (e) {
|
||||
shopError(e)
|
||||
} finally {
|
||||
busy.value = ''
|
||||
await sync(order)
|
||||
}
|
||||
}
|
||||
async function cancel(order: ShopOrder) {
|
||||
const answer = await uni.showModal({
|
||||
title: '取消订单',
|
||||
content: '确认取消这笔未付款订单?库存会在微信关单确认后释放。',
|
||||
})
|
||||
if (!answer.confirm || busy.value) return
|
||||
busy.value = order.id
|
||||
try {
|
||||
await post(`/shop/orders/${order.id}/cancel`)
|
||||
await load(true)
|
||||
} catch (e) {
|
||||
shopError(e)
|
||||
} finally {
|
||||
busy.value = ''
|
||||
}
|
||||
}
|
||||
async function complete(order: ShopOrder) {
|
||||
const code = (codes.value[order.id] || '').trim().toUpperCase()
|
||||
if (!/^[A-F0-9]{12}$/.test(code)) {
|
||||
shopError(new Error('请输入完整提货码'))
|
||||
return
|
||||
}
|
||||
const answer = await uni.showModal({
|
||||
title: '确认商品已交付',
|
||||
content: `${order.title} × ${order.quantity},取货人 ${order.contactName}。核销后订单将完成。`,
|
||||
})
|
||||
if (!answer.confirm || busy.value) return
|
||||
busy.value = order.id
|
||||
try {
|
||||
await post(`/admin/shop/orders/${order.id}/complete`, { pickupCode: code })
|
||||
await load(true)
|
||||
emit('changed')
|
||||
uni.showToast({ title: '提货完成' })
|
||||
} catch (e) {
|
||||
shopError(e)
|
||||
} finally {
|
||||
busy.value = ''
|
||||
}
|
||||
}
|
||||
defineExpose({ load, syncId })
|
||||
</script>
|
||||
<style scoped src="../pages/shop/shop.css"></style>
|
||||
<style scoped lang="scss">
|
||||
.price {
|
||||
font-size: 31rpx;
|
||||
}
|
||||
.order-no {
|
||||
display: block;
|
||||
margin-top: 14rpx;
|
||||
}
|
||||
.actions:empty {
|
||||
display: none;
|
||||
}
|
||||
</style>
|
||||
Reference in New Issue
Block a user