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

View File

@@ -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 },

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