perf: 完善订单管理
This commit is contained in:
@@ -35,6 +35,7 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { onMounted, ref } from 'vue'
|
import { onMounted, ref } from 'vue'
|
||||||
import type { StudioConfig } from '@mp-pilates/shared'
|
import type { StudioConfig } from '@mp-pilates/shared'
|
||||||
|
import { getSystemLayout } from '../utils/system'
|
||||||
|
|
||||||
defineProps<{
|
defineProps<{
|
||||||
studioInfo: StudioConfig | null
|
studioInfo: StudioConfig | null
|
||||||
@@ -43,8 +44,7 @@ defineProps<{
|
|||||||
const statusBarHeight = ref(0)
|
const statusBarHeight = ref(0)
|
||||||
|
|
||||||
onMounted(() => {
|
onMounted(() => {
|
||||||
const sysInfo = uni.getSystemInfoSync()
|
statusBarHeight.value = getSystemLayout().statusBarHeight
|
||||||
statusBarHeight.value = sysInfo.statusBarHeight ?? 20
|
|
||||||
})
|
})
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
|
|||||||
@@ -13,9 +13,13 @@
|
|||||||
hover-stay-time="150"
|
hover-stay-time="150"
|
||||||
@tap="handleTap(item)"
|
@tap="handleTap(item)"
|
||||||
>
|
>
|
||||||
<view class="profile-menu__icon-wrap" :class="{ 'profile-menu__icon-wrap--admin': item.isAdmin }">
|
<view
|
||||||
<text class="profile-menu__icon">{{ item.icon }}</text>
|
class="profile-menu__icon-wrap"
|
||||||
</view>
|
:class="[
|
||||||
|
`profile-menu__icon-wrap--${item.key}`,
|
||||||
|
{ 'profile-menu__icon-wrap--admin': item.isAdmin },
|
||||||
|
]"
|
||||||
|
/>
|
||||||
<text class="profile-menu__title" :class="{ 'profile-menu__title--admin': item.isAdmin }">
|
<text class="profile-menu__title" :class="{ 'profile-menu__title--admin': item.isAdmin }">
|
||||||
{{ item.title }}
|
{{ item.title }}
|
||||||
</text>
|
</text>
|
||||||
@@ -32,7 +36,6 @@ import { computed } from 'vue'
|
|||||||
interface MenuItem {
|
interface MenuItem {
|
||||||
key: string
|
key: string
|
||||||
type: 'item' | 'separator'
|
type: 'item' | 'separator'
|
||||||
icon?: string
|
|
||||||
title?: string
|
title?: string
|
||||||
path?: string
|
path?: string
|
||||||
isAdmin?: boolean
|
isAdmin?: boolean
|
||||||
@@ -57,7 +60,6 @@ const menuItems = computed<MenuItem[]>(() => {
|
|||||||
{
|
{
|
||||||
key: 'membership',
|
key: 'membership',
|
||||||
type: 'item',
|
type: 'item',
|
||||||
icon: '💳',
|
|
||||||
title: '我的会员卡',
|
title: '我的会员卡',
|
||||||
path: '/pages/profile/membership',
|
path: '/pages/profile/membership',
|
||||||
requireAuth: true,
|
requireAuth: true,
|
||||||
@@ -65,7 +67,6 @@ const menuItems = computed<MenuItem[]>(() => {
|
|||||||
{
|
{
|
||||||
key: 'bookings',
|
key: 'bookings',
|
||||||
type: 'item',
|
type: 'item',
|
||||||
icon: '📅',
|
|
||||||
title: '我的预约',
|
title: '我的预约',
|
||||||
path: '/pages/profile/bookings',
|
path: '/pages/profile/bookings',
|
||||||
requireAuth: true,
|
requireAuth: true,
|
||||||
@@ -73,7 +74,6 @@ const menuItems = computed<MenuItem[]>(() => {
|
|||||||
{
|
{
|
||||||
key: 'info',
|
key: 'info',
|
||||||
type: 'item',
|
type: 'item',
|
||||||
icon: '👤',
|
|
||||||
title: '个人信息',
|
title: '个人信息',
|
||||||
path: '/pages/profile/info',
|
path: '/pages/profile/info',
|
||||||
requireAuth: true,
|
requireAuth: true,
|
||||||
@@ -85,14 +85,12 @@ const menuItems = computed<MenuItem[]>(() => {
|
|||||||
{
|
{
|
||||||
key: 'clear',
|
key: 'clear',
|
||||||
type: 'item',
|
type: 'item',
|
||||||
icon: '🗑️',
|
|
||||||
title: '清除缓存',
|
title: '清除缓存',
|
||||||
action: 'clear',
|
action: 'clear',
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
key: 'about',
|
key: 'about',
|
||||||
type: 'item',
|
type: 'item',
|
||||||
icon: 'ℹ️',
|
|
||||||
title: '关于我们',
|
title: '关于我们',
|
||||||
action: 'about',
|
action: 'about',
|
||||||
},
|
},
|
||||||
@@ -103,7 +101,6 @@ const menuItems = computed<MenuItem[]>(() => {
|
|||||||
items.push({
|
items.push({
|
||||||
key: 'admin',
|
key: 'admin',
|
||||||
type: 'item',
|
type: 'item',
|
||||||
icon: '⚙️',
|
|
||||||
title: '管理中心',
|
title: '管理中心',
|
||||||
path: '/pages/admin/index',
|
path: '/pages/admin/index',
|
||||||
isAdmin: true,
|
isAdmin: true,
|
||||||
@@ -163,24 +160,177 @@ function handleTap(item: MenuItem) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
&__icon-wrap {
|
&__icon-wrap {
|
||||||
width: 64rpx;
|
width: 56rpx;
|
||||||
height: 64rpx;
|
height: 56rpx;
|
||||||
border-radius: $radius-sm;
|
border-radius: 50%;
|
||||||
background: rgba($brand-color, 0.08);
|
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
justify-content: center;
|
justify-content: center;
|
||||||
flex-shrink: 0;
|
flex-shrink: 0;
|
||||||
margin-right: $spacing-md;
|
margin-right: $spacing-md;
|
||||||
|
position: relative;
|
||||||
|
background: rgba($brand-color, 0.06);
|
||||||
|
|
||||||
|
// ─── Pure CSS Icons ────────────────────────────────
|
||||||
|
|
||||||
|
// 会员卡 — 圆角矩形卡片 + 横线
|
||||||
|
&--membership {
|
||||||
|
background: rgba($accent-color, 0.10);
|
||||||
|
&::before {
|
||||||
|
content: '';
|
||||||
|
width: 26rpx;
|
||||||
|
height: 18rpx;
|
||||||
|
border: 2.5rpx solid $accent-color;
|
||||||
|
border-radius: 4rpx;
|
||||||
|
box-sizing: border-box;
|
||||||
|
}
|
||||||
|
&::after {
|
||||||
|
content: '';
|
||||||
|
position: absolute;
|
||||||
|
top: 50%;
|
||||||
|
left: 50%;
|
||||||
|
transform: translate(-50%, 1rpx);
|
||||||
|
width: 16rpx;
|
||||||
|
height: 0;
|
||||||
|
border-top: 2.5rpx solid $accent-color;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 预约 — 日历(矩形 + 顶部两个小竖线)
|
||||||
|
&--bookings {
|
||||||
|
background: rgba($brand-color, 0.06);
|
||||||
|
&::before {
|
||||||
|
content: '';
|
||||||
|
width: 24rpx;
|
||||||
|
height: 22rpx;
|
||||||
|
border: 2.5rpx solid $brand-color;
|
||||||
|
border-radius: 4rpx;
|
||||||
|
border-top-width: 5rpx;
|
||||||
|
box-sizing: border-box;
|
||||||
|
}
|
||||||
|
&::after {
|
||||||
|
content: '';
|
||||||
|
position: absolute;
|
||||||
|
top: 14rpx;
|
||||||
|
left: 50%;
|
||||||
|
transform: translateX(-50%);
|
||||||
|
width: 10rpx;
|
||||||
|
height: 0;
|
||||||
|
border-top: 2.5rpx solid $brand-color;
|
||||||
|
// 用 box-shadow 模拟两个竖线
|
||||||
|
box-shadow:
|
||||||
|
-4rpx -7rpx 0 0 $brand-color,
|
||||||
|
4rpx -7rpx 0 0 $brand-color;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 个人信息 — 人形(圆 + 肩弧)
|
||||||
|
&--info {
|
||||||
|
background: rgba($brand-color, 0.06);
|
||||||
|
&::before {
|
||||||
|
content: '';
|
||||||
|
width: 12rpx;
|
||||||
|
height: 12rpx;
|
||||||
|
border: 2.5rpx solid $brand-color;
|
||||||
|
border-radius: 50%;
|
||||||
|
position: absolute;
|
||||||
|
top: 16rpx;
|
||||||
|
left: 50%;
|
||||||
|
transform: translateX(-50%);
|
||||||
|
box-sizing: border-box;
|
||||||
|
}
|
||||||
|
&::after {
|
||||||
|
content: '';
|
||||||
|
width: 22rpx;
|
||||||
|
height: 10rpx;
|
||||||
|
border: 2.5rpx solid $brand-color;
|
||||||
|
border-bottom: none;
|
||||||
|
border-radius: 12rpx 12rpx 0 0;
|
||||||
|
position: absolute;
|
||||||
|
bottom: 13rpx;
|
||||||
|
left: 50%;
|
||||||
|
transform: translateX(-50%);
|
||||||
|
box-sizing: border-box;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 清除缓存 — 旋转的刷新箭头(圆弧)
|
||||||
|
&--clear {
|
||||||
|
background: rgba($text-hint, 0.08);
|
||||||
|
&::before {
|
||||||
|
content: '';
|
||||||
|
width: 20rpx;
|
||||||
|
height: 20rpx;
|
||||||
|
border: 2.5rpx solid $text-secondary;
|
||||||
|
border-radius: 50%;
|
||||||
|
border-right-color: transparent;
|
||||||
|
box-sizing: border-box;
|
||||||
|
}
|
||||||
|
&::after {
|
||||||
|
content: '';
|
||||||
|
position: absolute;
|
||||||
|
top: 14rpx;
|
||||||
|
right: 15rpx;
|
||||||
|
width: 0;
|
||||||
|
height: 0;
|
||||||
|
border-left: 5rpx solid $text-secondary;
|
||||||
|
border-top: 4rpx solid transparent;
|
||||||
|
border-bottom: 4rpx solid transparent;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 关于我们 — 圆形中心一个点 + 竖线(info 标记)
|
||||||
|
&--about {
|
||||||
|
background: rgba($text-hint, 0.08);
|
||||||
|
&::before {
|
||||||
|
content: '';
|
||||||
|
width: 22rpx;
|
||||||
|
height: 22rpx;
|
||||||
|
border: 2.5rpx solid $text-secondary;
|
||||||
|
border-radius: 50%;
|
||||||
|
box-sizing: border-box;
|
||||||
|
}
|
||||||
|
&::after {
|
||||||
|
content: '';
|
||||||
|
position: absolute;
|
||||||
|
top: 50%;
|
||||||
|
left: 50%;
|
||||||
|
transform: translate(-50%, -50%);
|
||||||
|
width: 2.5rpx;
|
||||||
|
height: 8rpx;
|
||||||
|
background: $text-secondary;
|
||||||
|
border-radius: 1rpx;
|
||||||
|
box-shadow: 0 -6rpx 0 0 $text-secondary;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 管理中心 — 齿轮(圆 + 四个刻度)
|
||||||
&--admin {
|
&--admin {
|
||||||
background: rgba($accent-color, 0.12);
|
background: rgba($accent-color, 0.12);
|
||||||
|
&::before {
|
||||||
|
content: '';
|
||||||
|
width: 14rpx;
|
||||||
|
height: 14rpx;
|
||||||
|
border: 2.5rpx solid $accent-color;
|
||||||
|
border-radius: 50%;
|
||||||
|
box-sizing: border-box;
|
||||||
|
}
|
||||||
|
&::after {
|
||||||
|
content: '';
|
||||||
|
position: absolute;
|
||||||
|
top: 50%;
|
||||||
|
left: 50%;
|
||||||
|
width: 24rpx;
|
||||||
|
height: 24rpx;
|
||||||
|
transform: translate(-50%, -50%);
|
||||||
|
// 四条刻度线用 box-shadow 实现
|
||||||
|
background:
|
||||||
|
linear-gradient($accent-color, $accent-color) center top / 2.5rpx 5rpx no-repeat,
|
||||||
|
linear-gradient($accent-color, $accent-color) center bottom / 2.5rpx 5rpx no-repeat,
|
||||||
|
linear-gradient($accent-color, $accent-color) left center / 5rpx 2.5rpx no-repeat,
|
||||||
|
linear-gradient($accent-color, $accent-color) right center / 5rpx 2.5rpx no-repeat;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
&__icon {
|
|
||||||
font-size: 32rpx;
|
|
||||||
line-height: 1;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
&__title {
|
&__title {
|
||||||
|
|||||||
@@ -5,7 +5,7 @@
|
|||||||
<!-- Not logged in state -->
|
<!-- Not logged in state -->
|
||||||
<view v-if="!loggedIn" class="user-card__guest">
|
<view v-if="!loggedIn" class="user-card__guest">
|
||||||
<view class="user-card__avatar-wrap">
|
<view class="user-card__avatar-wrap">
|
||||||
<image class="user-card__avatar-img" src="/static/default-avatar.png" mode="aspectFill" />
|
<image class="user-card__avatar-img" src="/static/default-avatar.jpg" mode="aspectFill" />
|
||||||
</view>
|
</view>
|
||||||
<view class="user-card__guest-info">
|
<view class="user-card__guest-info">
|
||||||
<text class="user-card__guest-title">Hi,欢迎来到普拉提</text>
|
<text class="user-card__guest-title">Hi,欢迎来到普拉提</text>
|
||||||
@@ -25,9 +25,10 @@
|
|||||||
mode="aspectFill"
|
mode="aspectFill"
|
||||||
@error="onAvatarError"
|
@error="onAvatarError"
|
||||||
/>
|
/>
|
||||||
<view class="user-card__vip-badge" v-if="vipLevel">
|
<!-- VIP badge hidden for now -->
|
||||||
|
<!-- <view class="user-card__vip-badge" v-if="vipLevel">
|
||||||
<text class="user-card__vip-text">{{ vipLevel }}</text>
|
<text class="user-card__vip-text">{{ vipLevel }}</text>
|
||||||
</view>
|
</view> -->
|
||||||
</view>
|
</view>
|
||||||
<view class="user-card__info">
|
<view class="user-card__info">
|
||||||
<view class="user-card__name-row">
|
<view class="user-card__name-row">
|
||||||
@@ -105,7 +106,7 @@ watch(
|
|||||||
|
|
||||||
const avatarSrc = computed(() => {
|
const avatarSrc = computed(() => {
|
||||||
if (avatarFailed.value || !props.user?.avatarUrl) {
|
if (avatarFailed.value || !props.user?.avatarUrl) {
|
||||||
return '/static/default-avatar.png'
|
return '/static/default-avatar.jpg'
|
||||||
}
|
}
|
||||||
return props.user.avatarUrl
|
return props.user.avatarUrl
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -3,25 +3,19 @@
|
|||||||
{
|
{
|
||||||
"path": "pages/home/index",
|
"path": "pages/home/index",
|
||||||
"style": {
|
"style": {
|
||||||
"navigationStyle": "custom",
|
"navigationStyle": "custom"
|
||||||
"enableShareAppMessage": true,
|
|
||||||
"enableShareTimeline": true
|
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"path": "pages/booking/index",
|
"path": "pages/booking/index",
|
||||||
"style": {
|
"style": {
|
||||||
"navigationStyle": "custom",
|
"navigationStyle": "custom"
|
||||||
"enableShareAppMessage": true,
|
|
||||||
"enableShareTimeline": true
|
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"path": "pages/profile/index",
|
"path": "pages/profile/index",
|
||||||
"style": {
|
"style": {
|
||||||
"navigationStyle": "custom",
|
"navigationStyle": "custom"
|
||||||
"enableShareAppMessage": true,
|
|
||||||
"enableShareTimeline": true
|
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -212,6 +212,7 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { ref, onMounted } from 'vue'
|
import { ref, onMounted } from 'vue'
|
||||||
import CustomNavBar from '../../components/CustomNavBar.vue'
|
import CustomNavBar from '../../components/CustomNavBar.vue'
|
||||||
|
import { getSystemLayout } from '../../utils/system'
|
||||||
import { useAdminStore } from '../../stores/admin'
|
import { useAdminStore } from '../../stores/admin'
|
||||||
import { formatPrice } from '../../utils/format'
|
import { formatPrice } from '../../utils/format'
|
||||||
import { CardTypeCategory } from '@mp-pilates/shared'
|
import { CardTypeCategory } from '@mp-pilates/shared'
|
||||||
@@ -221,8 +222,7 @@ const adminStore = useAdminStore()
|
|||||||
|
|
||||||
const navBarHeight = ref('64px')
|
const navBarHeight = ref('64px')
|
||||||
onMounted(() => {
|
onMounted(() => {
|
||||||
const sys = uni.getSystemInfoSync()
|
navBarHeight.value = `${getSystemLayout().navBarHeight}px`
|
||||||
navBarHeight.value = `${(sys.statusBarHeight ?? 20) + Math.round(88 * sys.windowWidth / 750)}px`
|
|
||||||
})
|
})
|
||||||
|
|
||||||
const cardTypes = ref<CardType[]>([])
|
const cardTypes = ref<CardType[]>([])
|
||||||
|
|||||||
@@ -42,6 +42,7 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { ref, onMounted } from 'vue'
|
import { ref, onMounted } from 'vue'
|
||||||
import CustomNavBar from '../../components/CustomNavBar.vue'
|
import CustomNavBar from '../../components/CustomNavBar.vue'
|
||||||
|
import { getSystemLayout } from '../../utils/system'
|
||||||
import { useAdminStore } from '../../stores/admin'
|
import { useAdminStore } from '../../stores/admin'
|
||||||
import type { AdminStats } from '../../stores/admin'
|
import type { AdminStats } from '../../stores/admin'
|
||||||
|
|
||||||
@@ -77,8 +78,7 @@ async function loadStats() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
onMounted(() => {
|
onMounted(() => {
|
||||||
const sys = uni.getSystemInfoSync()
|
navBarHeight.value = `${getSystemLayout().navBarHeight}px`
|
||||||
navBarHeight.value = `${(sys.statusBarHeight ?? 20) + Math.round(88 * sys.windowWidth / 750)}px`
|
|
||||||
loadStats()
|
loadStats()
|
||||||
})
|
})
|
||||||
</script>
|
</script>
|
||||||
|
|||||||
@@ -1,16 +1,20 @@
|
|||||||
<template>
|
<template>
|
||||||
<view class="page" :style="{ paddingTop: navBarHeight }">
|
<view class="page" :style="{ paddingTop: navBarHeight }">
|
||||||
<CustomNavBar title="会员管理" show-back />
|
<CustomNavBar title="会员管理" show-back />
|
||||||
|
|
||||||
<!-- Search bar -->
|
<!-- Search bar -->
|
||||||
<view class="filter-bar">
|
<view class="filter-bar">
|
||||||
<input
|
<input
|
||||||
class="search-input"
|
class="search-input"
|
||||||
v-model="searchQuery"
|
v-model="searchQuery"
|
||||||
placeholder="搜索昵称或手机号"
|
placeholder="搜索昵称或 OpenID"
|
||||||
placeholder-style="color:#bbb"
|
placeholder-style="color:#bbb"
|
||||||
@confirm="onSearch"
|
@confirm="onSearch"
|
||||||
confirm-type="search"
|
confirm-type="search"
|
||||||
/>
|
/>
|
||||||
|
<view v-if="searchQuery" class="search-clear" @tap="onClear">
|
||||||
|
<text class="search-clear-icon">×</text>
|
||||||
|
</view>
|
||||||
<view class="search-btn" @tap="onSearch">
|
<view class="search-btn" @tap="onSearch">
|
||||||
<text class="search-btn-text">搜索</text>
|
<text class="search-btn-text">搜索</text>
|
||||||
</view>
|
</view>
|
||||||
@@ -31,8 +35,10 @@
|
|||||||
|
|
||||||
<!-- Empty -->
|
<!-- Empty -->
|
||||||
<view v-else-if="!loading && !members.length" class="empty-state">
|
<view v-else-if="!loading && !members.length" class="empty-state">
|
||||||
<text class="empty-icon">👥</text>
|
<view class="empty-icon-wrap">
|
||||||
<text class="empty-text">暂无会员数据</text>
|
<view class="empty-icon-person" />
|
||||||
|
</view>
|
||||||
|
<text class="empty-text">{{ searchQuery ? '未找到匹配的会员' : '暂无会员数据' }}</text>
|
||||||
</view>
|
</view>
|
||||||
|
|
||||||
<!-- Member list -->
|
<!-- Member list -->
|
||||||
@@ -51,7 +57,7 @@
|
|||||||
</view>
|
</view>
|
||||||
<view class="member-info">
|
<view class="member-info">
|
||||||
<text class="member-name">{{ m.nickname || '未知用户' }}</text>
|
<text class="member-name">{{ m.nickname || '未知用户' }}</text>
|
||||||
<text class="member-phone">{{ m.phone || '未绑定手机' }}</text>
|
<text class="member-openid">{{ m.openid }}</text>
|
||||||
</view>
|
</view>
|
||||||
<view class="member-stats">
|
<view class="member-stats">
|
||||||
<text class="member-stat-value">{{ m.totalBookings }}</text>
|
<text class="member-stat-value">{{ m.totalBookings }}</text>
|
||||||
@@ -61,9 +67,11 @@
|
|||||||
</view>
|
</view>
|
||||||
</view>
|
</view>
|
||||||
|
|
||||||
<!-- Load more -->
|
<!-- Bottom status -->
|
||||||
<view v-if="hasMore" class="load-more" @tap="loadMore">
|
<view v-if="members.length" class="list-footer">
|
||||||
<text class="load-more-text">{{ loading ? '加载中...' : '加载更多' }}</text>
|
<text class="list-footer-text">
|
||||||
|
{{ loading ? '加载中...' : hasMore ? '上拉加载更多' : '— 已加载全部 —' }}
|
||||||
|
</text>
|
||||||
</view>
|
</view>
|
||||||
|
|
||||||
<!-- Detail modal -->
|
<!-- Detail modal -->
|
||||||
@@ -77,6 +85,9 @@
|
|||||||
</view>
|
</view>
|
||||||
</view>
|
</view>
|
||||||
<text class="detail-name">{{ detailMember.nickname || '未知用户' }}</text>
|
<text class="detail-name">{{ detailMember.nickname || '未知用户' }}</text>
|
||||||
|
<text class="detail-openid" @tap="copyOpenid(detailMember.openid)">
|
||||||
|
{{ detailMember.openid }}
|
||||||
|
</text>
|
||||||
<text class="detail-phone">{{ detailMember.phone || '未绑定手机' }}</text>
|
<text class="detail-phone">{{ detailMember.phone || '未绑定手机' }}</text>
|
||||||
</view>
|
</view>
|
||||||
|
|
||||||
@@ -105,7 +116,9 @@
|
|||||||
|
|
||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { ref, onMounted } from 'vue'
|
import { ref, onMounted } from 'vue'
|
||||||
|
import { onReachBottom } from '@dcloudio/uni-app'
|
||||||
import CustomNavBar from '../../components/CustomNavBar.vue'
|
import CustomNavBar from '../../components/CustomNavBar.vue'
|
||||||
|
import { getSystemLayout } from '../../utils/system'
|
||||||
import { useAdminStore } from '../../stores/admin'
|
import { useAdminStore } from '../../stores/admin'
|
||||||
import type { MemberSummary } from '../../stores/admin'
|
import type { MemberSummary } from '../../stores/admin'
|
||||||
|
|
||||||
@@ -113,8 +126,7 @@ const adminStore = useAdminStore()
|
|||||||
|
|
||||||
const navBarHeight = ref('64px')
|
const navBarHeight = ref('64px')
|
||||||
onMounted(() => {
|
onMounted(() => {
|
||||||
const sys = uni.getSystemInfoSync()
|
navBarHeight.value = `${getSystemLayout().navBarHeight}px`
|
||||||
navBarHeight.value = `${(sys.statusBarHeight ?? 20) + Math.round(88 * sys.windowWidth / 750)}px`
|
|
||||||
})
|
})
|
||||||
|
|
||||||
const members = ref<MemberSummary[]>([])
|
const members = ref<MemberSummary[]>([])
|
||||||
@@ -136,15 +148,16 @@ async function loadMembers(reset = false) {
|
|||||||
}
|
}
|
||||||
loading.value = true
|
loading.value = true
|
||||||
try {
|
try {
|
||||||
|
const search = searchQuery.value.trim()
|
||||||
const result = await adminStore.fetchMembers({
|
const result = await adminStore.fetchMembers({
|
||||||
page: page.value,
|
page: page.value,
|
||||||
limit: LIMIT,
|
limit: LIMIT,
|
||||||
search: searchQuery.value.trim() || undefined,
|
...(search ? { search } : {}),
|
||||||
})
|
})
|
||||||
if (reset) {
|
if (reset) {
|
||||||
members.value = [...result.items]
|
members.value = [...result.items]
|
||||||
} else {
|
} else {
|
||||||
members.value.push(...result.items)
|
members.value = [...members.value, ...result.items]
|
||||||
}
|
}
|
||||||
total.value = result.total
|
total.value = result.total
|
||||||
hasMore.value = members.value.length < result.total
|
hasMore.value = members.value.length < result.total
|
||||||
@@ -159,24 +172,37 @@ function onSearch() {
|
|||||||
loadMembers(true)
|
loadMembers(true)
|
||||||
}
|
}
|
||||||
|
|
||||||
function loadMore() {
|
function onClear() {
|
||||||
|
searchQuery.value = ''
|
||||||
|
loadMembers(true)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Scroll to bottom → load next page
|
||||||
|
onReachBottom(() => {
|
||||||
if (!hasMore.value || loading.value) return
|
if (!hasMore.value || loading.value) return
|
||||||
page.value++
|
page.value++
|
||||||
loadMembers(false)
|
loadMembers(false)
|
||||||
}
|
})
|
||||||
|
|
||||||
function openDetail(m: MemberSummary) {
|
function openDetail(m: MemberSummary) {
|
||||||
detailMember.value = m
|
detailMember.value = m
|
||||||
showDetail.value = true
|
showDetail.value = true
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function copyOpenid(openid: string) {
|
||||||
|
uni.setClipboardData({
|
||||||
|
data: openid,
|
||||||
|
success: () => uni.showToast({ title: '已复制 OpenID', icon: 'success' }),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
onMounted(() => loadMembers(true))
|
onMounted(() => loadMembers(true))
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<style lang="scss" scoped>
|
<style lang="scss" scoped>
|
||||||
.page {
|
.page {
|
||||||
min-height: 100vh;
|
min-height: 100vh;
|
||||||
background: #f5f3f0;
|
background: $bg-page;
|
||||||
padding-bottom: 40rpx;
|
padding-bottom: 40rpx;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -186,27 +212,44 @@ onMounted(() => loadMembers(true))
|
|||||||
align-items: center;
|
align-items: center;
|
||||||
gap: 16rpx;
|
gap: 16rpx;
|
||||||
padding: 24rpx;
|
padding: 24rpx;
|
||||||
background: #ffffff;
|
background: $bg-card;
|
||||||
border-bottom: 1rpx solid #eee;
|
border-bottom: 1rpx solid $border-color;
|
||||||
|
position: relative;
|
||||||
}
|
}
|
||||||
|
|
||||||
.search-input {
|
.search-input {
|
||||||
flex: 1;
|
flex: 1;
|
||||||
height: 72rpx;
|
height: 72rpx;
|
||||||
background: #f5f3f0;
|
background: $bg-page;
|
||||||
border-radius: 36rpx;
|
border-radius: 36rpx;
|
||||||
padding: 0 28rpx;
|
padding: 0 28rpx;
|
||||||
font-size: 26rpx;
|
font-size: 26rpx;
|
||||||
color: #333;
|
color: $text-primary;
|
||||||
|
}
|
||||||
|
|
||||||
|
.search-clear {
|
||||||
|
position: absolute;
|
||||||
|
right: 168rpx;
|
||||||
|
width: 44rpx;
|
||||||
|
height: 44rpx;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.search-clear-icon {
|
||||||
|
font-size: 32rpx;
|
||||||
|
color: $text-hint;
|
||||||
|
line-height: 1;
|
||||||
}
|
}
|
||||||
|
|
||||||
.search-btn {
|
.search-btn {
|
||||||
background: #1a1a2e;
|
background: $brand-color;
|
||||||
border-radius: 36rpx;
|
border-radius: 36rpx;
|
||||||
padding: 16rpx 32rpx;
|
padding: 16rpx 32rpx;
|
||||||
}
|
}
|
||||||
|
|
||||||
.search-btn-text { font-size: 26rpx; font-weight: 600; color: #c9a87c; }
|
.search-btn-text { font-size: 26rpx; font-weight: 600; color: $accent-color; }
|
||||||
|
|
||||||
/* ── Stats row ───────────────────────────── */
|
/* ── Stats row ───────────────────────────── */
|
||||||
.stats-row {
|
.stats-row {
|
||||||
@@ -215,17 +258,17 @@ onMounted(() => loadMembers(true))
|
|||||||
}
|
}
|
||||||
|
|
||||||
.stat-item { display: flex; align-items: baseline; gap: 8rpx; }
|
.stat-item { display: flex; align-items: baseline; gap: 8rpx; }
|
||||||
.stat-value { font-size: 36rpx; font-weight: 800; color: #c9a87c; }
|
.stat-value { font-size: 36rpx; font-weight: 800; color: $accent-color; }
|
||||||
.stat-label { font-size: 24rpx; color: #999; }
|
.stat-label { font-size: 24rpx; color: $text-hint; }
|
||||||
|
|
||||||
/* ── Skeleton ────────────────────────────── */
|
/* ── Skeleton ────────────────────────────── */
|
||||||
.skeleton-list { padding: 0 24rpx; }
|
.skeleton-list { padding: 0 24rpx; }
|
||||||
|
|
||||||
.skeleton-item {
|
.skeleton-item {
|
||||||
height: 100rpx;
|
height: 120rpx;
|
||||||
border-radius: 16rpx;
|
border-radius: $radius-md;
|
||||||
margin-bottom: 16rpx;
|
margin-bottom: 16rpx;
|
||||||
background: linear-gradient(90deg, #f0f0f0 25%, #e0e0e0 50%, #f0f0f0 75%);
|
background: linear-gradient(90deg, #f0f0f0 25%, #e8e8e8 50%, #f0f0f0 75%);
|
||||||
background-size: 400% 100%;
|
background-size: 400% 100%;
|
||||||
animation: shimmer 1.4s infinite;
|
animation: shimmer 1.4s infinite;
|
||||||
}
|
}
|
||||||
@@ -240,25 +283,63 @@ onMounted(() => loadMembers(true))
|
|||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
padding: 100rpx 0;
|
padding: 120rpx 0;
|
||||||
gap: 20rpx;
|
gap: 24rpx;
|
||||||
}
|
}
|
||||||
|
|
||||||
.empty-icon { font-size: 80rpx; }
|
.empty-icon-wrap {
|
||||||
.empty-text { font-size: 28rpx; color: #bbb; }
|
width: 96rpx;
|
||||||
|
height: 96rpx;
|
||||||
|
border-radius: 50%;
|
||||||
|
background: rgba($brand-color, 0.06);
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
position: relative;
|
||||||
|
}
|
||||||
|
|
||||||
|
.empty-icon-person {
|
||||||
|
&::before {
|
||||||
|
content: '';
|
||||||
|
width: 20rpx;
|
||||||
|
height: 20rpx;
|
||||||
|
border: 3rpx solid $text-hint;
|
||||||
|
border-radius: 50%;
|
||||||
|
position: absolute;
|
||||||
|
top: 22rpx;
|
||||||
|
left: 50%;
|
||||||
|
transform: translateX(-50%);
|
||||||
|
box-sizing: border-box;
|
||||||
|
}
|
||||||
|
&::after {
|
||||||
|
content: '';
|
||||||
|
width: 36rpx;
|
||||||
|
height: 16rpx;
|
||||||
|
border: 3rpx solid $text-hint;
|
||||||
|
border-bottom: none;
|
||||||
|
border-radius: 20rpx 20rpx 0 0;
|
||||||
|
position: absolute;
|
||||||
|
bottom: 20rpx;
|
||||||
|
left: 50%;
|
||||||
|
transform: translateX(-50%);
|
||||||
|
box-sizing: border-box;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.empty-text { font-size: 28rpx; color: $text-hint; }
|
||||||
|
|
||||||
/* ── Member list ─────────────────────────── */
|
/* ── Member list ─────────────────────────── */
|
||||||
.member-list { padding: 0 24rpx; padding-top: 8rpx; }
|
.member-list { padding: 0 24rpx; padding-top: 8rpx; }
|
||||||
|
|
||||||
.member-row {
|
.member-row {
|
||||||
background: #ffffff;
|
background: $bg-card;
|
||||||
border-radius: 16rpx;
|
border-radius: $radius-md;
|
||||||
padding: 24rpx;
|
padding: 24rpx;
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
gap: 20rpx;
|
gap: 20rpx;
|
||||||
margin-bottom: 16rpx;
|
margin-bottom: 16rpx;
|
||||||
box-shadow: 0 2rpx 8rpx rgba(0,0,0,0.05);
|
box-shadow: 0 2rpx 8rpx rgba(0, 0, 0, 0.04);
|
||||||
}
|
}
|
||||||
|
|
||||||
.member-avatar {
|
.member-avatar {
|
||||||
@@ -275,7 +356,7 @@ onMounted(() => loadMembers(true))
|
|||||||
width: 80rpx;
|
width: 80rpx;
|
||||||
height: 80rpx;
|
height: 80rpx;
|
||||||
border-radius: 50%;
|
border-radius: 50%;
|
||||||
background: #1a1a2e;
|
background: $brand-color;
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
justify-content: center;
|
justify-content: center;
|
||||||
@@ -289,28 +370,47 @@ onMounted(() => loadMembers(true))
|
|||||||
.avatar-text {
|
.avatar-text {
|
||||||
font-size: 32rpx;
|
font-size: 32rpx;
|
||||||
font-weight: 700;
|
font-weight: 700;
|
||||||
color: #c9a87c;
|
color: $accent-color;
|
||||||
}
|
}
|
||||||
|
|
||||||
.avatar-text--lg { font-size: 48rpx; }
|
.avatar-text--lg { font-size: 48rpx; }
|
||||||
|
|
||||||
.member-info { flex: 1; display: flex; flex-direction: column; gap: 8rpx; }
|
.member-info {
|
||||||
.member-name { font-size: 28rpx; font-weight: 600; color: #1a1a2e; }
|
flex: 1;
|
||||||
.member-phone { font-size: 22rpx; color: #999; }
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
.member-stats { display: flex; flex-direction: column; align-items: flex-end; gap: 4rpx; }
|
gap: 6rpx;
|
||||||
.member-stat-value { font-size: 32rpx; font-weight: 700; color: #c9a87c; }
|
min-width: 0;
|
||||||
.member-stat-label { font-size: 20rpx; color: #bbb; }
|
|
||||||
|
|
||||||
.member-arrow { font-size: 36rpx; color: #ccc; }
|
|
||||||
|
|
||||||
/* ── Load more ───────────────────────────── */
|
|
||||||
.load-more {
|
|
||||||
text-align: center;
|
|
||||||
padding: 32rpx;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.load-more-text { font-size: 26rpx; color: #c9a87c; }
|
.member-name {
|
||||||
|
font-size: 28rpx;
|
||||||
|
font-weight: 600;
|
||||||
|
color: $brand-color;
|
||||||
|
}
|
||||||
|
|
||||||
|
.member-openid {
|
||||||
|
font-size: 20rpx;
|
||||||
|
color: $text-hint;
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
white-space: nowrap;
|
||||||
|
font-family: Menlo, Monaco, Consolas, monospace;
|
||||||
|
}
|
||||||
|
|
||||||
|
.member-stats { display: flex; flex-direction: column; align-items: flex-end; gap: 4rpx; flex-shrink: 0; }
|
||||||
|
.member-stat-value { font-size: 32rpx; font-weight: 700; color: $accent-color; }
|
||||||
|
.member-stat-label { font-size: 20rpx; color: $text-hint; }
|
||||||
|
|
||||||
|
.member-arrow { font-size: 36rpx; color: $text-hint; transform: scaleX(0.6); }
|
||||||
|
|
||||||
|
/* ── List footer ─────────────────────────── */
|
||||||
|
.list-footer {
|
||||||
|
text-align: center;
|
||||||
|
padding: 28rpx 0 16rpx;
|
||||||
|
}
|
||||||
|
|
||||||
|
.list-footer-text { font-size: 24rpx; color: $text-hint; }
|
||||||
|
|
||||||
/* ── Detail modal ────────────────────────── */
|
/* ── Detail modal ────────────────────────── */
|
||||||
.modal-mask {
|
.modal-mask {
|
||||||
@@ -324,8 +424,8 @@ onMounted(() => loadMembers(true))
|
|||||||
|
|
||||||
.modal {
|
.modal {
|
||||||
width: 100%;
|
width: 100%;
|
||||||
background: #ffffff;
|
background: $bg-card;
|
||||||
border-radius: 24rpx 24rpx 0 0;
|
border-radius: $radius-lg $radius-lg 0 0;
|
||||||
padding: 48rpx 32rpx 60rpx;
|
padding: 48rpx 32rpx 60rpx;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -333,7 +433,7 @@ onMounted(() => loadMembers(true))
|
|||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
gap: 16rpx;
|
gap: 12rpx;
|
||||||
margin-bottom: 40rpx;
|
margin-bottom: 40rpx;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -342,33 +442,44 @@ onMounted(() => loadMembers(true))
|
|||||||
height: 120rpx;
|
height: 120rpx;
|
||||||
border-radius: 50%;
|
border-radius: 50%;
|
||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
|
margin-bottom: 8rpx;
|
||||||
}
|
}
|
||||||
|
|
||||||
.detail-name { font-size: 32rpx; font-weight: 700; color: #1a1a2e; }
|
.detail-name { font-size: 32rpx; font-weight: 700; color: $brand-color; }
|
||||||
.detail-phone { font-size: 26rpx; color: #888; }
|
|
||||||
|
.detail-openid {
|
||||||
|
font-size: 22rpx;
|
||||||
|
color: $accent-color;
|
||||||
|
font-family: Menlo, Monaco, Consolas, monospace;
|
||||||
|
padding: 6rpx 16rpx;
|
||||||
|
background: rgba($accent-color, 0.08);
|
||||||
|
border-radius: 8rpx;
|
||||||
|
}
|
||||||
|
|
||||||
|
.detail-phone { font-size: 26rpx; color: $text-secondary; }
|
||||||
|
|
||||||
.detail-stats {
|
.detail-stats {
|
||||||
display: flex;
|
display: flex;
|
||||||
justify-content: space-around;
|
justify-content: space-around;
|
||||||
background: #f5f3f0;
|
background: $bg-page;
|
||||||
border-radius: 16rpx;
|
border-radius: $radius-md;
|
||||||
padding: 28rpx;
|
padding: 28rpx;
|
||||||
margin-bottom: 32rpx;
|
margin-bottom: 32rpx;
|
||||||
}
|
}
|
||||||
|
|
||||||
.detail-stat { display: flex; flex-direction: column; align-items: center; gap: 8rpx; }
|
.detail-stat { display: flex; flex-direction: column; align-items: center; gap: 8rpx; }
|
||||||
.detail-stat-value { font-size: 40rpx; font-weight: 800; color: #c9a87c; }
|
.detail-stat-value { font-size: 40rpx; font-weight: 800; color: $accent-color; }
|
||||||
.detail-stat-label { font-size: 22rpx; color: #999; }
|
.detail-stat-label { font-size: 22rpx; color: $text-hint; }
|
||||||
|
|
||||||
.modal-close {
|
.modal-close {
|
||||||
width: 100%;
|
width: 100%;
|
||||||
height: 88rpx;
|
height: 88rpx;
|
||||||
background: #f0f0f0;
|
background: $bg-page;
|
||||||
border-radius: 44rpx;
|
border-radius: 44rpx;
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
justify-content: center;
|
justify-content: center;
|
||||||
}
|
}
|
||||||
|
|
||||||
.modal-close-text { font-size: 28rpx; color: #555; }
|
.modal-close-text { font-size: 28rpx; color: $text-secondary; }
|
||||||
</style>
|
</style>
|
||||||
|
|||||||
@@ -1,22 +1,44 @@
|
|||||||
<template>
|
<template>
|
||||||
<view class="page" :style="{ paddingTop: navBarHeight }">
|
<view class="page" :style="{ paddingTop: navBarHeight }">
|
||||||
<CustomNavBar title="订单管理" show-back />
|
<CustomNavBar title="订单管理" show-back />
|
||||||
|
|
||||||
|
<!-- Summary stats bar -->
|
||||||
|
<view class="stats-bar">
|
||||||
|
<view class="stat-item">
|
||||||
|
<text class="stat-num">{{ totalCount || '--' }}</text>
|
||||||
|
<text class="stat-label">全部订单</text>
|
||||||
|
</view>
|
||||||
|
<view class="stat-divider" />
|
||||||
|
<view class="stat-item">
|
||||||
|
<text class="stat-num paid">{{ paidCount || '--' }}</text>
|
||||||
|
<text class="stat-label">已支付</text>
|
||||||
|
</view>
|
||||||
|
<view class="stat-divider" />
|
||||||
|
<view class="stat-item">
|
||||||
|
<text class="stat-num pending">{{ pendingCount || '--' }}</text>
|
||||||
|
<text class="stat-label">待支付</text>
|
||||||
|
</view>
|
||||||
|
</view>
|
||||||
|
|
||||||
<!-- Status filter tabs -->
|
<!-- Status filter tabs -->
|
||||||
|
<view class="filter-wrap">
|
||||||
<scroll-view scroll-x class="filter-scroll" :show-scrollbar="false">
|
<scroll-view scroll-x class="filter-scroll" :show-scrollbar="false">
|
||||||
<view class="filter-row">
|
<view class="filter-row">
|
||||||
<view
|
<view
|
||||||
v-for="f in filters"
|
v-for="f in filters"
|
||||||
:key="f.value"
|
:key="f.value"
|
||||||
class="filter-chip"
|
class="filter-pill"
|
||||||
:class="{ 'filter-chip--active': activeFilter === f.value }"
|
:class="{ active: activeFilter === f.value }"
|
||||||
@tap="selectFilter(f.value)"
|
@tap="selectFilter(f.value)"
|
||||||
>
|
>
|
||||||
<text class="filter-chip-text">{{ f.label }}</text>
|
<text class="filter-pill-text">{{ f.label }}</text>
|
||||||
|
<view v-if="f.count != null" class="filter-pill-dot" />
|
||||||
</view>
|
</view>
|
||||||
</view>
|
</view>
|
||||||
</scroll-view>
|
</scroll-view>
|
||||||
|
</view>
|
||||||
|
|
||||||
<!-- Pull-to-refresh wrapper -->
|
<!-- Pull-to-refresh -->
|
||||||
<scroll-view
|
<scroll-view
|
||||||
scroll-y
|
scroll-y
|
||||||
class="list-scroll"
|
class="list-scroll"
|
||||||
@@ -25,60 +47,95 @@
|
|||||||
@refresherrefresh="onRefresh"
|
@refresherrefresh="onRefresh"
|
||||||
>
|
>
|
||||||
<!-- Loading skeleton -->
|
<!-- Loading skeleton -->
|
||||||
<view v-if="loading && !orders.length" class="skeleton-list">
|
<view v-if="loading && !orders.length" class="order-list">
|
||||||
<view v-for="i in 5" :key="i" class="skeleton-item" />
|
<view v-for="i in 5" :key="i" class="skeleton-card" />
|
||||||
</view>
|
</view>
|
||||||
|
|
||||||
<!-- Empty -->
|
<!-- Empty -->
|
||||||
<view v-else-if="!loading && !orders.length" class="empty-state">
|
<view v-else-if="!loading && !orders.length" class="empty-state">
|
||||||
<text class="empty-icon">📋</text>
|
<view class="empty-illustration">
|
||||||
<text class="empty-text">暂无订单</text>
|
<text class="empty-icon">📭</text>
|
||||||
|
</view>
|
||||||
|
<text class="empty-title">暂无订单</text>
|
||||||
|
<text class="empty-sub">当前筛选条件下没有找到订单</text>
|
||||||
</view>
|
</view>
|
||||||
|
|
||||||
<!-- Order list -->
|
<!-- Order cards -->
|
||||||
<view v-else class="order-list">
|
<view v-else class="order-list">
|
||||||
<view v-for="order in orders" :key="order.id" class="order-card">
|
<view
|
||||||
<view class="order-header">
|
v-for="(order, idx) in orders"
|
||||||
<text class="order-card-name">{{ order.cardType?.name ?? '-' }}</text>
|
:key="order.id"
|
||||||
<view class="order-status-badge" :class="statusBadgeClass(order.status)">
|
class="order-card"
|
||||||
<text class="order-status-text">{{ statusLabel(order.status) }}</text>
|
:class="{ 'order-card--paid': order.status === OrderStatus.PAID, 'order-card--pending': order.status === OrderStatus.PENDING }"
|
||||||
|
:style="{ animationDelay: `${idx * 40}ms` }"
|
||||||
|
>
|
||||||
|
<!-- Card accent bar -->
|
||||||
|
<view class="card-accent" :class="statusAccentClass(order.status)" />
|
||||||
|
|
||||||
|
<!-- Card header -->
|
||||||
|
<view class="card-header">
|
||||||
|
<view class="card-title-row">
|
||||||
|
<text class="card-plan">{{ order.cardType?.name ?? '未知套餐' }}</text>
|
||||||
|
<view class="badge" :class="statusBadgeClass(order.status)">
|
||||||
|
<text class="badge-text">{{ statusLabel(order.status) }}</text>
|
||||||
</view>
|
</view>
|
||||||
</view>
|
</view>
|
||||||
<view class="order-body">
|
<text class="card-order-no">#{{ order.orderNo }}</text>
|
||||||
<view class="order-row">
|
|
||||||
<text class="order-row-label">用户</text>
|
|
||||||
<text class="order-row-value">{{ order.user?.nickname ?? '-' }}</text>
|
|
||||||
</view>
|
</view>
|
||||||
<view class="order-row">
|
|
||||||
<text class="order-row-label">手机</text>
|
<!-- Card divider -->
|
||||||
<text class="order-row-value">{{ order.user?.phone ?? '未绑定' }}</text>
|
<view class="card-divider" />
|
||||||
|
|
||||||
|
<!-- Card body -->
|
||||||
|
<view class="card-body">
|
||||||
|
<view class="info-row">
|
||||||
|
<view class="info-left">
|
||||||
|
<text class="info-label">用户</text>
|
||||||
|
<text class="info-value">{{ order.user?.nickname ?? '未知用户' }}</text>
|
||||||
</view>
|
</view>
|
||||||
<view class="order-row">
|
<view class="info-right">
|
||||||
<text class="order-row-label">金额</text>
|
<text class="info-label">手机</text>
|
||||||
<text class="order-row-value order-price">¥{{ formatPrice(order.amount) }}</text>
|
<text class="info-value mono">{{ order.user?.phone ?? '未绑定' }}</text>
|
||||||
</view>
|
</view>
|
||||||
<view class="order-row">
|
</view>
|
||||||
<text class="order-row-label">时间</text>
|
|
||||||
<text class="order-row-value">{{ formatDate(order.createdAt) }}</text>
|
<view class="info-row">
|
||||||
|
<view class="info-left">
|
||||||
|
<text class="info-label">金额</text>
|
||||||
|
<text class="info-value price">¥{{ formatPrice(order.amount) }}</text>
|
||||||
|
</view>
|
||||||
|
<view class="info-right">
|
||||||
|
<text class="info-label">下单时间</text>
|
||||||
|
<text class="info-value">{{ formatDate(order.createdAt) }}</text>
|
||||||
|
</view>
|
||||||
|
</view>
|
||||||
|
|
||||||
|
<!-- Paid time if available -->
|
||||||
|
<view v-if="order.paidAt && order.status === OrderStatus.PAID" class="info-row">
|
||||||
|
<text class="info-label">支付时间</text>
|
||||||
|
<text class="info-value">{{ formatDate(order.paidAt) }}</text>
|
||||||
</view>
|
</view>
|
||||||
</view>
|
</view>
|
||||||
</view>
|
</view>
|
||||||
</view>
|
</view>
|
||||||
|
|
||||||
<!-- Load more -->
|
<!-- Load more / no more -->
|
||||||
<view v-if="hasMore" class="load-more" @tap="loadMore">
|
<view v-if="hasMore" class="load-more" @tap="loadMore">
|
||||||
<text class="load-more-text">{{ loading ? '加载中...' : '加载更多' }}</text>
|
<text class="load-more-text">{{ loading ? '加载中...' : '加载更多' }}</text>
|
||||||
</view>
|
</view>
|
||||||
|
<view v-else-if="orders.length > 0" class="no-more">
|
||||||
|
<text class="no-more-text">— 已加载全部 {{ orders.length }} 条订单 —</text>
|
||||||
|
</view>
|
||||||
|
|
||||||
<!-- Bottom spacer -->
|
<view style="height: 60rpx" />
|
||||||
<view style="height: 40rpx;" />
|
|
||||||
</scroll-view>
|
</scroll-view>
|
||||||
</view>
|
</view>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { ref, onMounted } from 'vue'
|
import { ref, computed, onMounted } from 'vue'
|
||||||
import CustomNavBar from '../../components/CustomNavBar.vue'
|
import CustomNavBar from '../../components/CustomNavBar.vue'
|
||||||
|
import { getSystemLayout } from '../../utils/system'
|
||||||
import { useAdminStore } from '../../stores/admin'
|
import { useAdminStore } from '../../stores/admin'
|
||||||
import { formatPrice, formatDate } from '../../utils/format'
|
import { formatPrice, formatDate } from '../../utils/format'
|
||||||
import { OrderStatus } from '@mp-pilates/shared'
|
import { OrderStatus } from '@mp-pilates/shared'
|
||||||
@@ -88,15 +145,14 @@ const adminStore = useAdminStore()
|
|||||||
|
|
||||||
const navBarHeight = ref('64px')
|
const navBarHeight = ref('64px')
|
||||||
onMounted(() => {
|
onMounted(() => {
|
||||||
const sys = uni.getSystemInfoSync()
|
navBarHeight.value = `${getSystemLayout().navBarHeight}px`
|
||||||
navBarHeight.value = `${(sys.statusBarHeight ?? 20) + Math.round(88 * sys.windowWidth / 750)}px`
|
|
||||||
})
|
})
|
||||||
|
|
||||||
const filters = [
|
const filters = [
|
||||||
{ label: '全部', value: '' },
|
{ label: '全部', value: '', count: null },
|
||||||
{ label: '已支付', value: OrderStatus.PAID },
|
{ label: '已支付', value: OrderStatus.PAID, count: null },
|
||||||
{ label: '待支付', value: OrderStatus.PENDING },
|
{ label: '待支付', value: OrderStatus.PENDING, count: null },
|
||||||
{ label: '已退款', value: OrderStatus.REFUNDED },
|
{ label: '已退款', value: OrderStatus.REFUNDED, count: null },
|
||||||
]
|
]
|
||||||
|
|
||||||
const activeFilter = ref('')
|
const activeFilter = ref('')
|
||||||
@@ -105,6 +161,9 @@ const loading = ref(false)
|
|||||||
const refreshing = ref(false)
|
const refreshing = ref(false)
|
||||||
const page = ref(1)
|
const page = ref(1)
|
||||||
const hasMore = ref(false)
|
const hasMore = ref(false)
|
||||||
|
const totalCount = ref<number | null>(null)
|
||||||
|
const paidCount = ref<number | null>(null)
|
||||||
|
const pendingCount = ref<number | null>(null)
|
||||||
|
|
||||||
const LIMIT = 20
|
const LIMIT = 20
|
||||||
|
|
||||||
@@ -124,25 +183,31 @@ function statusBadgeClass(s: string) {
|
|||||||
return 'badge--default'
|
return 'badge--default'
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function statusAccentClass(s: string) {
|
||||||
|
if (s === OrderStatus.PAID) return 'accent--paid'
|
||||||
|
if (s === OrderStatus.PENDING) return 'accent--pending'
|
||||||
|
if (s === OrderStatus.REFUNDED) return 'accent--refunded'
|
||||||
|
return ''
|
||||||
|
}
|
||||||
|
|
||||||
async function loadOrders(reset = false) {
|
async function loadOrders(reset = false) {
|
||||||
if (loading.value) return
|
if (loading.value) return
|
||||||
if (reset) {
|
if (reset) page.value = 1
|
||||||
page.value = 1
|
|
||||||
orders.value = []
|
|
||||||
}
|
|
||||||
loading.value = true
|
loading.value = true
|
||||||
try {
|
try {
|
||||||
const result = await adminStore.fetchAdminOrders({
|
const params: { page: number; limit: number; status?: string } = {
|
||||||
page: page.value,
|
page: page.value,
|
||||||
limit: LIMIT,
|
limit: LIMIT,
|
||||||
status: activeFilter.value || undefined,
|
}
|
||||||
})
|
if (activeFilter.value) params.status = activeFilter.value
|
||||||
|
const result = await adminStore.fetchAdminOrders(params)
|
||||||
if (reset) {
|
if (reset) {
|
||||||
orders.value = [...result.items]
|
orders.value = [...result.data]
|
||||||
} else {
|
} else {
|
||||||
orders.value.push(...result.items)
|
orders.value.push(...result.data)
|
||||||
}
|
}
|
||||||
hasMore.value = orders.value.length < result.total
|
hasMore.value = orders.value.length < result.total
|
||||||
|
totalCount.value = result.total
|
||||||
} catch {
|
} catch {
|
||||||
uni.showToast({ title: '加载失败', icon: 'none' })
|
uni.showToast({ title: '加载失败', icon: 'none' })
|
||||||
} finally {
|
} finally {
|
||||||
@@ -151,14 +216,30 @@ async function loadOrders(reset = false) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async function loadSummaryCounts() {
|
||||||
|
try {
|
||||||
|
const [allResult, paidResult, pendingResult] = await Promise.all([
|
||||||
|
adminStore.fetchAdminOrders({ page: 1, limit: 1 }),
|
||||||
|
adminStore.fetchAdminOrders({ page: 1, limit: 1, status: OrderStatus.PAID }),
|
||||||
|
adminStore.fetchAdminOrders({ page: 1, limit: 1, status: OrderStatus.PENDING }),
|
||||||
|
])
|
||||||
|
totalCount.value = allResult.total
|
||||||
|
paidCount.value = paidResult.total
|
||||||
|
pendingCount.value = pendingResult.total
|
||||||
|
} catch {
|
||||||
|
// non-critical, ignore
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
function selectFilter(value: string) {
|
function selectFilter(value: string) {
|
||||||
activeFilter.value = value
|
activeFilter.value = value
|
||||||
|
totalCount.value = null
|
||||||
loadOrders(true)
|
loadOrders(true)
|
||||||
}
|
}
|
||||||
|
|
||||||
async function onRefresh() {
|
async function onRefresh() {
|
||||||
refreshing.value = true
|
refreshing.value = true
|
||||||
await loadOrders(true)
|
await Promise.all([loadOrders(true), loadSummaryCounts()])
|
||||||
}
|
}
|
||||||
|
|
||||||
function loadMore() {
|
function loadMore() {
|
||||||
@@ -167,65 +248,140 @@ function loadMore() {
|
|||||||
loadOrders(false)
|
loadOrders(false)
|
||||||
}
|
}
|
||||||
|
|
||||||
onMounted(() => loadOrders(true))
|
onMounted(() => {
|
||||||
|
loadOrders(true)
|
||||||
|
loadSummaryCounts()
|
||||||
|
})
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<style lang="scss" scoped>
|
<style lang="scss" scoped>
|
||||||
|
/* ── Page shell ──────────────────────────────── */
|
||||||
.page {
|
.page {
|
||||||
height: 100vh;
|
height: 100vh;
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
background: #f5f3f0;
|
background: #FAF8F5;
|
||||||
|
font-family: -apple-system, BlinkMacSystemFont, 'PingFang SC', sans-serif;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* ── Filter scroll ───────────────────────── */
|
/* ── Stats bar ──────────────────────────────── */
|
||||||
.filter-scroll {
|
.stats-bar {
|
||||||
flex-shrink: 0;
|
display: flex;
|
||||||
background: #ffffff;
|
align-items: center;
|
||||||
border-bottom: 1rpx solid #eee;
|
background: #FFFFFF;
|
||||||
|
padding: 28rpx 0;
|
||||||
|
margin: 0;
|
||||||
|
border-bottom: 1rpx solid rgba(180, 160, 130, 0.2);
|
||||||
|
box-shadow: 0 2rpx 12rpx rgba(180, 160, 130, 0.08);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.stat-item {
|
||||||
|
flex: 1;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: center;
|
||||||
|
gap: 8rpx;
|
||||||
|
}
|
||||||
|
|
||||||
|
.stat-num {
|
||||||
|
font-size: 42rpx;
|
||||||
|
font-weight: 700;
|
||||||
|
color: #4A4035;
|
||||||
|
letter-spacing: -1rpx;
|
||||||
|
line-height: 1;
|
||||||
|
font-family: 'DIN Alternate', 'Helvetica Neue', Arial, sans-serif;
|
||||||
|
}
|
||||||
|
|
||||||
|
.stat-num.paid { color: #7A9E7E; }
|
||||||
|
.stat-num.pending { color: #C4956A; }
|
||||||
|
|
||||||
|
.stat-label {
|
||||||
|
font-size: 22rpx;
|
||||||
|
color: #A09080;
|
||||||
|
letter-spacing: 1rpx;
|
||||||
|
}
|
||||||
|
|
||||||
|
.stat-divider {
|
||||||
|
width: 1rpx;
|
||||||
|
height: 48rpx;
|
||||||
|
background: rgba(180, 160, 130, 0.25);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── Filter pills ───────────────────────────── */
|
||||||
|
.filter-wrap {
|
||||||
|
background: #FAF8F5;
|
||||||
|
border-bottom: 1rpx solid rgba(180, 160, 130, 0.15);
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.filter-scroll { overflow: hidden; }
|
||||||
|
|
||||||
.filter-row {
|
.filter-row {
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
padding: 16rpx 24rpx;
|
padding: 20rpx 28rpx;
|
||||||
gap: 16rpx;
|
gap: 16rpx;
|
||||||
white-space: nowrap;
|
white-space: nowrap;
|
||||||
}
|
}
|
||||||
|
|
||||||
.filter-chip {
|
.filter-pill {
|
||||||
|
position: relative;
|
||||||
display: inline-flex;
|
display: inline-flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
height: 60rpx;
|
gap: 8rpx;
|
||||||
padding: 0 28rpx;
|
height: 64rpx;
|
||||||
border-radius: 30rpx;
|
padding: 0 32rpx;
|
||||||
background: #f0f0f0;
|
border-radius: 32rpx;
|
||||||
|
background: rgba(180, 160, 130, 0.1);
|
||||||
|
border: 1.5rpx solid rgba(180, 160, 130, 0.2);
|
||||||
flex-shrink: 0;
|
flex-shrink: 0;
|
||||||
|
transition: all 0.22s ease;
|
||||||
}
|
}
|
||||||
|
|
||||||
.filter-chip--active {
|
.filter-pill.active {
|
||||||
background: #1a1a2e;
|
background: #4A4035;
|
||||||
|
border-color: #4A4035;
|
||||||
}
|
}
|
||||||
|
|
||||||
.filter-chip-text { font-size: 26rpx; color: #888; }
|
.filter-pill-text {
|
||||||
.filter-chip--active .filter-chip-text { color: #c9a87c; font-weight: 600; }
|
font-size: 26rpx;
|
||||||
|
color: #7A6A5A;
|
||||||
|
font-weight: 500;
|
||||||
|
transition: color 0.22s ease;
|
||||||
|
}
|
||||||
|
|
||||||
/* ── List scroll ─────────────────────────── */
|
.filter-pill.active .filter-pill-text {
|
||||||
|
color: #E8D8C0;
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
|
||||||
|
.filter-pill-dot {
|
||||||
|
width: 6rpx;
|
||||||
|
height: 6rpx;
|
||||||
|
border-radius: 50%;
|
||||||
|
background: #B08050;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── List ───────────────────────────────────── */
|
||||||
.list-scroll {
|
.list-scroll {
|
||||||
flex: 1;
|
flex: 1;
|
||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* ── Skeleton ────────────────────────────── */
|
.order-list {
|
||||||
.skeleton-list { padding: 24rpx; }
|
padding: 20rpx 24rpx 0;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 20rpx;
|
||||||
|
}
|
||||||
|
|
||||||
.skeleton-item {
|
/* ── Skeleton ───────────────────────────────── */
|
||||||
height: 180rpx;
|
.skeleton-card {
|
||||||
border-radius: 16rpx;
|
height: 220rpx;
|
||||||
margin-bottom: 16rpx;
|
border-radius: 20rpx;
|
||||||
background: linear-gradient(90deg, #f0f0f0 25%, #e0e0e0 50%, #f0f0f0 75%);
|
background: linear-gradient(90deg, #F0EBE3 25%, #E8E0D5 50%, #F0EBE3 75%);
|
||||||
background-size: 400% 100%;
|
background-size: 400% 100%;
|
||||||
animation: shimmer 1.4s infinite;
|
animation: shimmer 1.6s ease infinite;
|
||||||
}
|
}
|
||||||
|
|
||||||
@keyframes shimmer {
|
@keyframes shimmer {
|
||||||
@@ -233,70 +389,185 @@ onMounted(() => loadOrders(true))
|
|||||||
100% { background-position: -100% 0; }
|
100% { background-position: -100% 0; }
|
||||||
}
|
}
|
||||||
|
|
||||||
/* ── Empty ───────────────────────────────── */
|
/* ── Empty ──────────────────────────────────── */
|
||||||
.empty-state {
|
.empty-state {
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
padding: 120rpx 0;
|
padding: 120rpx 48rpx;
|
||||||
gap: 20rpx;
|
gap: 12rpx;
|
||||||
}
|
}
|
||||||
|
|
||||||
.empty-icon { font-size: 80rpx; }
|
.empty-illustration {
|
||||||
.empty-text { font-size: 28rpx; color: #bbb; }
|
width: 120rpx;
|
||||||
|
height: 120rpx;
|
||||||
|
border-radius: 60rpx;
|
||||||
|
background: rgba(180, 160, 130, 0.15);
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
margin-bottom: 12rpx;
|
||||||
|
}
|
||||||
|
|
||||||
/* ── Order list ──────────────────────────── */
|
.empty-icon { font-size: 56rpx; }
|
||||||
.order-list { padding: 16rpx 24rpx 0; }
|
|
||||||
|
|
||||||
|
.empty-title {
|
||||||
|
font-size: 32rpx;
|
||||||
|
font-weight: 600;
|
||||||
|
color: #4A4035;
|
||||||
|
letter-spacing: 0.5rpx;
|
||||||
|
}
|
||||||
|
|
||||||
|
.empty-sub {
|
||||||
|
font-size: 26rpx;
|
||||||
|
color: rgba(74, 64, 53, 0.4);
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── Order card ─────────────────────────────── */
|
||||||
.order-card {
|
.order-card {
|
||||||
background: #ffffff;
|
position: relative;
|
||||||
border-radius: 16rpx;
|
background: #FFFFFF;
|
||||||
overflow: hidden;
|
|
||||||
margin-bottom: 20rpx;
|
|
||||||
box-shadow: 0 2rpx 12rpx rgba(0,0,0,0.06);
|
|
||||||
}
|
|
||||||
|
|
||||||
.order-header {
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
justify-content: space-between;
|
|
||||||
padding: 20rpx 24rpx;
|
|
||||||
border-bottom: 1rpx solid #f5f5f5;
|
|
||||||
}
|
|
||||||
|
|
||||||
.order-card-name { font-size: 28rpx; font-weight: 600; color: #1a1a2e; }
|
|
||||||
|
|
||||||
.order-status-badge {
|
|
||||||
border-radius: 20rpx;
|
border-radius: 20rpx;
|
||||||
padding: 6rpx 20rpx;
|
overflow: hidden;
|
||||||
|
box-shadow: 0 4rpx 20rpx rgba(180, 160, 130, 0.12);
|
||||||
|
animation: cardIn 0.4s ease both;
|
||||||
}
|
}
|
||||||
|
|
||||||
.badge--paid { background: rgba(39,174,96,0.1); }
|
@keyframes cardIn {
|
||||||
.badge--paid .order-status-text { font-size: 22rpx; color: #27ae60; }
|
from { opacity: 0; transform: translateY(12rpx); }
|
||||||
.badge--pending { background: rgba(230,126,34,0.1); }
|
to { opacity: 1; transform: translateY(0); }
|
||||||
.badge--pending .order-status-text { font-size: 22rpx; color: #e67e22; }
|
}
|
||||||
.badge--refunded { background: rgba(0,0,0,0.06); }
|
|
||||||
.badge--refunded .order-status-text { font-size: 22rpx; color: #999; }
|
|
||||||
.badge--default .order-status-text { font-size: 22rpx; color: #888; }
|
|
||||||
|
|
||||||
.order-body { padding: 16rpx 24rpx; }
|
.card-accent {
|
||||||
|
position: absolute;
|
||||||
|
left: 0;
|
||||||
|
top: 0;
|
||||||
|
bottom: 0;
|
||||||
|
width: 6rpx;
|
||||||
|
}
|
||||||
|
|
||||||
.order-row {
|
.accent--paid { background: #8FCB9B; }
|
||||||
|
.accent--pending { background: #F2C94C; }
|
||||||
|
.accent--refunded { background: rgba(43, 43, 43, 0.2); }
|
||||||
|
|
||||||
|
.card-header {
|
||||||
|
padding: 24rpx 24rpx 20rpx 30rpx;
|
||||||
|
}
|
||||||
|
|
||||||
|
.card-title-row {
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
justify-content: space-between;
|
justify-content: space-between;
|
||||||
padding: 10rpx 0;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.order-row-label { font-size: 24rpx; color: #999; }
|
.card-plan {
|
||||||
.order-row-value { font-size: 26rpx; color: #333; }
|
font-size: 30rpx;
|
||||||
.order-price { font-size: 28rpx; font-weight: 700; color: #c9a87c; }
|
font-weight: 700;
|
||||||
|
color: #4A4035;
|
||||||
|
letter-spacing: 0.5rpx;
|
||||||
|
}
|
||||||
|
|
||||||
/* ── Load more ───────────────────────────── */
|
.card-order-no {
|
||||||
|
font-size: 22rpx;
|
||||||
|
color: rgba(74, 64, 53, 0.35);
|
||||||
|
margin-top: 6rpx;
|
||||||
|
display: block;
|
||||||
|
font-family: 'SF Mono', 'Menlo', monospace;
|
||||||
|
}
|
||||||
|
|
||||||
|
.card-divider {
|
||||||
|
height: 1rpx;
|
||||||
|
background: rgba(180, 160, 130, 0.15);
|
||||||
|
margin: 0 24rpx;
|
||||||
|
}
|
||||||
|
|
||||||
|
.card-body {
|
||||||
|
padding: 20rpx 24rpx 20rpx 30rpx;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 12rpx;
|
||||||
|
}
|
||||||
|
|
||||||
|
.info-row {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.info-left,
|
||||||
|
.info-right {
|
||||||
|
flex: 1;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 12rpx;
|
||||||
|
}
|
||||||
|
|
||||||
|
.info-right {
|
||||||
|
justify-content: flex-end;
|
||||||
|
}
|
||||||
|
|
||||||
|
.info-label {
|
||||||
|
font-size: 24rpx;
|
||||||
|
color: rgba(74, 64, 53, 0.4);
|
||||||
|
flex-shrink: 0;
|
||||||
|
min-width: 80rpx;
|
||||||
|
}
|
||||||
|
|
||||||
|
.info-value {
|
||||||
|
font-size: 26rpx;
|
||||||
|
color: #4A4035;
|
||||||
|
font-weight: 500;
|
||||||
|
}
|
||||||
|
|
||||||
|
.info-value.mono {
|
||||||
|
font-family: 'SF Mono', 'Menlo', monospace;
|
||||||
|
font-size: 24rpx;
|
||||||
|
}
|
||||||
|
|
||||||
|
.info-value.price {
|
||||||
|
font-size: 30rpx;
|
||||||
|
font-weight: 700;
|
||||||
|
color: #B08050;
|
||||||
|
font-family: 'DIN Alternate', 'Helvetica Neue', Arial, sans-serif;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── Status badges ─────────────────────────── */
|
||||||
|
.badge {
|
||||||
|
border-radius: 8rpx;
|
||||||
|
padding: 4rpx 14rpx;
|
||||||
|
}
|
||||||
|
|
||||||
|
.badge--paid { background: rgba(122, 158, 126, 0.15); }
|
||||||
|
.badge--paid .badge-text { font-size: 22rpx; color: #5A7E5E; font-weight: 600; }
|
||||||
|
|
||||||
|
.badge--pending { background: rgba(196, 149, 106, 0.2); }
|
||||||
|
.badge--pending .badge-text { font-size: 22rpx; color: #A07540; font-weight: 600; }
|
||||||
|
|
||||||
|
.badge--refunded { background: rgba(180, 160, 130, 0.15); }
|
||||||
|
.badge--refunded .badge-text { font-size: 22rpx; color: #8A7A6A; }
|
||||||
|
|
||||||
|
.badge--default .badge-text { font-size: 22rpx; color: #888; }
|
||||||
|
|
||||||
|
/* ── Load more ─────────────────────────────── */
|
||||||
.load-more {
|
.load-more {
|
||||||
text-align: center;
|
text-align: center;
|
||||||
padding: 32rpx;
|
padding: 40rpx 0 20rpx;
|
||||||
}
|
}
|
||||||
|
|
||||||
.load-more-text { font-size: 26rpx; color: #c9a87c; }
|
.load-more-text {
|
||||||
|
font-size: 26rpx;
|
||||||
|
color: #B08050;
|
||||||
|
font-weight: 500;
|
||||||
|
}
|
||||||
|
|
||||||
|
.no-more {
|
||||||
|
text-align: center;
|
||||||
|
padding: 32rpx 0 20rpx;
|
||||||
|
}
|
||||||
|
|
||||||
|
.no-more-text {
|
||||||
|
font-size: 24rpx;
|
||||||
|
color: rgba(74, 64, 53, 0.3);
|
||||||
|
letter-spacing: 0.5rpx;
|
||||||
|
}
|
||||||
</style>
|
</style>
|
||||||
|
|||||||
@@ -158,6 +158,7 @@
|
|||||||
import { ref, computed, onMounted } from 'vue'
|
import { ref, computed, onMounted } from 'vue'
|
||||||
import type { ScheduleSlotPreview } from '@mp-pilates/shared'
|
import type { ScheduleSlotPreview } from '@mp-pilates/shared'
|
||||||
import CustomNavBar from '../../components/CustomNavBar.vue'
|
import CustomNavBar from '../../components/CustomNavBar.vue'
|
||||||
|
import { getSystemLayout } from '../../utils/system'
|
||||||
import { useAdminStore } from '../../stores/admin'
|
import { useAdminStore } from '../../stores/admin'
|
||||||
import { formatDate } from '../../utils/format'
|
import { formatDate } from '../../utils/format'
|
||||||
import DateSelector from '../../components/DateSelector.vue'
|
import DateSelector from '../../components/DateSelector.vue'
|
||||||
@@ -409,8 +410,7 @@ function slotBadgeText(slot: EditableSlot): string {
|
|||||||
// ── Lifecycle ─────────────────────────────────────────────
|
// ── Lifecycle ─────────────────────────────────────────────
|
||||||
|
|
||||||
onMounted(() => {
|
onMounted(() => {
|
||||||
const sys = uni.getSystemInfoSync()
|
navBarHeight.value = `${getSystemLayout().navBarHeight}px`
|
||||||
navBarHeight.value = `${(sys.statusBarHeight ?? 20) + Math.round(88 * sys.windowWidth / 750)}px`
|
|
||||||
loadPreview(selectedDate.value)
|
loadPreview(selectedDate.value)
|
||||||
})
|
})
|
||||||
</script>
|
</script>
|
||||||
|
|||||||
@@ -141,6 +141,7 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { ref, onMounted } from 'vue'
|
import { ref, onMounted } from 'vue'
|
||||||
import CustomNavBar from '../../components/CustomNavBar.vue'
|
import CustomNavBar from '../../components/CustomNavBar.vue'
|
||||||
|
import { getSystemLayout } from '../../utils/system'
|
||||||
import { useAdminStore } from '../../stores/admin'
|
import { useAdminStore } from '../../stores/admin'
|
||||||
import { formatDate } from '../../utils/format'
|
import { formatDate } from '../../utils/format'
|
||||||
import type { TimeSlot } from '@mp-pilates/shared'
|
import type { TimeSlot } from '@mp-pilates/shared'
|
||||||
@@ -247,8 +248,7 @@ async function submitGenerate() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
onMounted(() => {
|
onMounted(() => {
|
||||||
const sys = uni.getSystemInfoSync()
|
navBarHeight.value = `${getSystemLayout().navBarHeight}px`
|
||||||
navBarHeight.value = `${(sys.statusBarHeight ?? 20) + Math.round(88 * sys.windowWidth / 750)}px`
|
|
||||||
})
|
})
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
|
|||||||
@@ -152,14 +152,14 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { ref, computed, onMounted } from 'vue'
|
import { ref, computed, onMounted } from 'vue'
|
||||||
import CustomNavBar from '../../components/CustomNavBar.vue'
|
import CustomNavBar from '../../components/CustomNavBar.vue'
|
||||||
|
import { getSystemLayout } from '../../utils/system'
|
||||||
import { useAdminStore } from '../../stores/admin'
|
import { useAdminStore } from '../../stores/admin'
|
||||||
|
|
||||||
const adminStore = useAdminStore()
|
const adminStore = useAdminStore()
|
||||||
|
|
||||||
const navBarHeight = ref('64px')
|
const navBarHeight = ref('64px')
|
||||||
onMounted(() => {
|
onMounted(() => {
|
||||||
const sys = uni.getSystemInfoSync()
|
navBarHeight.value = `${getSystemLayout().navBarHeight}px`
|
||||||
navBarHeight.value = `${(sys.statusBarHeight ?? 20) + Math.round(88 * sys.windowWidth / 750)}px`
|
|
||||||
})
|
})
|
||||||
|
|
||||||
const form = ref({
|
const form = ref({
|
||||||
|
|||||||
@@ -139,6 +139,7 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { ref, computed, onMounted } from 'vue'
|
import { ref, computed, onMounted } from 'vue'
|
||||||
import CustomNavBar from '../../components/CustomNavBar.vue'
|
import CustomNavBar from '../../components/CustomNavBar.vue'
|
||||||
|
import { getSystemLayout } from '../../utils/system'
|
||||||
import { useAdminStore } from '../../stores/admin'
|
import { useAdminStore } from '../../stores/admin'
|
||||||
import { WEEKDAY_LABELS } from '@mp-pilates/shared'
|
import { WEEKDAY_LABELS } from '@mp-pilates/shared'
|
||||||
import type { WeekTemplate } from '@mp-pilates/shared'
|
import type { WeekTemplate } from '@mp-pilates/shared'
|
||||||
@@ -322,8 +323,7 @@ async function handleSave() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
onMounted(() => {
|
onMounted(() => {
|
||||||
const sys = uni.getSystemInfoSync()
|
navBarHeight.value = `${getSystemLayout().navBarHeight}px`
|
||||||
navBarHeight.value = `${(sys.statusBarHeight ?? 20) + Math.round(88 * sys.windowWidth / 750)}px`
|
|
||||||
fetchTemplates()
|
fetchTemplates()
|
||||||
})
|
})
|
||||||
</script>
|
</script>
|
||||||
|
|||||||
@@ -85,6 +85,7 @@ import { TIME_PERIODS } from '@mp-pilates/shared'
|
|||||||
import { useBookingStore } from '../../stores/booking'
|
import { useBookingStore } from '../../stores/booking'
|
||||||
import { useUserStore } from '../../stores/user'
|
import { useUserStore } from '../../stores/user'
|
||||||
import { formatDate } from '../../utils/format'
|
import { formatDate } from '../../utils/format'
|
||||||
|
import { getSystemLayout } from '../../utils/system'
|
||||||
import DateSelector from '../../components/DateSelector.vue'
|
import DateSelector from '../../components/DateSelector.vue'
|
||||||
import TimePeriodFilter from '../../components/TimePeriodFilter.vue'
|
import TimePeriodFilter from '../../components/TimePeriodFilter.vue'
|
||||||
import SlotCard from '../../components/SlotCard.vue'
|
import SlotCard from '../../components/SlotCard.vue'
|
||||||
@@ -128,9 +129,8 @@ const FILTER_HEADER_RPX = 240 // DateSelector + TimePeriodFilter
|
|||||||
const TABBAR_RPX = 100
|
const TABBAR_RPX = 100
|
||||||
|
|
||||||
function updateLayout() {
|
function updateLayout() {
|
||||||
const sysInfo = uni.getSystemInfoSync()
|
const { statusBarHeight: statusBarPx, windowWidth } = getSystemLayout()
|
||||||
const ratio = sysInfo.windowWidth / 750
|
const ratio = windowWidth / 750
|
||||||
const statusBarPx = sysInfo.statusBarHeight ?? 20
|
|
||||||
statusBarHeight.value = `${statusBarPx}px`
|
statusBarHeight.value = `${statusBarPx}px`
|
||||||
|
|
||||||
const headerPx = Math.round(PAGE_HEADER_RPX * ratio)
|
const headerPx = Math.round(PAGE_HEADER_RPX * ratio)
|
||||||
@@ -138,7 +138,8 @@ function updateLayout() {
|
|||||||
const tabbarPx = Math.round(TABBAR_RPX * ratio)
|
const tabbarPx = Math.round(TABBAR_RPX * ratio)
|
||||||
|
|
||||||
// scroll-view fills remaining space: window - statusBar - pageHeader - filters - tabbar
|
// scroll-view fills remaining space: window - statusBar - pageHeader - filters - tabbar
|
||||||
const remaining = sysInfo.windowHeight - statusBarPx - headerPx - filterPx - tabbarPx
|
const { windowHeight } = uni.getSystemInfoSync()
|
||||||
|
const remaining = windowHeight - statusBarPx - headerPx - filterPx - tabbarPx
|
||||||
scrollHeight.value = `${remaining}px`
|
scrollHeight.value = `${remaining}px`
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -130,6 +130,7 @@ import type { CardType, CreateOrderResponse } from '@mp-pilates/shared'
|
|||||||
import { CardTypeCategory } from '@mp-pilates/shared'
|
import { CardTypeCategory } from '@mp-pilates/shared'
|
||||||
import { get, post } from '../../utils/request'
|
import { get, post } from '../../utils/request'
|
||||||
import { formatPrice } from '../../utils/format'
|
import { formatPrice } from '../../utils/format'
|
||||||
|
import { getSystemLayout } from '../../utils/system'
|
||||||
import { useUserStore } from '../../stores/user'
|
import { useUserStore } from '../../stores/user'
|
||||||
import CustomNavBar from '../../components/CustomNavBar.vue'
|
import CustomNavBar from '../../components/CustomNavBar.vue'
|
||||||
|
|
||||||
@@ -278,8 +279,7 @@ async function doPurchase() {
|
|||||||
|
|
||||||
// ─── Lifecycle ────────────────────────────────────────────
|
// ─── Lifecycle ────────────────────────────────────────────
|
||||||
onMounted(() => {
|
onMounted(() => {
|
||||||
const sys = uni.getSystemInfoSync()
|
navBarHeight.value = `${getSystemLayout().navBarHeight}px`
|
||||||
navBarHeight.value = `${(sys.statusBarHeight ?? 20) + Math.round(88 * sys.windowWidth / 750)}px`
|
|
||||||
|
|
||||||
const pages = getCurrentPages()
|
const pages = getCurrentPages()
|
||||||
const current = pages[pages.length - 1]
|
const current = pages[pages.length - 1]
|
||||||
|
|||||||
@@ -52,6 +52,7 @@ import CardShop from '../../components/CardShop.vue'
|
|||||||
import { useUserStore } from '../../stores/user'
|
import { useUserStore } from '../../stores/user'
|
||||||
import { useStudioStore } from '../../stores/studio'
|
import { useStudioStore } from '../../stores/studio'
|
||||||
import { useBookingStore } from '../../stores/booking'
|
import { useBookingStore } from '../../stores/booking'
|
||||||
|
import { getSystemLayout } from '../../utils/system'
|
||||||
|
|
||||||
const userStore = useUserStore()
|
const userStore = useUserStore()
|
||||||
const studioStore = useStudioStore()
|
const studioStore = useStudioStore()
|
||||||
@@ -77,11 +78,9 @@ onShareTimeline(() => {
|
|||||||
const navBarHeight = ref('64px')
|
const navBarHeight = ref('64px')
|
||||||
|
|
||||||
function updateLayout() {
|
function updateLayout() {
|
||||||
const sysInfo = uni.getSystemInfoSync()
|
const { statusBarHeight: statusBarPx, windowWidth, navBarHeight: navBarPx } = getSystemLayout()
|
||||||
const ratio = sysInfo.windowWidth / 750
|
const ratio = windowWidth / 750
|
||||||
const statusBarPx = sysInfo.statusBarHeight ?? 20
|
|
||||||
const navTitlePx = 88 * ratio
|
const navTitlePx = 88 * ratio
|
||||||
const navBarPx = Math.round(statusBarPx + navTitlePx)
|
|
||||||
navBarHeight.value = `${navBarPx}px`
|
navBarHeight.value = `${navBarPx}px`
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -36,6 +36,7 @@ import { ref, onMounted } from 'vue'
|
|||||||
import { onShow, onShareAppMessage, onShareTimeline } from '@dcloudio/uni-app'
|
import { onShow, onShareAppMessage, onShareTimeline } from '@dcloudio/uni-app'
|
||||||
import { storeToRefs } from 'pinia'
|
import { storeToRefs } from 'pinia'
|
||||||
import { useUserStore } from '../../stores/user'
|
import { useUserStore } from '../../stores/user'
|
||||||
|
import { getSystemLayout } from '../../utils/system'
|
||||||
import UserCard from '../../components/UserCard.vue'
|
import UserCard from '../../components/UserCard.vue'
|
||||||
import ProfileMenu from '../../components/ProfileMenu.vue'
|
import ProfileMenu from '../../components/ProfileMenu.vue'
|
||||||
import CustomNavBar from '../../components/CustomNavBar.vue'
|
import CustomNavBar from '../../components/CustomNavBar.vue'
|
||||||
@@ -63,10 +64,7 @@ onShareTimeline(() => {
|
|||||||
})
|
})
|
||||||
|
|
||||||
onMounted(() => {
|
onMounted(() => {
|
||||||
const sysInfo = uni.getSystemInfoSync()
|
navBarHeight.value = getSystemLayout().navBarHeight
|
||||||
const statusBarPx = sysInfo.statusBarHeight ?? 20
|
|
||||||
const navTitlePx = 88 * (sysInfo.windowWidth / 750)
|
|
||||||
navBarHeight.value = Math.round(statusBarPx + navTitlePx)
|
|
||||||
})
|
})
|
||||||
|
|
||||||
onShow(async () => {
|
onShow(async () => {
|
||||||
|
|||||||
@@ -85,6 +85,7 @@
|
|||||||
import { ref, computed, onMounted } from 'vue'
|
import { ref, computed, onMounted } from 'vue'
|
||||||
import { useUserStore } from '../../stores/user'
|
import { useUserStore } from '../../stores/user'
|
||||||
import { wxBindPhone } from '../../utils/auth'
|
import { wxBindPhone } from '../../utils/auth'
|
||||||
|
import { getSystemLayout } from '../../utils/system'
|
||||||
import CustomNavBar from '../../components/CustomNavBar.vue'
|
import CustomNavBar from '../../components/CustomNavBar.vue'
|
||||||
|
|
||||||
const userStore = useUserStore()
|
const userStore = useUserStore()
|
||||||
@@ -216,8 +217,7 @@ async function handleSave() {
|
|||||||
|
|
||||||
// ─── Lifecycle ────────────────────────────────────────────
|
// ─── Lifecycle ────────────────────────────────────────────
|
||||||
onMounted(async () => {
|
onMounted(async () => {
|
||||||
const sys = uni.getSystemInfoSync()
|
navBarHeight.value = `${getSystemLayout().navBarHeight}px`
|
||||||
navBarHeight.value = `${(sys.statusBarHeight ?? 20) + Math.round(88 * sys.windowWidth / 750)}px`
|
|
||||||
await userStore.fetchProfile()
|
await userStore.fetchProfile()
|
||||||
if (userStore.user) {
|
if (userStore.user) {
|
||||||
form.value = { nickname: userStore.user.nickname }
|
form.value = { nickname: userStore.user.nickname }
|
||||||
|
|||||||
@@ -147,6 +147,7 @@ import { ref, computed, onMounted } from 'vue'
|
|||||||
import type { MembershipWithCardType } from '@mp-pilates/shared'
|
import type { MembershipWithCardType } from '@mp-pilates/shared'
|
||||||
import { MembershipStatus, CardTypeCategory } from '@mp-pilates/shared'
|
import { MembershipStatus, CardTypeCategory } from '@mp-pilates/shared'
|
||||||
import { useUserStore } from '../../stores/user'
|
import { useUserStore } from '../../stores/user'
|
||||||
|
import { getSystemLayout } from '../../utils/system'
|
||||||
import CustomNavBar from '../../components/CustomNavBar.vue'
|
import CustomNavBar from '../../components/CustomNavBar.vue'
|
||||||
|
|
||||||
const userStore = useUserStore()
|
const userStore = useUserStore()
|
||||||
@@ -240,8 +241,7 @@ function goStore() {
|
|||||||
|
|
||||||
// ─── Lifecycle ────────────────────────────────────────────
|
// ─── Lifecycle ────────────────────────────────────────────
|
||||||
onMounted(() => {
|
onMounted(() => {
|
||||||
const sys = uni.getSystemInfoSync()
|
navBarHeight.value = `${getSystemLayout().navBarHeight}px`
|
||||||
navBarHeight.value = `${(sys.statusBarHeight ?? 20) + Math.round(88 * sys.windowWidth / 750)}px`
|
|
||||||
loadMemberships()
|
loadMemberships()
|
||||||
})
|
})
|
||||||
</script>
|
</script>
|
||||||
|
|||||||
BIN
packages/app/src/static/default-avatar.jpg
Normal file
BIN
packages/app/src/static/default-avatar.jpg
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 135 KiB |
@@ -25,6 +25,7 @@ export interface AdminStats {
|
|||||||
|
|
||||||
export interface MemberSummary {
|
export interface MemberSummary {
|
||||||
userId: string
|
userId: string
|
||||||
|
openid: string
|
||||||
nickname: string
|
nickname: string
|
||||||
phone: string | null
|
phone: string | null
|
||||||
avatarUrl: string | null
|
avatarUrl: string | null
|
||||||
@@ -115,7 +116,12 @@ export const useAdminStore = defineStore('admin', () => {
|
|||||||
limit?: number
|
limit?: number
|
||||||
search?: string
|
search?: string
|
||||||
}): Promise<PaginatedData<MemberSummary>> {
|
}): Promise<PaginatedData<MemberSummary>> {
|
||||||
return get<PaginatedData<MemberSummary>>('/admin/members', params)
|
// Filter out undefined/empty values to avoid sending "undefined" as string
|
||||||
|
const cleanParams: Record<string, unknown> = {}
|
||||||
|
if (params?.page != null) cleanParams.page = params.page
|
||||||
|
if (params?.limit != null) cleanParams.limit = params.limit
|
||||||
|
if (params?.search) cleanParams.search = params.search
|
||||||
|
return get<PaginatedData<MemberSummary>>('/admin/members', cleanParams)
|
||||||
}
|
}
|
||||||
|
|
||||||
// ── Time slots ───────────────────────────────────────────────────
|
// ── Time slots ───────────────────────────────────────────────────
|
||||||
|
|||||||
54
packages/app/src/utils/system.ts
Normal file
54
packages/app/src/utils/system.ts
Normal file
@@ -0,0 +1,54 @@
|
|||||||
|
/**
|
||||||
|
* System info utilities — replaces deprecated uni.getSystemInfoSync()
|
||||||
|
* with the recommended granular APIs.
|
||||||
|
*/
|
||||||
|
|
||||||
|
interface SystemLayout {
|
||||||
|
/** Status bar height in px */
|
||||||
|
readonly statusBarHeight: number
|
||||||
|
/** Window width in px */
|
||||||
|
readonly windowWidth: number
|
||||||
|
/** Custom nav bar height in px (status bar + title bar) */
|
||||||
|
readonly navBarHeight: number
|
||||||
|
}
|
||||||
|
|
||||||
|
let cached: SystemLayout | null = null
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Returns layout dimensions using the new granular APIs.
|
||||||
|
* Falls back to getSystemInfoSync only if the new APIs are unavailable.
|
||||||
|
* Results are cached since these values never change during a session.
|
||||||
|
*/
|
||||||
|
export function getSystemLayout(): SystemLayout {
|
||||||
|
if (cached) return cached
|
||||||
|
|
||||||
|
let statusBarHeight = 20
|
||||||
|
let windowWidth = 375
|
||||||
|
|
||||||
|
try {
|
||||||
|
// New recommended APIs (WeChat base lib >= 2.25.3)
|
||||||
|
const windowInfo = uni.getWindowInfo()
|
||||||
|
const deviceInfo = uni.getDeviceInfo()
|
||||||
|
|
||||||
|
statusBarHeight = windowInfo.statusBarHeight ?? 20
|
||||||
|
windowWidth = windowInfo.windowWidth ?? 375
|
||||||
|
|
||||||
|
// Silence unused var — deviceInfo is here for future use
|
||||||
|
void deviceInfo
|
||||||
|
} catch {
|
||||||
|
// Fallback for older base lib versions
|
||||||
|
try {
|
||||||
|
const sysInfo = uni.getSystemInfoSync()
|
||||||
|
statusBarHeight = sysInfo.statusBarHeight ?? 20
|
||||||
|
windowWidth = sysInfo.windowWidth ?? 375
|
||||||
|
} catch {
|
||||||
|
// Use defaults
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const navTitlePx = 88 * (windowWidth / 750)
|
||||||
|
const navBarHeight = Math.round(statusBarHeight + navTitlePx)
|
||||||
|
|
||||||
|
cached = { statusBarHeight, windowWidth, navBarHeight }
|
||||||
|
return cached
|
||||||
|
}
|
||||||
37
packages/server/src/admin/admin.controller.ts
Normal file
37
packages/server/src/admin/admin.controller.ts
Normal file
@@ -0,0 +1,37 @@
|
|||||||
|
import { Controller, Get, UseGuards } from '@nestjs/common'
|
||||||
|
import { JwtAuthGuard } from '../auth/jwt-auth.guard'
|
||||||
|
import { Roles } from '../auth/roles.decorator'
|
||||||
|
import { RolesGuard } from '../auth/roles.guard'
|
||||||
|
import { UserRole } from '@mp-pilates/shared'
|
||||||
|
import { PrismaService } from '../prisma/prisma.service'
|
||||||
|
|
||||||
|
interface AdminStats {
|
||||||
|
todayBookings: number
|
||||||
|
totalOrders: number
|
||||||
|
totalBookings: number
|
||||||
|
}
|
||||||
|
|
||||||
|
@Controller('admin')
|
||||||
|
@UseGuards(JwtAuthGuard, RolesGuard)
|
||||||
|
@Roles(UserRole.ADMIN)
|
||||||
|
export class AdminController {
|
||||||
|
constructor(private readonly prisma: PrismaService) {}
|
||||||
|
|
||||||
|
@Get('stats')
|
||||||
|
async getStats(): Promise<AdminStats> {
|
||||||
|
const today = new Date()
|
||||||
|
today.setUTCHours(0, 0, 0, 0)
|
||||||
|
|
||||||
|
const [todayBookings, totalOrders, totalBookings] = await Promise.all([
|
||||||
|
this.prisma.booking.count({
|
||||||
|
where: {
|
||||||
|
timeSlot: { date: today },
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
this.prisma.order.count(),
|
||||||
|
this.prisma.booking.count(),
|
||||||
|
])
|
||||||
|
|
||||||
|
return { todayBookings, totalOrders, totalBookings }
|
||||||
|
}
|
||||||
|
}
|
||||||
7
packages/server/src/admin/admin.module.ts
Normal file
7
packages/server/src/admin/admin.module.ts
Normal file
@@ -0,0 +1,7 @@
|
|||||||
|
import { Module } from '@nestjs/common'
|
||||||
|
import { AdminController } from './admin.controller'
|
||||||
|
|
||||||
|
@Module({
|
||||||
|
controllers: [AdminController],
|
||||||
|
})
|
||||||
|
export class AdminModule {}
|
||||||
@@ -10,6 +10,7 @@ import { MembershipModule } from './membership/membership.module'
|
|||||||
import { BookingModule } from './booking/booking.module'
|
import { BookingModule } from './booking/booking.module'
|
||||||
import { SchedulerModule } from './scheduler/scheduler.module'
|
import { SchedulerModule } from './scheduler/scheduler.module'
|
||||||
import { PaymentModule } from './payment/payment.module'
|
import { PaymentModule } from './payment/payment.module'
|
||||||
|
import { AdminModule } from './admin/admin.module'
|
||||||
|
|
||||||
@Module({
|
@Module({
|
||||||
imports: [
|
imports: [
|
||||||
@@ -26,6 +27,7 @@ import { PaymentModule } from './payment/payment.module'
|
|||||||
BookingModule,
|
BookingModule,
|
||||||
SchedulerModule,
|
SchedulerModule,
|
||||||
PaymentModule,
|
PaymentModule,
|
||||||
|
AdminModule,
|
||||||
],
|
],
|
||||||
controllers: [AppController],
|
controllers: [AppController],
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -9,7 +9,7 @@ import {
|
|||||||
UseGuards,
|
UseGuards,
|
||||||
ValidationPipe,
|
ValidationPipe,
|
||||||
} from '@nestjs/common'
|
} from '@nestjs/common'
|
||||||
import { UserRole } from '@mp-pilates/shared'
|
import { UserRole, OrderStatus } from '@mp-pilates/shared'
|
||||||
import { JwtAuthGuard } from '../auth/jwt-auth.guard'
|
import { JwtAuthGuard } from '../auth/jwt-auth.guard'
|
||||||
import { RolesGuard } from '../auth/roles.guard'
|
import { RolesGuard } from '../auth/roles.guard'
|
||||||
import { Roles } from '../auth/roles.decorator'
|
import { Roles } from '../auth/roles.decorator'
|
||||||
@@ -85,7 +85,7 @@ export class PaymentController {
|
|||||||
return this.paymentService.getAllOrders(
|
return this.paymentService.getAllOrders(
|
||||||
page ? parseInt(page, 10) : 1,
|
page ? parseInt(page, 10) : 1,
|
||||||
limit ? parseInt(limit, 10) : 10,
|
limit ? parseInt(limit, 10) : 10,
|
||||||
status as any,
|
status ? (status as OrderStatus) : undefined,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -3,24 +3,28 @@ import {
|
|||||||
Get,
|
Get,
|
||||||
Put,
|
Put,
|
||||||
Body,
|
Body,
|
||||||
|
Query,
|
||||||
UseGuards,
|
UseGuards,
|
||||||
} from '@nestjs/common'
|
} from '@nestjs/common'
|
||||||
|
import { UserRole } from '@mp-pilates/shared'
|
||||||
import { JwtAuthGuard } from '../auth/jwt-auth.guard'
|
import { JwtAuthGuard } from '../auth/jwt-auth.guard'
|
||||||
|
import { RolesGuard } from '../auth/roles.guard'
|
||||||
|
import { Roles } from '../auth/roles.decorator'
|
||||||
import { CurrentUser } from '../common/decorators/current-user.decorator'
|
import { CurrentUser } from '../common/decorators/current-user.decorator'
|
||||||
import { UserService } from './user.service'
|
import { UserService } from './user.service'
|
||||||
import { UpdateProfileDto } from './dto/update-profile.dto'
|
import { UpdateProfileDto } from './dto/update-profile.dto'
|
||||||
|
|
||||||
@UseGuards(JwtAuthGuard)
|
@UseGuards(JwtAuthGuard)
|
||||||
@Controller('user')
|
@Controller()
|
||||||
export class UserController {
|
export class UserController {
|
||||||
constructor(private readonly userService: UserService) {}
|
constructor(private readonly userService: UserService) {}
|
||||||
|
|
||||||
@Get('profile')
|
@Get('user/profile')
|
||||||
getProfile(@CurrentUser('sub') userId: string) {
|
getProfile(@CurrentUser('sub') userId: string) {
|
||||||
return this.userService.getProfile(userId)
|
return this.userService.getProfile(userId)
|
||||||
}
|
}
|
||||||
|
|
||||||
@Put('profile')
|
@Put('user/profile')
|
||||||
updateProfile(
|
updateProfile(
|
||||||
@CurrentUser('sub') userId: string,
|
@CurrentUser('sub') userId: string,
|
||||||
@Body() dto: UpdateProfileDto,
|
@Body() dto: UpdateProfileDto,
|
||||||
@@ -28,8 +32,25 @@ export class UserController {
|
|||||||
return this.userService.updateProfile(userId, dto)
|
return this.userService.updateProfile(userId, dto)
|
||||||
}
|
}
|
||||||
|
|
||||||
@Get('stats')
|
@Get('user/stats')
|
||||||
getStats(@CurrentUser('sub') userId: string) {
|
getStats(@CurrentUser('sub') userId: string) {
|
||||||
return this.userService.getStats(userId)
|
return this.userService.getStats(userId)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ─── Admin: Member Management ─────────────────────────────────────────────
|
||||||
|
|
||||||
|
@Get('admin/members')
|
||||||
|
@UseGuards(RolesGuard)
|
||||||
|
@Roles(UserRole.ADMIN)
|
||||||
|
getMembers(
|
||||||
|
@Query('page') page?: string,
|
||||||
|
@Query('limit') limit?: string,
|
||||||
|
@Query('search') search?: string,
|
||||||
|
) {
|
||||||
|
return this.userService.getMembers(
|
||||||
|
page ? Number(page) : 1,
|
||||||
|
limit ? Number(limit) : 20,
|
||||||
|
search && search !== 'undefined' ? search : undefined,
|
||||||
|
)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,8 +1,10 @@
|
|||||||
import { Module } from '@nestjs/common'
|
import { Module } from '@nestjs/common'
|
||||||
|
import { AuthModule } from '../auth/auth.module'
|
||||||
import { UserController } from './user.controller'
|
import { UserController } from './user.controller'
|
||||||
import { UserService } from './user.service'
|
import { UserService } from './user.service'
|
||||||
|
|
||||||
@Module({
|
@Module({
|
||||||
|
imports: [AuthModule],
|
||||||
controllers: [UserController],
|
controllers: [UserController],
|
||||||
providers: [UserService],
|
providers: [UserService],
|
||||||
exports: [UserService],
|
exports: [UserService],
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
import { Injectable, NotFoundException } from '@nestjs/common'
|
import { Injectable, NotFoundException } from '@nestjs/common'
|
||||||
import { MembershipStatus, BookingStatus, UserRole } from '@mp-pilates/shared'
|
import { MembershipStatus, BookingStatus, UserRole } from '@mp-pilates/shared'
|
||||||
|
import type { PaginatedData, UserProfileResponse, UserStatsResponse } from '@mp-pilates/shared'
|
||||||
import { PrismaService } from '../prisma/prisma.service'
|
import { PrismaService } from '../prisma/prisma.service'
|
||||||
import type { UserProfileResponse, UserStatsResponse } from '@mp-pilates/shared'
|
|
||||||
|
|
||||||
@Injectable()
|
@Injectable()
|
||||||
export class UserService {
|
export class UserService {
|
||||||
@@ -117,4 +117,89 @@ export class UserService {
|
|||||||
monthHours,
|
monthHours,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ─── Admin: paginated member list ─────────────────────────────────────────
|
||||||
|
|
||||||
|
async getMembers(
|
||||||
|
page: number,
|
||||||
|
limit: number,
|
||||||
|
search?: string,
|
||||||
|
): Promise<PaginatedData<{
|
||||||
|
userId: string
|
||||||
|
openid: string
|
||||||
|
nickname: string
|
||||||
|
phone: string | null
|
||||||
|
avatarUrl: string | null
|
||||||
|
totalBookings: number
|
||||||
|
completedBookings: number
|
||||||
|
cancelledBookings: number
|
||||||
|
}>> {
|
||||||
|
const where = search
|
||||||
|
? {
|
||||||
|
OR: [
|
||||||
|
{ nickname: { contains: search, mode: 'insensitive' as const } },
|
||||||
|
{ openid: { contains: search, mode: 'insensitive' as const } },
|
||||||
|
{ phone: { contains: search } },
|
||||||
|
],
|
||||||
|
}
|
||||||
|
: {}
|
||||||
|
|
||||||
|
const [users, total] = await Promise.all([
|
||||||
|
this.prisma.user.findMany({
|
||||||
|
where,
|
||||||
|
select: {
|
||||||
|
id: true,
|
||||||
|
openid: true,
|
||||||
|
nickname: true,
|
||||||
|
phone: true,
|
||||||
|
avatarUrl: true,
|
||||||
|
_count: {
|
||||||
|
select: {
|
||||||
|
bookings: true,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
orderBy: { createdAt: 'desc' },
|
||||||
|
skip: (page - 1) * limit,
|
||||||
|
take: limit,
|
||||||
|
}),
|
||||||
|
this.prisma.user.count({ where }),
|
||||||
|
])
|
||||||
|
|
||||||
|
// Batch-fetch booking stats for the page of users
|
||||||
|
const userIds = users.map((u) => u.id)
|
||||||
|
|
||||||
|
const bookingStats = userIds.length
|
||||||
|
? await this.prisma.booking.groupBy({
|
||||||
|
by: ['userId', 'status'],
|
||||||
|
where: { userId: { in: userIds } },
|
||||||
|
_count: { id: true },
|
||||||
|
})
|
||||||
|
: []
|
||||||
|
|
||||||
|
const statsMap = new Map<string, { total: number; completed: number; cancelled: number }>()
|
||||||
|
for (const stat of bookingStats) {
|
||||||
|
const entry = statsMap.get(stat.userId) ?? { total: 0, completed: 0, cancelled: 0 }
|
||||||
|
entry.total += stat._count.id
|
||||||
|
if (stat.status === BookingStatus.COMPLETED) entry.completed += stat._count.id
|
||||||
|
if (stat.status === BookingStatus.CANCELLED) entry.cancelled += stat._count.id
|
||||||
|
statsMap.set(stat.userId, entry)
|
||||||
|
}
|
||||||
|
|
||||||
|
const items = users.map((u) => {
|
||||||
|
const s = statsMap.get(u.id) ?? { total: 0, completed: 0, cancelled: 0 }
|
||||||
|
return {
|
||||||
|
userId: u.id,
|
||||||
|
openid: u.openid,
|
||||||
|
nickname: u.nickname,
|
||||||
|
phone: u.phone,
|
||||||
|
avatarUrl: u.avatarUrl,
|
||||||
|
totalBookings: s.total,
|
||||||
|
completedBookings: s.completed,
|
||||||
|
cancelledBookings: s.cancelled,
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
return { items, total, page, limit }
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
File diff suppressed because one or more lines are too long
Reference in New Issue
Block a user