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>
|
||||
@@ -206,6 +206,18 @@
|
||||
"style": {
|
||||
"navigationStyle": "custom"
|
||||
}
|
||||
},
|
||||
{
|
||||
"path": "products",
|
||||
"style": {
|
||||
"navigationStyle": "custom"
|
||||
}
|
||||
},
|
||||
{
|
||||
"path": "product-edit",
|
||||
"style": {
|
||||
"navigationStyle": "custom"
|
||||
}
|
||||
}
|
||||
]
|
||||
},
|
||||
@@ -249,6 +261,29 @@
|
||||
}
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"root": "pages/shop",
|
||||
"pages": [
|
||||
{
|
||||
"path": "index",
|
||||
"style": {
|
||||
"navigationStyle": "custom"
|
||||
}
|
||||
},
|
||||
{
|
||||
"path": "detail",
|
||||
"style": {
|
||||
"navigationStyle": "custom"
|
||||
}
|
||||
},
|
||||
{
|
||||
"path": "orders",
|
||||
"style": {
|
||||
"navigationStyle": "custom"
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
],
|
||||
"globalStyle": {
|
||||
|
||||
@@ -62,6 +62,9 @@
|
||||
</view>
|
||||
</view>
|
||||
|
||||
<view class="section-header"><text class="section-title">馆内好物</text></view>
|
||||
<view class="list"><view class="list-item" @tap="navigate('/pages/admin/products')"><text class="item-title">商品、库存与销售订单</text><text class="arrow-text">›</text></view></view>
|
||||
|
||||
<!-- Section: 系统设置 -->
|
||||
<view class="section-header">
|
||||
<text class="section-title">系统设置</text>
|
||||
|
||||
299
packages/app/src/pages/admin/product-edit.vue
Normal file
299
packages/app/src/pages/admin/product-edit.vue
Normal file
@@ -0,0 +1,299 @@
|
||||
<template>
|
||||
<view class="shop-page" :style="{ paddingTop: navBarHeight }">
|
||||
<CustomNavBar :title="id ? '编辑商品' : '添加商品'" show-back />
|
||||
<view v-if="user.isAdmin" class="content">
|
||||
<text class="eyebrow">CURATE YOUR STUDIO</text>
|
||||
<text class="headline">{{ id ? '维护一件好物' : '上新一件好物' }}</text>
|
||||
<text class="muted">每个尺码 / 颜色可单独创建商品,标题中注明规格。</text>
|
||||
<view v-if="loadError" class="state" @tap="load">{{ loadError }} · 点击重试</view>
|
||||
<template v-else-if="ready">
|
||||
<view class="cover-picker" @tap="upload">
|
||||
<image v-if="form.coverUrl" :src="form.coverUrl" mode="aspectFill" />
|
||||
<view class="cover-hint">
|
||||
{{ uploading ? '正在上传…' : form.coverUrl ? '点击更换封面' : '+ 上传商品封面' }}
|
||||
</view>
|
||||
</view>
|
||||
<view class="panel">
|
||||
<view class="field">
|
||||
<text class="label">商品标题(含规格)</text>
|
||||
<input
|
||||
v-model="form.title"
|
||||
class="input"
|
||||
maxlength="80"
|
||||
placeholder="例如:防滑普拉提袜 · 奶白 / 均码"
|
||||
/>
|
||||
</view>
|
||||
<view class="field">
|
||||
<text class="label">价格(元)</text>
|
||||
<input v-model="price" class="input" type="digit" placeholder="0.00" />
|
||||
</view>
|
||||
<view class="field">
|
||||
<text class="label">商品说明</text>
|
||||
<textarea
|
||||
v-model="form.description"
|
||||
class="input textarea"
|
||||
maxlength="5000"
|
||||
placeholder="材质、尺码、适用场景、清洗方式与售后说明…"
|
||||
/>
|
||||
</view>
|
||||
<view class="field">
|
||||
<text class="label">展示顺序(数字越小越靠前)</text>
|
||||
<input v-model="sort" class="input" type="number" />
|
||||
</view>
|
||||
<view class="row">
|
||||
<view>
|
||||
<text class="label">上架展示</text>
|
||||
<text class="muted">下架不影响已有订单的提货</text>
|
||||
</view>
|
||||
<switch :checked="form.isActive" color="#647c6b" @change="toggleActive" />
|
||||
</view>
|
||||
<button class="primary save" :disabled="busy || uploading" :loading="busy" @tap="save">
|
||||
保存商品信息
|
||||
</button>
|
||||
</view>
|
||||
<view v-if="id" class="panel">
|
||||
<text class="item-title">库存维护</text>
|
||||
<view class="notice">
|
||||
当前可售 {{ stock }} 件 · 已售 {{ sold }} 件
|
||||
<text></text>
|
||||
待付款订单已预占库存。这里调整的是可售库存。
|
||||
</view>
|
||||
<view class="field">
|
||||
<text class="label">调整数量(补货填正数,盘亏填负数)</text>
|
||||
<input
|
||||
v-model="delta"
|
||||
class="input"
|
||||
type="text"
|
||||
maxlength="8"
|
||||
placeholder="例如 20 或 -2"
|
||||
/>
|
||||
</view>
|
||||
<view class="field">
|
||||
<text class="label">调整原因</text>
|
||||
<input
|
||||
v-model="reason"
|
||||
class="input"
|
||||
maxlength="200"
|
||||
placeholder="例如:本周到货 / 盘点差异"
|
||||
/>
|
||||
</view>
|
||||
<button class="secondary" :disabled="busy" @tap="adjust">确认调整库存</button>
|
||||
<button class="secondary refresh" :disabled="busy" @tap="reload">刷新商品与库存</button>
|
||||
<view v-if="history.length" class="divider" />
|
||||
<text v-if="history.length" class="label">最近 50 次人工调整</text>
|
||||
<view v-for="h in history" :key="h.id" class="history row">
|
||||
<view>
|
||||
<text class="label">{{ h.reason }}</text>
|
||||
<text class="muted">{{ new Date(h.createdAt).toLocaleString() }}</text>
|
||||
</view>
|
||||
<text class="price">{{ h.delta > 0 ? '+' : '' }}{{ h.delta }}</text>
|
||||
</view>
|
||||
</view>
|
||||
<view v-else class="notice">先保存商品,再补充库存。新商品默认库存为 0。</view>
|
||||
</template>
|
||||
<view v-else class="state">加载中…</view>
|
||||
</view>
|
||||
<view v-else class="state">仅管理员可编辑商品</view>
|
||||
</view>
|
||||
</template>
|
||||
<script setup lang="ts">
|
||||
import { reactive, ref } from 'vue'
|
||||
import { onLoad } from '@dcloudio/uni-app'
|
||||
import type { ShopProduct, ShopStockChange } from '@mp-pilates/shared'
|
||||
import CustomNavBar from '../../components/CustomNavBar.vue'
|
||||
import { getSystemLayout } from '../../utils/system'
|
||||
import { useUserStore } from '../../stores/user'
|
||||
import { useAdminStore } from './stores/admin'
|
||||
import { uploadStudioAsset } from './utils/studio-upload'
|
||||
import { get, post, put } from '../../utils/request'
|
||||
import { shopError, shopMoney } from '../../utils/shop'
|
||||
const navBarHeight = `${getSystemLayout().navBarHeight}px`,
|
||||
user = useUserStore(),
|
||||
admin = useAdminStore()
|
||||
const id = ref(''),
|
||||
price = ref(''),
|
||||
sort = ref('0'),
|
||||
stock = ref(0),
|
||||
sold = ref(0),
|
||||
version = ref(0),
|
||||
delta = ref(''),
|
||||
reason = ref(''),
|
||||
busy = ref(false),
|
||||
uploading = ref(false),
|
||||
ready = ref(false),
|
||||
loadError = ref(''),
|
||||
history = ref<ShopStockChange[]>([])
|
||||
const form = reactive({ title: '', description: '', coverUrl: '', isActive: false })
|
||||
function apply(p: ShopProduct) {
|
||||
Object.assign(form, {
|
||||
title: p.title,
|
||||
description: p.description,
|
||||
coverUrl: p.coverUrl,
|
||||
isActive: p.isActive,
|
||||
})
|
||||
price.value = shopMoney(p.price)
|
||||
sort.value = String(p.sortOrder)
|
||||
stock.value = p.stock
|
||||
sold.value = p.sold
|
||||
version.value = p.version
|
||||
}
|
||||
function toggleActive(event: Event) {
|
||||
form.isActive = (event as unknown as { detail: { value: boolean } }).detail.value
|
||||
}
|
||||
async function load() {
|
||||
loadError.value = ''
|
||||
try {
|
||||
if (id.value) {
|
||||
apply(await get<ShopProduct>(`/admin/shop/products/${id.value}`))
|
||||
history.value = await get<ShopStockChange[]>(`/admin/shop/products/${id.value}/stock`)
|
||||
}
|
||||
ready.value = true
|
||||
} catch (e) {
|
||||
loadError.value = e instanceof Error ? e.message : '加载失败'
|
||||
}
|
||||
}
|
||||
async function reload() {
|
||||
const result = await uni.showModal({
|
||||
title: '刷新商品',
|
||||
content: '将读取最新商品与库存,尚未保存的编辑会被替换。',
|
||||
})
|
||||
if (result.confirm) await load()
|
||||
}
|
||||
async function upload() {
|
||||
if (uploading.value || busy.value) return
|
||||
try {
|
||||
const selection = await uni.chooseImage({
|
||||
count: 1,
|
||||
sizeType: ['compressed'],
|
||||
sourceType: ['album', 'camera'],
|
||||
})
|
||||
const file = selection.tempFilePaths[0]
|
||||
uploading.value = true
|
||||
form.coverUrl = await uploadStudioAsset({
|
||||
adminStore: admin,
|
||||
filePath: file,
|
||||
fileName: file.split('/').pop() || 'product.jpg',
|
||||
assetType: 'product-cover',
|
||||
})
|
||||
} catch (e) {
|
||||
shopError(e)
|
||||
} finally {
|
||||
uploading.value = false
|
||||
}
|
||||
}
|
||||
async function save() {
|
||||
if (busy.value || uploading.value) return
|
||||
busy.value = true
|
||||
try {
|
||||
if (!form.title.trim() || !form.coverUrl) throw new Error('请填写标题并上传封面')
|
||||
if (
|
||||
!/^\d+(\.\d{1,2})?$/.test(price.value) ||
|
||||
Number(price.value) <= 0 ||
|
||||
Number(price.value) > 100000
|
||||
)
|
||||
throw new Error('价格须为 0.01~100000 元,最多两位小数')
|
||||
if (!/^\d+$/.test(sort.value) || Number(sort.value) > 99999)
|
||||
throw new Error('排序须为 0~99999 的整数')
|
||||
const payload = {
|
||||
...form,
|
||||
title: form.title.trim(),
|
||||
price: Math.round(Number(price.value) * 100),
|
||||
sortOrder: Number(sort.value),
|
||||
...(id.value ? { version: version.value } : {}),
|
||||
}
|
||||
const p = id.value
|
||||
? await put<ShopProduct>(`/admin/shop/products/${id.value}`, payload)
|
||||
: await post<ShopProduct>('/admin/shop/products', payload)
|
||||
id.value = p.id
|
||||
apply(p)
|
||||
uni.showToast({ title: '商品已保存' })
|
||||
} catch (e) {
|
||||
shopError(e)
|
||||
} finally {
|
||||
busy.value = false
|
||||
}
|
||||
}
|
||||
async function adjust() {
|
||||
if (busy.value) return
|
||||
if (
|
||||
!/^-?\d+$/.test(delta.value) ||
|
||||
!Number(delta.value) ||
|
||||
Math.abs(Number(delta.value)) > 1000000 ||
|
||||
!reason.value.trim()
|
||||
) {
|
||||
shopError(new Error('请填写有效调整数量和原因'))
|
||||
return
|
||||
}
|
||||
const answer = await uni.showModal({
|
||||
title: '确认库存调整',
|
||||
content: `可售库存 ${stock.value} → ${stock.value + Number(delta.value)} 件。${reason.value}`,
|
||||
})
|
||||
if (!answer.confirm) return
|
||||
busy.value = true
|
||||
try {
|
||||
const p = await post<ShopProduct>(`/admin/shop/products/${id.value}/stock`, {
|
||||
delta: Number(delta.value),
|
||||
version: version.value,
|
||||
reason: reason.value.trim(),
|
||||
})
|
||||
stock.value = p.stock
|
||||
version.value = p.version
|
||||
delta.value = ''
|
||||
reason.value = ''
|
||||
history.value = await get<ShopStockChange[]>(`/admin/shop/products/${id.value}/stock`)
|
||||
uni.showToast({ title: '库存已更新' })
|
||||
} catch (e) {
|
||||
shopError(e)
|
||||
} finally {
|
||||
busy.value = false
|
||||
}
|
||||
}
|
||||
onLoad(async (q) => {
|
||||
id.value = q?.id || ''
|
||||
await user.fetchProfile()
|
||||
if (user.isAdmin) load()
|
||||
})
|
||||
</script>
|
||||
<style scoped src="../shop/shop.css"></style>
|
||||
<style scoped lang="scss">
|
||||
.headline {
|
||||
margin-bottom: 18rpx;
|
||||
}
|
||||
.cover-picker {
|
||||
height: 430rpx;
|
||||
border-radius: 24rpx;
|
||||
background: #e8e7de;
|
||||
margin: 32rpx 0;
|
||||
position: relative;
|
||||
overflow: hidden;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
}
|
||||
.cover-picker image {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
}
|
||||
.cover-hint {
|
||||
position: absolute;
|
||||
bottom: 24rpx;
|
||||
background: #fffc;
|
||||
padding: 14rpx 28rpx;
|
||||
border-radius: 12rpx;
|
||||
font-size: 26rpx;
|
||||
color: #697a5f;
|
||||
}
|
||||
.save {
|
||||
margin-top: 32rpx;
|
||||
}
|
||||
.history {
|
||||
border-top: 1rpx solid #eee8e1;
|
||||
padding: 24rpx 0;
|
||||
}
|
||||
.history .price {
|
||||
font-size: 30rpx;
|
||||
}
|
||||
.history .muted {
|
||||
font-size: 22rpx;
|
||||
}
|
||||
</style>
|
||||
162
packages/app/src/pages/admin/products.vue
Normal file
162
packages/app/src/pages/admin/products.vue
Normal file
@@ -0,0 +1,162 @@
|
||||
<template>
|
||||
<view class="shop-page" :style="{ paddingTop: navBarHeight }">
|
||||
<CustomNavBar title="商品经营" show-back />
|
||||
<view class="content" v-if="user.isAdmin">
|
||||
<text class="eyebrow">STUDIO SHOP · MANAGEMENT</text>
|
||||
<view class="row">
|
||||
<text class="headline">馆内好物经营</text>
|
||||
<button class="primary" @tap="edit()">+ 新商品</button>
|
||||
</view>
|
||||
<view v-if="summary" class="summary">
|
||||
<view>
|
||||
<text class="muted">累计销售额</text>
|
||||
<text class="revenue">¥{{ shopMoney(summary.revenue) }}</text>
|
||||
<text class="muted">{{ summary.orders }} 笔已付款 · {{ summary.quantity }} 件商品</text>
|
||||
</view>
|
||||
<view class="stats">
|
||||
<text>{{ summary.pendingPickup }} 待取货</text>
|
||||
<text>{{ summary.activeProducts }} 已上架</text>
|
||||
<text>低库存 {{ summary.lowStock }} 款</text>
|
||||
</view>
|
||||
</view>
|
||||
<view class="tabs">
|
||||
<text class="tab" :class="{ active: tab === 'products' }" @tap="tab = 'products'">
|
||||
商品与库存
|
||||
</text>
|
||||
<text class="tab" :class="{ active: tab === 'orders' }" @tap="showOrders">订单与提货</text>
|
||||
</view>
|
||||
<view v-show="tab === 'products'">
|
||||
<input
|
||||
v-model="search"
|
||||
class="input search"
|
||||
placeholder="搜索商品标题"
|
||||
confirm-type="search"
|
||||
@confirm="load(true)"
|
||||
/>
|
||||
<text v-if="error" class="error" @tap="load(true)">{{ error }} · 点击重试</text>
|
||||
<view v-if="!items.length" class="state">
|
||||
{{ loading ? '加载中…' : '还没有商品,先添加一件馆内好物吧' }}
|
||||
</view>
|
||||
<view v-for="p in items" :key="p.id" class="panel">
|
||||
<view class="row">
|
||||
<image class="thumb" :src="p.coverUrl" mode="aspectFill" />
|
||||
<view style="flex: 1">
|
||||
<text class="item-title">{{ p.title }}</text>
|
||||
<text class="price">¥{{ shopMoney(p.price) }}</text>
|
||||
<text class="muted">可售 {{ p.stock }} · 已售 {{ p.sold }}</text>
|
||||
</view>
|
||||
<text class="badge">{{ p.isActive ? '已上架' : '已下架' }}</text>
|
||||
</view>
|
||||
<view class="actions">
|
||||
<button class="secondary" @tap="edit(p.id)">编辑 / 调整库存</button>
|
||||
</view>
|
||||
</view>
|
||||
<button
|
||||
v-if="items.length < total"
|
||||
class="secondary"
|
||||
:disabled="loading"
|
||||
@tap="load(false)"
|
||||
>
|
||||
加载更多
|
||||
</button>
|
||||
</view>
|
||||
<ShopOrders v-show="tab === 'orders'" ref="orders" admin @changed="loadSummary" />
|
||||
</view>
|
||||
<view v-else class="state">仅管理员可访问商品管理</view>
|
||||
</view>
|
||||
</template>
|
||||
<script setup lang="ts">
|
||||
import { ref, nextTick } from 'vue'
|
||||
import { onShow } from '@dcloudio/uni-app'
|
||||
import type { ShopProduct, ShopPage, ShopSummary } from '@mp-pilates/shared'
|
||||
import CustomNavBar from '../../components/CustomNavBar.vue'
|
||||
import ShopOrders from '../../components/ShopOrders.vue'
|
||||
import { getSystemLayout } from '../../utils/system'
|
||||
import { useUserStore } from '../../stores/user'
|
||||
import { get } from '../../utils/request'
|
||||
import { shopMoney, shopError } from '../../utils/shop'
|
||||
const navBarHeight = `${getSystemLayout().navBarHeight}px`,
|
||||
user = useUserStore(),
|
||||
items = ref<ShopProduct[]>([]),
|
||||
summary = ref<ShopSummary | null>(null),
|
||||
tab = ref('products'),
|
||||
search = ref(''),
|
||||
page = ref(0),
|
||||
total = ref(0),
|
||||
loading = ref(false),
|
||||
error = ref(''),
|
||||
orders = ref<InstanceType<typeof ShopOrders> | null>(null)
|
||||
async function loadSummary() {
|
||||
try {
|
||||
summary.value = await get<ShopSummary>('/admin/shop/summary')
|
||||
} catch (e) {
|
||||
shopError(e)
|
||||
}
|
||||
}
|
||||
async function load(reset = true) {
|
||||
if (loading.value) return
|
||||
loading.value = true
|
||||
error.value = ''
|
||||
try {
|
||||
const data = await get<ShopPage<ShopProduct>>('/admin/shop/products', {
|
||||
page: reset ? 1 : page.value + 1,
|
||||
search: search.value,
|
||||
})
|
||||
items.value = reset ? data.items : [...items.value, ...data.items]
|
||||
page.value = data.page
|
||||
total.value = data.total
|
||||
} catch (e) {
|
||||
error.value = e instanceof Error ? e.message : '加载失败'
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
const edit = (id?: string) =>
|
||||
uni.navigateTo({ url: `/pages/admin/product-edit${id ? '?id=' + id : ''}` })
|
||||
async function showOrders() {
|
||||
tab.value = 'orders'
|
||||
await nextTick()
|
||||
orders.value?.load(true)
|
||||
}
|
||||
onShow(async () => {
|
||||
await user.fetchProfile()
|
||||
if (user.isAdmin) {
|
||||
load(true)
|
||||
loadSummary()
|
||||
if (tab.value === 'orders') showOrders()
|
||||
}
|
||||
})
|
||||
</script>
|
||||
<style scoped src="../shop/shop.css"></style>
|
||||
<style scoped lang="scss">
|
||||
.headline {
|
||||
font-size: 39rpx;
|
||||
}
|
||||
.summary {
|
||||
margin: 32rpx 0;
|
||||
padding: 32rpx;
|
||||
border-radius: 24rpx;
|
||||
background: #e9eee4;
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
gap: 16rpx;
|
||||
}
|
||||
.revenue {
|
||||
display: block;
|
||||
font-family: serif;
|
||||
font-size: 49rpx;
|
||||
color: #536c54;
|
||||
margin: 16rpx 0;
|
||||
}
|
||||
.stats {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
justify-content: center;
|
||||
gap: 24rpx;
|
||||
color: #667b61;
|
||||
font-size: 23rpx;
|
||||
}
|
||||
.price {
|
||||
font-size: 30rpx;
|
||||
}
|
||||
</style>
|
||||
@@ -16,6 +16,7 @@
|
||||
<view :id="cardShopAnchorId">
|
||||
<CardShop ref="cardShopRef" />
|
||||
</view>
|
||||
<ProductShop ref="productShopRef" />
|
||||
<AboutSection />
|
||||
<view class="bottom-padding" />
|
||||
</view>
|
||||
@@ -32,6 +33,7 @@ import ReviewSummaryCard from '../../components/ReviewSummaryCard.vue'
|
||||
import StudioInfo from '../../components/StudioInfo.vue'
|
||||
import QuickEntry from '../../components/QuickEntry.vue'
|
||||
import UpcomingBooking from '../../components/UpcomingBooking.vue'
|
||||
import ProductShop from '../../components/ProductShop.vue'
|
||||
import CardShop from '../../components/CardShop.vue'
|
||||
import AboutSection from '../../components/AboutSection.vue'
|
||||
|
||||
@@ -62,6 +64,7 @@ onShareTimeline(() => {
|
||||
// ─── Layout ───────────────────────────────────────────────
|
||||
const refreshing = ref(false)
|
||||
const cardShopRef = ref<InstanceType<typeof CardShop> | null>(null)
|
||||
const productShopRef = ref<InstanceType<typeof ProductShop> | null>(null)
|
||||
const cardShopAnchorId = 'card-shop-anchor'
|
||||
const scrollTarget = ref('')
|
||||
const pageHeight = ref(`${uni.getWindowInfo().windowHeight}px`)
|
||||
@@ -105,6 +108,7 @@ async function refreshData() {
|
||||
// Also refresh card shop
|
||||
await Promise.allSettled([
|
||||
cardShopRef.value?.fetchCardTypes(),
|
||||
productShopRef.value?.load(),
|
||||
])
|
||||
}
|
||||
|
||||
|
||||
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>
|
||||
124
packages/app/src/pages/shop/index.vue
Normal file
124
packages/app/src/pages/shop/index.vue
Normal file
@@ -0,0 +1,124 @@
|
||||
<template>
|
||||
<view class="shop-page" :style="{ paddingTop: navBarHeight }">
|
||||
<CustomNavBar title="馆内好物" show-back />
|
||||
<view class="content">
|
||||
<text class="eyebrow">FOCUS CORE · STUDIO EDIT</text>
|
||||
<view class="row">
|
||||
<text class="headline">让每一次练习都更舒适</text>
|
||||
<text class="badge" @tap="orders">我的订单 ›</text>
|
||||
</view>
|
||||
<text class="muted">为日常练习挑选的好物,来馆时顺手带走。</text>
|
||||
<input
|
||||
v-model="search"
|
||||
class="input search"
|
||||
placeholder="搜索瑜伽服、袜子等好物"
|
||||
confirm-type="search"
|
||||
@confirm="load(true)"
|
||||
/>
|
||||
<view v-if="error" class="state" @tap="load(true)">{{ error }} · 点击重试</view>
|
||||
<view v-else-if="!items.length" class="state">
|
||||
{{ loading ? '加载中…' : '暂无相关商品' }}
|
||||
</view>
|
||||
<view class="grid">
|
||||
<view v-for="p in items" :key="p.id" class="product" @tap="detail(p.id)">
|
||||
<view class="cover">
|
||||
<image :src="p.coverUrl" mode="aspectFill" lazy-load />
|
||||
<text v-if="!p.stock" class="sold-out">暂时售罄</text>
|
||||
</view>
|
||||
<text class="item-title">{{ p.title }}</text>
|
||||
<view class="row">
|
||||
<text class="price">¥{{ shopMoney(p.price) }}</text>
|
||||
<text class="muted">{{ p.sold }} 件已售</text>
|
||||
</view>
|
||||
</view>
|
||||
</view>
|
||||
<button
|
||||
v-if="items.length < total"
|
||||
class="secondary load-more"
|
||||
:loading="loading"
|
||||
@tap="load(false)"
|
||||
>
|
||||
加载更多
|
||||
</button>
|
||||
</view>
|
||||
</view>
|
||||
</template>
|
||||
<script setup lang="ts">
|
||||
import { ref } from 'vue'
|
||||
import { onShow } from '@dcloudio/uni-app'
|
||||
import CustomNavBar from '../../components/CustomNavBar.vue'
|
||||
import { getSystemLayout } from '../../utils/system'
|
||||
import { get } from '../../utils/request'
|
||||
import { shopMoney } from '../../utils/shop'
|
||||
import type { ShopProduct, ShopPage } from '@mp-pilates/shared'
|
||||
const navBarHeight = `${getSystemLayout().navBarHeight}px`
|
||||
const items = ref<ShopProduct[]>([]),
|
||||
search = ref(''),
|
||||
page = ref(0),
|
||||
total = ref(0),
|
||||
loading = ref(false),
|
||||
error = ref('')
|
||||
async function load(reset: boolean) {
|
||||
if (loading.value) return
|
||||
loading.value = true
|
||||
error.value = ''
|
||||
try {
|
||||
const result = await get<ShopPage<ShopProduct>>('/shop/products', {
|
||||
page: reset ? 1 : page.value + 1,
|
||||
search: search.value,
|
||||
})
|
||||
items.value = reset ? result.items : [...items.value, ...result.items]
|
||||
total.value = result.total
|
||||
page.value = result.page
|
||||
} catch (e) {
|
||||
error.value = e instanceof Error ? e.message : '加载失败'
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
const detail = (id: string) => uni.navigateTo({ url: `/pages/shop/detail?id=${id}` })
|
||||
const orders = () => uni.navigateTo({ url: '/pages/shop/orders' })
|
||||
onShow(() => load(true))
|
||||
</script>
|
||||
<style scoped src="./shop.css"></style>
|
||||
<style scoped lang="scss">
|
||||
.headline {
|
||||
white-space: pre-line;
|
||||
margin: 12rpx 0 24rpx;
|
||||
}
|
||||
.grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||
gap: 32rpx 22rpx;
|
||||
}
|
||||
.cover {
|
||||
height: 380rpx;
|
||||
background: #e8e4db;
|
||||
border-radius: 20rpx;
|
||||
overflow: hidden;
|
||||
position: relative;
|
||||
margin-bottom: 18rpx;
|
||||
}
|
||||
.cover image {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
}
|
||||
.product .price {
|
||||
font-size: 30rpx;
|
||||
}
|
||||
.product .muted {
|
||||
font-size: 20rpx;
|
||||
}
|
||||
.sold-out {
|
||||
position: absolute;
|
||||
bottom: 20rpx;
|
||||
left: 20rpx;
|
||||
padding: 10rpx 18rpx;
|
||||
background: #fbf9f6e8;
|
||||
font-size: 22rpx;
|
||||
border-radius: 8rpx;
|
||||
}
|
||||
.product .row {
|
||||
margin-top: 12rpx;
|
||||
}
|
||||
</style>
|
||||
58
packages/app/src/pages/shop/orders.vue
Normal file
58
packages/app/src/pages/shop/orders.vue
Normal file
@@ -0,0 +1,58 @@
|
||||
<template>
|
||||
<view class="shop-page" :style="{ paddingTop: navBarHeight }">
|
||||
<CustomNavBar title="我的商品订单" show-back />
|
||||
<view class="content">
|
||||
<text class="eyebrow">YOUR STUDIO FINDS</text>
|
||||
<text class="headline">把喜欢带回家</text>
|
||||
<text class="muted">付款后到馆领取,提货码在订单内查看。</text>
|
||||
<ShopOrders v-if="user.loggedIn" ref="orders" />
|
||||
<view v-else class="state">
|
||||
<text>登录后查看你的商品订单</text>
|
||||
<button class="primary" :disabled="busy" @tap="login">微信登录</button>
|
||||
</view>
|
||||
</view>
|
||||
</view>
|
||||
</template>
|
||||
<script setup lang="ts">
|
||||
import { nextTick, ref } from 'vue'
|
||||
import { onLoad, onShow } from '@dcloudio/uni-app'
|
||||
import CustomNavBar from '../../components/CustomNavBar.vue'
|
||||
import ShopOrders from '../../components/ShopOrders.vue'
|
||||
import { getSystemLayout } from '../../utils/system'
|
||||
import { useUserStore } from '../../stores/user'
|
||||
import { shopError } from '../../utils/shop'
|
||||
const navBarHeight = `${getSystemLayout().navBarHeight}px`,
|
||||
user = useUserStore(),
|
||||
orders = ref<InstanceType<typeof ShopOrders> | null>(null),
|
||||
busy = ref(false)
|
||||
let syncId = ''
|
||||
onLoad((q) => {
|
||||
syncId = q?.id || ''
|
||||
})
|
||||
async function refresh() {
|
||||
await nextTick()
|
||||
if (syncId && orders.value) {
|
||||
const id = syncId
|
||||
syncId = ''
|
||||
await orders.value.syncId(id)
|
||||
} else await orders.value?.load(true)
|
||||
}
|
||||
async function login() {
|
||||
busy.value = true
|
||||
try {
|
||||
await user.loginWithSetup()
|
||||
await refresh()
|
||||
} catch (e) {
|
||||
shopError(e)
|
||||
} finally {
|
||||
busy.value = false
|
||||
}
|
||||
}
|
||||
onShow(refresh)
|
||||
</script>
|
||||
<style scoped src="./shop.css"></style>
|
||||
<style scoped lang="scss">
|
||||
.headline {
|
||||
margin-bottom: 16rpx;
|
||||
}
|
||||
</style>
|
||||
184
packages/app/src/pages/shop/shop.css
Normal file
184
packages/app/src/pages/shop/shop.css
Normal file
@@ -0,0 +1,184 @@
|
||||
.shop-page {
|
||||
min-height: 100vh;
|
||||
box-sizing: border-box;
|
||||
background: #fbf9f6;
|
||||
color: #4a4035;
|
||||
padding-bottom: calc(50rpx + env(safe-area-inset-bottom));
|
||||
}
|
||||
.content {
|
||||
padding: 32rpx;
|
||||
}
|
||||
.eyebrow {
|
||||
display: block;
|
||||
font-size: 19rpx;
|
||||
letter-spacing: 4rpx;
|
||||
color: #83917b;
|
||||
margin-bottom: 16rpx;
|
||||
}
|
||||
.headline {
|
||||
display: block;
|
||||
font-family: serif;
|
||||
font-size: 46rpx;
|
||||
line-height: 1.3;
|
||||
}
|
||||
.muted {
|
||||
display: block;
|
||||
font-size: 25rpx;
|
||||
color: #95897e;
|
||||
line-height: 1.7;
|
||||
}
|
||||
.panel {
|
||||
background: #fff;
|
||||
border: 1rpx solid #ede7df;
|
||||
border-radius: 24rpx;
|
||||
padding: 28rpx;
|
||||
margin-bottom: 24rpx;
|
||||
}
|
||||
.row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 20rpx;
|
||||
}
|
||||
.price {
|
||||
font-size: 36rpx;
|
||||
color: #60785e;
|
||||
}
|
||||
.primary,
|
||||
.secondary {
|
||||
border-radius: 16rpx;
|
||||
font-size: 27rpx;
|
||||
margin: 0;
|
||||
padding: 0 28rpx;
|
||||
line-height: 84rpx;
|
||||
}
|
||||
.primary {
|
||||
background: #627c6c;
|
||||
color: #fff;
|
||||
}
|
||||
.secondary {
|
||||
background: #f0ece5;
|
||||
color: #6c6256;
|
||||
}
|
||||
.primary[disabled] {
|
||||
background: #c5cbbf;
|
||||
color: #fff;
|
||||
}
|
||||
button::after {
|
||||
border: 0;
|
||||
}
|
||||
.state {
|
||||
padding: 100rpx 32rpx;
|
||||
text-align: center;
|
||||
color: #988c80;
|
||||
font-size: 28rpx;
|
||||
}
|
||||
.state button {
|
||||
margin-top: 24rpx;
|
||||
}
|
||||
.field {
|
||||
margin: 24rpx 0;
|
||||
}
|
||||
.label {
|
||||
display: block;
|
||||
font-size: 25rpx;
|
||||
margin-bottom: 14rpx;
|
||||
color: #716657;
|
||||
}
|
||||
.input {
|
||||
box-sizing: border-box;
|
||||
background: #f6f3ee;
|
||||
border-radius: 12rpx;
|
||||
padding: 22rpx;
|
||||
font-size: 28rpx;
|
||||
min-height: 86rpx;
|
||||
width: 100%;
|
||||
}
|
||||
.textarea {
|
||||
height: 220rpx;
|
||||
}
|
||||
.actions {
|
||||
display: flex;
|
||||
gap: 16rpx;
|
||||
align-items: center;
|
||||
justify-content: flex-end;
|
||||
margin-top: 24rpx;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
.thumb {
|
||||
width: 136rpx;
|
||||
height: 156rpx;
|
||||
flex-shrink: 0;
|
||||
border-radius: 14rpx;
|
||||
background: #e9e5dc;
|
||||
}
|
||||
.item-title {
|
||||
font-size: 29rpx;
|
||||
line-height: 1.5;
|
||||
display: block;
|
||||
word-break: break-all;
|
||||
}
|
||||
.badge {
|
||||
display: inline-block;
|
||||
font-size: 22rpx;
|
||||
background: #edf2e9;
|
||||
color: #60785e;
|
||||
padding: 8rpx 16rpx;
|
||||
border-radius: 8rpx;
|
||||
}
|
||||
.search {
|
||||
margin: 24rpx 0;
|
||||
}
|
||||
.tabs {
|
||||
display: flex;
|
||||
gap: 12rpx;
|
||||
flex-wrap: wrap;
|
||||
margin: 24rpx 0;
|
||||
}
|
||||
.tab {
|
||||
padding: 14rpx 22rpx;
|
||||
border-radius: 30rpx;
|
||||
font-size: 24rpx;
|
||||
background: #ede9e2;
|
||||
color: #8f8276;
|
||||
}
|
||||
.tab.active {
|
||||
background: #647c6b;
|
||||
color: #fff;
|
||||
}
|
||||
.divider {
|
||||
height: 1rpx;
|
||||
background: #eee8e1;
|
||||
margin: 24rpx 0;
|
||||
}
|
||||
.order-no {
|
||||
font-size: 21rpx;
|
||||
color: #a09487;
|
||||
word-break: break-all;
|
||||
}
|
||||
.code {
|
||||
font-family: monospace;
|
||||
font-size: 38rpx;
|
||||
letter-spacing: 4rpx;
|
||||
color: #547060;
|
||||
margin: 18rpx 0;
|
||||
display: block;
|
||||
}
|
||||
.load-more {
|
||||
margin: 24rpx auto;
|
||||
}
|
||||
.notice {
|
||||
padding: 22rpx;
|
||||
background: #edf1e9;
|
||||
border-radius: 14rpx;
|
||||
color: #61745f;
|
||||
font-size: 24rpx;
|
||||
line-height: 1.7;
|
||||
margin: 24rpx 0;
|
||||
}
|
||||
.error {
|
||||
color: #aa5d43;
|
||||
font-size: 25rpx;
|
||||
display: block;
|
||||
margin: 20rpx 0;
|
||||
}
|
||||
32
packages/app/src/utils/shop.ts
Normal file
32
packages/app/src/utils/shop.ts
Normal file
@@ -0,0 +1,32 @@
|
||||
import type { ShopOrderStatus, PaymentParams } from '@mp-pilates/shared'
|
||||
import { post } from './request'
|
||||
export const shopMoney = (fen: number) => (fen / 100).toFixed(2)
|
||||
export const shopStatus: Record<ShopOrderStatus, string> = {
|
||||
PENDING: '待付款',
|
||||
PAID: '待到馆取货',
|
||||
COMPLETED: '已取货',
|
||||
CANCELLED: '已取消',
|
||||
}
|
||||
export const shopError = (error: unknown) =>
|
||||
uni.showToast({
|
||||
title: error instanceof Error ? error.message : '操作失败,请重试',
|
||||
icon: 'none',
|
||||
})
|
||||
export async function payShopOrder(id: string) {
|
||||
const params = await post<PaymentParams>(`/shop/orders/${id}/pay`)
|
||||
await new Promise<void>((resolve, reject) =>
|
||||
uni.requestPayment({
|
||||
...params,
|
||||
provider: 'wxpay',
|
||||
success: () => resolve(),
|
||||
fail: (error) =>
|
||||
reject(
|
||||
new Error(
|
||||
error.errMsg.includes('cancel')
|
||||
? '已取消支付,可在订单中继续付款'
|
||||
: '支付未完成,请刷新订单确认状态',
|
||||
),
|
||||
),
|
||||
}),
|
||||
)
|
||||
}
|
||||
Reference in New Issue
Block a user